使用 NDbUnit 和 XPath 查询对 .NET 数据库应用程序进行单元测试的方法






4.50/5 (4投票s)
2007年6月22日
12分钟阅读

71790

431
重点是如何测试数据库内容是否符合特定假设
引言
出于多种原因,任何开发人员都应该是单元测试的忠实拥护者。如果为一个软件单元编写了一套好的单元测试,那么在任何时候都可以验证代码是否仍然按照编写单元测试的开发人员的设想运行。这能让您作为开发人员对您的代码更有信心。这是因为关于代码的所有假设,在不同的单元测试中得到表达,都可以在任何时间点进行验证。所有开发人员都感受到这种需求,尤其是在代码发生一些更改且更改的影响尚不可预见之后。更容易理解单元测试的长期好处,但在短期内并不那么明显。尤其是在工具支持不太好的情况下,您必须花费大量时间来设置单元测试所需的框架。
幸运的是,有多种工具可以帮助编写 .NET 代码的单元测试。最具代表性的大概是 NUnit。在许多情况下,NUnit 本身就能胜任。在其他情况下,必须使用各种扩展,例如 ASPUnit、NUnitForms、不同的 mock 库等。另一个这样的扩展是 NDbUnit,它允许将数据库置于特定状态。不幸的是,在测试数据库应用程序时,将测试数据库置于初始状态只是问题的一部分。仍然需要一种简单的方法来验证在对数据库应用某些处理后数据库的(部分)内容。理想情况下,这应该像从 NUnit 调用 Assert
方法一样简单。然而,由于数据库内容可能比原子值更复杂,因此检查内容也可能有点复杂。
本文展示了如何使用 DataSet、XML 和 XPath 查询以紧凑的形式表达关于数据库内容的假设。结合 NDbUnit,它们可以实现相当紧凑的单元测试开发,并且可以在相对较短的时间内编写。我已经将此方法应用于我当前项目中多个 SQL Server Integration Service (SSIS) 包的测试。然而,处理数据库的模块与本文无关,因为本文侧重于检查数据库内容以验证处理结果是否正确,无论处理是如何执行的。只需要一种方法从单元测试中启动数据库处理。在我的项目中,我不得不从单元测试中启动 SSIS 包,但在大多数情况下,数据库处理是通过 ADO.NET 完成的。解决方案中提供的示例不会以任何方式处理数据库内容,而是侧重于检查数据库中已有的内容。数据库内容是使用 NDbUnit 加载的。
数据库架构
为了说明本文提出的概念,我将使用一个简化的著名 NorthWind 数据库版本。我将侧重于 Customers、Orders 和 OrderDetail 表的简化版本。数据库架构如下所示。为了重新创建数据库,您必须执行解决方案中包含的 TSQL 脚本 CreateTables.SQL
。
设置数据库内容
使用 NDbUnit 可以非常轻松地将特定内容加载到数据库中
SqlDbUnitTest dbUnitTest = new SqlDbUnitTest(connectionString);
dbUnitTest.ReadXmlSchema(xsdStream);
dbUnitTest.ReadXml(dataStream);
dbUnitTest.PerformDbOperation(operation);
在此代码片段中,xsdStream
必须包含数据库的架构,而 dataStream
是将加载到数据库中的数据库内容。加载数据时,将执行 DbOperationFlag enum
定义的操作之一。
public enum DbOperationFlag
{
/// No operation.
None,
/// Insert rows into a set of database tables.
Insert,
/// Insert rows into a set of database tables. Allow identity
/// inserts to occur.
InsertIdentity,
/// Delete rows from a set of database tables.
Delete,
/// Delete all rows from a set of database tables.
DeleteAll,
/// Update rows in a set of database tables.
Update,
/// Refresh rows in a set of database tables. Rows that exist
/// in the database are updated. Rows that don't exist are inserted.
Refresh,
/// Composite operation of DeleteAll and Insert.
CleanInsert,
/// Composite operation of DeleteAll and InsertIdentity.
CleanInsertIdentity
}
在 Visual Studio 中生成与被测数据库关联的类型化数据集很容易。首先,必须在 Server Explorer 面板中定义数据库连接。然后向您的项目添加一个新的 DataSet 文件,并在设计器窗口中查看它。最后一步是将数据库连接中的所有表拖放到设计器窗口中。数据库架构发生任何更改后,还必须更新类型化数据集关联的 XSD 文件,以反映数据库架构的更改。如果 XSD 文件与数据库架构不同步,NDbUnit 将不会生成任何异常来帮助您理解为什么数据库内容未按预期设置。示例数据库的类型化数据集生成在文件 DBSchema.Designer.cs 中。
提供数据库内容的 XML 文件可以使用编辑器或 Visual Studio 创建。Visual Studio 提供 Intellisense 来帮助您创建此文件。但是,如果您已将所需内容存储在数据库中,则可以很容易地将其导出到 XML 文件,例如使用 Altova 的 XMLSpy。只需确保指示您希望根据您已创建的 XSD 文件的定义创建导出。
分析结果
在测试数据库内容时,可以想象许多场景。这是由于数据库字段之间可能存在的各种关联所致。我在这里将说明两个类别。第一组允许在全局级别测试数据库内容,而第二组允许详细测试内容。全局级别测试在您只需要知道一切是否按预期进行时很有用。如果测试失败,您可能找不到原因,在这种情况下,第二类可能更好。
全局内容测试
测试数据在单个表中
这是最容易测试的数据库内容。它适用于全局测试,当开发人员希望验证整个表的内容是否与预期内容相等时。在失败的情况下,测试将无助于确定失败的原因。此类测试的一个示例是 GlobalTesting
fixture 中定义的 TestCustomers
。该测试验证 Customers 表的内容是否与文件 ExpectedCustomers.xml 中定义的内容相同。
可以通过将表内容加载到数据集,然后比较加载行的字段与所需值来执行测试。但是,您必须为每一行编写多个 assert 才能测试该行是否具有预期内容。ResultInspector
类中定义的以下 2 个帮助方法允许测试 2 行或 2 个表的内容是否相等
方法 1
public static bool AreEqual(DataTable expected, DataTable actual)
{
Assert.AreEqual(expected.Rows.Count,
actual.Rows.Count, "Different number of rows");
for(int i=0; i > expected.Rows.Count; i++)
{
AreEqual(expected.Rows[i], actual.Rows[i], i);
}
return true;
}
方法 2
public static bool AreEqual(DataRow expected, DataRow actual, int rowIndex)
{
Assert.AreEqual(expected.ItemArray.Length, actual.ItemArray.Length);
for (int i = 0; i > expected.ItemArray.Length; i++)
{
Assert.AreEqual(expected[i], actual[i], "Difference on row:" +
rowIndex.ToString() + ", column:" + expected.Table.Columns[i]);
}
return true;
}
编写断言变得像
Assert.IsTrue(
ResultInspector.AreEqual(
ResultInspector.GetExpectedTable(
"Schemas.DBSchema.xsd",
"TestData.ExpectedCustomers.xml",
"Customers"
),
GetDatabaseCustomers()
)
);
GetDatabaseCustomers
从数据库加载表,GetExpectedTable
从 XML 资源加载预期内容。对于 GetExpectedTable
方法,您必须在 XML 文件中定义您在数据库中预期的内容。对于 GetDatabaseCustomers
,您必须编写 SQL 查询,从数据库中提取所需内容。
一个重要的注意事项:定义 DataTable
参数的 AreEqual
方法是基于表中行的顺序执行相等性检查的。这意味着您必须在编写的 SQL 查询中指定 ORDER BY
子句,并在创建指定预期内容的 XML 文件时使用相同的顺序。AreEqual
方法仅对由预期参数指定的字段执行相等性测试,即使数据库中的实际表包含许多其他字段。如果您只对几个字段感兴趣,这使得创建和维护预期结果 XML 变得更容易。
这种类型的测试可以外推到整个数据集的级别,而不是单个表。但是,这通常更难,因为在大多数数据库中,表之间的关系基于一些内部标识符,这些标识符仅在数据库内部有意义。这使得指定数据库的预期内容变得困难。如果可以控制这些内部标识符的创建方式,这会更难,但并非不可能。
使用 JOIN 和自定义 XSD
跨多个表分布并通过某些内部标识符关联的测试相关数据,可以通过为每个测试定义一个自定义 XSD 来简化到前一种情况,该 XSD 指定了要测试的字段。通过编写一个连接表和关系标识符并选择要测试的字段的 SQL 查询来隐藏内部标识符。
为了说明这种方法,让我们考虑单元测试 MarchOrders
。在此测试中,我们想检查某些员工代表某些客户在 2006 年 3 月创建了订单。我们对 CompanyName、FirstName、LastName 和 OrderDate 字段感兴趣。这些字段来自 Employees、Orders、Customers 表,时间范围是 2006 年 3 月 1 日至 31 日。测试 MarchOrders
检查是否属实。GetMarchOrders
方法中定义的以下 SQL 查询负责检索被测试的字段并隐藏内部标识符
SELECT CompanyName, FirstName, LastName, OrderDate
FROM Customers
JOIN Orders ON (Customers.CustomerID = Orders.CustomerID )
JOIN Employees ON (Orders.EmployeeID = Employees.EmployeeID)
ORDER BY CompanyName, FirstName, LastName, OrderDate"
关联的 XSD 定义在 CustomersEmployeesOrders.xsd 文件中,而预期结果在 ExpectedMarchOrders.xml 中。即使每个测试都必须定义一个 XSD 文件,这也很简单
<xs:schema xmlns="http://tempuri.org/Result.xsd"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"
elementFormDefault="qualified">
<xs:element name="DataSet">
<xs:complexType>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="Result">
<xs:complexType>
<xs:sequence>
<xs:element name="CompanyName" type="xs:string" />
<xs:element name="FirstName" type="xs:string" />
<xs:element name="LastName" type="xs:string" />
<xs:element name="OrderDate" type="xs:dateTime" />
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:choice>
</xs:complexType>
</xs:element>
</xs:schema>
创建这些 XSD 文件的工作量很小。只有字段的顺序会因一个测试用例到另一个测试用例而异,并且每个字段都由 XSD 文件中的一行非常简单的定义。使用相同的方法 ResultInspector.AreEqual
来测试数据库内容是否与预期结果相同。
测试详细内容
从单元测试中获得某事按预期运行的确认是很好的!这可能是您在将项目发布到集成测试或报告您的实现已完成时所期望的指示。但是,如果单元测试失败,它对您识别导致错误的源代码中的确切位置帮助不大。如果单元测试编写得当,失败的单元测试应提供足够的信息来识别错误的根本原因。从某种意义上说,单元测试可以看作是调试器的替代品:如果您编写了好的单元测试,那么您就不必使用调试器,因为单元测试会告诉您什么以及在哪里失败。这通常意味着您需要编写比先前场景更精细的测试。XPath 可以非常有效地以紧凑的形式表达不同的测试条件。
使用 XPath 测试详细信息
让我们考虑 DetailedTesting
fixture 中定义的 AroundTheHornOrderedCPUs
测试。此测试检查公司 Around the Horn 是否订购了产品 CPU-64X2。为此,需要检查除 Employees 之外的所有表的内容。一旦数据库内容被转换为 XML 格式,以下 XPath 表达式就可以检查测试条件
//Orders[
CustomerID=//Customers[CompanyName='Around the Horn']/CustomerID
and OrderID=//OrderDetails[SKU='CPU-64X2']/OrderID
]
如果它返回任何节点,则测试条件已验证。XPath 查询应用于方法返回的 XmlDocument
public static XmlDocument LoadTablesAsXML<typedataset />(string connectionString,
bool doNestTableElements, params string[] tableNames)
此方法加载最后一个参数中指定的表的内容,并将内容转换为 XmlDocument。参数 doNestTableElements
指示结果 XML 中的元素是否应按照表之间的关系进行嵌套,或者不进行嵌套。请注意,该方法还删除了生成 XML 中的命名空间引用,以便于编写 XPath 表达式,而无需指定命名空间。另一个类似的测试是 MichelaHasCreatedExactlyOneOrder
,它验证 Michela 是否只创建了一个订单。
XPath on parent-child relationships
根据我的经验,编写复杂的 XPath 查询可能具有挑战性。可能需要一些时间来表达正确的选择条件,XPath 调试器在这种情况下非常有用。XmlSpy 可以用于在更复杂的情况下测试 XPath 表达式。XPath 表达式可以简化的一个非常常见的情况是,当要测试的表是通过父子关系组织的。在这种情况下,可以使用元素层次结构中的路径而不是元素之间的连接。在我们的例子中,Customers-Orders-OrderDetails 或 Employees-Orders-OrderDetails 表之间存在两种这样的层次关系。可以使用父子关系从类型化数据集生成嵌套 XML,而不是像前几种情况那样的扁平 XML。只需为 LoadTablesAsXML
的 doNestTableElements
参数使用 true
。提供生成 XML 的表的时要小心,因为如果至少一个表有多个父节点,则无法生成这种嵌套 XML。
AroundTheHornHasBigOrders
测试检查公司 Around the Horn 是否有包含超过 100 件相同类型商品的较大订单。因为元素是嵌套的,所以在这种情况下 XPath 表达式更简单
//Customers[CompanyName='Around the Horn']/Orders/OrderDetails[Quantity>=100]
使用代码、配置
测试项目是在 Visual Studio 2005 中开发的,并使用 Microsoft SQL Server 2005 数据库进行了测试。由于在某些方法中使用了泛型,它在这种形式下无法在 Visual Studio 2003 中正常工作,但应该可以在较早版本的 Microsoft SQL Server 上工作。
运行单元测试
- 下载源项目并将其解压缩到一个本地文件夹,例如 C:\NDbUnitXPath\
- 创建一个本地数据库,例如 NDbUnitXPath,并在数据库上执行
CreateTables.SQL
- 调整文件 App.config 中
DbConnectionString
键的值,以匹配您刚刚创建的数据库的连接字符串 - 打开并构建解决方案:C:\NDbUnitXPath\NDbUnitXPath.sln
- 启动单元测试。这可以通过多种方式实现。一种选择是安装 NUnit,然后使用 nunit-gui.exe 加载并测试上一步中构建的程序集
历史
2007-06-06
- 已发布初始文章