NUnit 测试套件实现






4.89/5 (10投票s)
本文介绍了一种可扩展的 NUnit 单元测试套件,用于分层、数据库驱动的 .NET 应用程序。
引言
在本文中,我将介绍一种可扩展的 NUnit 单元测试套件,用于分层、数据库驱动的 .NET 应用程序。该套件将定义示例生成器,用于轻松创建测试的虚拟数据,并将使用测试夹具继承来提高可扩展性并允许轻松测试常见功能。
我将重点关注对示例应用程序的 域模型的测试。这通常位于应用程序的“中间”层,通常称为业务逻辑层 (BLL)。它使用数据访问层 (DAL) 来介导与数据库之间的数据传输,并驱动用户与之交互或显示数据的一个或多个用户界面 (UI) 的行为。
本文假定具备 .NET 和 C# 知识,但不需要单元测试或 NUnit 的经验。
BLL 实现
在此示例应用程序中,实现数据库操作的 BLL 中的类继承自一个名为 PersistentObject
的基类。此类定义了以下接口 [1]
public abstract class PersistentObject {
protected long _uid = long.MinValue;
/// <summary>
/// The unique identifier of this object
/// in the database
/// </summary>
/// <remarks>
/// Set when Fill() is called
/// </remarks>
public long UID {
get { return _uid; }
}
/// <summary>
/// Save this object's data to the database.
/// </summary>
public abstract void Save();
/// <summary>
/// Fill this object with data fetched from the
/// database for the given UID
/// </summary>
/// <param name="uid">The unique identifier of
/// the record to fetch from the database</param>
public abstract void Fill(long uid);
/// <summary>
/// Remove this object from the database
/// </summary>
public abstract void Delete();
/// <summary>
/// Fetches an object of the given type and with the
/// given UID from the database
/// </summary>
/// <typeparam name="ConcreteType">
/// The type of object to fetch
/// </typeparam>
/// <param name="uid">
/// The unique identifier of the object in the database
/// </param>
public static ConcreteType Fetch<ConcreteType>(long uid)
where ConcreteType : PersistentObject, new() {
ConcreteType toReturn = new ConcreteType();
toReturn.Fill(uid);
return toReturn;
}
}
例如,假设应用程序必须保存一些客户端数据和一个可以在应用程序其他地方使用的客户端地址。因此,BLL 需要包含从 PersistentObject
派生的 Address
和 Client
类。
public class Address : PersistentObject {
private string _streetAddress = null;
private string _city = null;
private string _state = null;
private string _zip = null;
public string StreetAddress {
get { return _streetAddress; }
set { _streetAddress = value; }
}
public string City {
get { return _city; }
set { _city = value; }
}
public string State {
get { return _state; }
set { _state = value; }
}
public string Zip {
get { return _zip; }
set { _zip = value; }
}
public override void Save() {
// Call DAL to save fields
// ...
}
public override void Fill(long uid) {
// Call DAL to fill fields
// ...
}
public override void Delete() {
// Call DAL to delete object
// ...
}
/// <summary>
/// Utility function that returns the Address with
/// the given UID
/// </summary>
public static Address Fetch(long addressUID) {
return PersistentObject.Fetch<Address>(addressUID);
}
}
Client
类似,只是它包含一个返回 Client
的 Address
对象的属性。
public class Client : PersistentObject {
private string _firstName = null;
private string _lastName = null;
private string _middleName = null;
private long _addressUID = long.MinValue;
private Address _addressObject;
// ...
public long AddressUID {
get { return _addressUID; }
set { _addressUID = value; }
}
/// <summary>
/// On-demand property that returns this Client's
/// Address based on the current value of AddressUID
/// </summary>
public Address Address {
get {
if (AddressUID == long.MinValue) {
_addressObject = null;
}
else if (_addressObject == null
|| AddressUID != _addressObject.UID) {
_addressObject = new Address();
_addressObject.Fill(AddressUID);
}
return _addressObject;
}
}
// ...
}
要保存新的客户端数据,用户将执行类似以下的操作:
// Create the address that the client will link to
Address newAddress = new Address();
newAddress.StreetAddress = StreetAddressInput.Text;
newAddress.City = CityInput.Text;
newAddress.State = StateInput.Text;
newAddress.Zip = ZipInput.Text;
// Save the address to the database
newAddress.Save();
// Create the client
Client newClient = new Client();
newClient.FirstName = FirstNameInput.Text;
newClient.MiddleName = MiddleNameInput.Text;
newClient.LastName = LastNameInput.Text;
// Link to the address
newClient.AddressUID = newAddress.UID;
// Save the client to the database
newClient.Save();
要在应用程序的其他地方检索客户端数据,用户将执行类似以下操作:
Client existingClient = Client.Fetch(clientUID);
Address clientAddress = existingClient.Address;
单元测试背景
上面概述的 BLL 实现相对标准。可以通过多种方式验证其行为。最简单但最不健壮的方法是测试 UI。由于 UI 依赖于 BLL,因此可以通过手动浏览网页或对话框来验证应用程序。但如果应用程序有多个 UI 呢?显然,此方法速度慢、难以重复、容易出错,并且可能遗漏 bug。此外,它可能助长糟糕的编程实践,即初学者可能会修复 UI 中的症状而不是 BLL 中的根本原因。这并不是说我们应该省略 UI 测试,只是我们不应该依赖它来验证业务逻辑。
更好的选择是创建一个简单的驱动程序来调用正在开发的 BLL 方法。此选项当然更容易重复,但可能难以保存驱动程序供以后使用或运行所有现有驱动程序以验证没有任何内容被破坏。
这就是 单元测试的用武之地。可以认为单元测试是一个简单的驱动程序,您很可能会编写它。单元测试框架组织测试,提供使编写测试更简单的工具,并允许您聚合运行测试。
测试套件实现
由于本文讨论的是 .NET 应用程序,因此我将在示例测试套件中使用 NUnit 测试框架。NUnit 提供了多种功能,如 测试执行 GUI、内置断言和 测试属性,这些功能使编写和运行测试变得非常容易。
为 BLL 中的每个类创建一个测试夹具(即包含一系列测试的类)是最直观的。因此,在示例中,我们将在示例测试套件中有 ClientTest
和 AddressTest
类。这些基本的测试夹具需要验证数据是否已正确添加到数据库、检索、编辑和删除。我们经常需要创建虚拟对象,因此这些测试夹具还将包括一些示例生成器。最后,我们不想在许多不同的测试夹具中重复通用的测试代码,因此我们将在 PersistentObjectTest
类中测试通用的数据库操作,而 ClientTest
和 AddressTest
都从此类继承。
我将分步解释 PersistentObjectTest
的构造。首先是类声明
/// <summary>
/// Abstract base class for test fixtures that test
/// classes derived from BLL.PersistentObject
/// </summary>
/// <typeparam name="PersistentObjectType">
/// The type of BLL.PersistentObject that the derived
/// class tests
/// </typeparam>
public abstract class PersistentObjectTest<PersistentObjectType>
where PersistentObjectType : PersistentObject, new() {
这表明 PersistentObjectTest
是一个泛型类型,它接受其派生类测试的对象的类型。此类型派生自 PersistentObject
并具有空构造函数。这使我们能够以类型安全、泛型的方式创建示例生成器和其他实用程序。
#region Sample Generators
/// <summary>
/// Returns a dummy object
/// </summary>
/// <param name="type">
/// Indicates whether the returned dummy object should
/// be saved to the database or not
/// </param>
public PersistentObjectType GetSample(SampleSaveStatus saveStatus) {
PersistentObjectType toReturn = new PersistentObjectType();
FillSample(toReturn);
if (saveStatus == SampleSaveStatus.SAVED_SAMPLE) {
toReturn.Save();
// Check Save() postconditions...
}
return toReturn;
}
/// <summary>
/// Fills the given object with random data
/// </summary>
/// <param name="sample">
/// The sample object whose fields to fill
/// </param>
/// <remarks>
/// Should be overridden and extended in
/// derived classes
/// </remarks>
public virtual void FillSample(PersistentObjectType sample) {
// nothing to fill in the base class
}
/// <summary>
/// Asserts that all fields in the given objects match
/// </summary>
/// <param name="expected">
/// The object whose data to check against
/// </param>
/// <param name="actual">
/// The object whose fields to test
/// </param>
/// <remarks>
/// Should be overridden and extended in
/// derived classes
/// </remarks>
public virtual void AssertIdentical
(PersistentObjectType expected, PersistentObjectType actual) {
Assert.AreEqual(expected.UID, actual.UID,
"UID does not match");
}
#endregion
GetSample()
仅返回一个虚拟对象。FillSample()
和 AssertIdentical()
的实现委托给派生类。这三个方法用于其他测试夹具来创建和测试示例对象。基类使用它们来在以下测试方法中验证基本的数据库操作
#region Data Tests
/// <summary>
/// Tests that data is sent to and retrieved from
/// the database correctly
/// </summary>
[Test()]
public virtual void SaveAndFetch() {
PersistentObjectType original =
GetSample(SampleSaveStatus.SAVED_SAMPLE);
PersistentObjectType fetched =
PersistentObject.Fetch<PersistentObjectType>(original.UID);
// verify that the objects are identical
AssertIdentical(original, fetched);
}
/// <summary>
/// Tests that editing an existing object works correctly
/// </summary>
[Test()]
public virtual void EditAndFetch() {
PersistentObjectType modified =
GetSample(SampleSaveStatus.SAVED_SAMPLE);
// edit fields
FillSample(modified);
// save edits
modified.Save();
// make sure edits were reflected in the database
PersistentObjectType fetched =
PersistentObject.Fetch<PersistentObjectType>(modified.UID);
AssertIdentical(modified, fetched);
}
/// <summary>
/// Tests that deletion works correctly.
/// </summary>
/// <remarks>
/// Expects data retrieval to fail
/// </remarks>
[Test(),
ExpectedException(typeof(DataNotFoundException))]
public virtual void Delete() {
PersistentObjectType toDelete =
GetSample(SampleSaveStatus.SAVED_SAMPLE);
long originalUID = toDelete.UID;
toDelete.Delete();
// expect failure because the object does not exist
PersistentObject.Fetch<PersistentObjectType>(originalUID);
}
#endregion
有了 PersistentObjectTest
来处理繁重的工作,具体的测试类只需要定义如何填充示例对象以及如何检查两个示例对象是否相同。它们还可以根据需要定义额外的示例生成器、实用程序函数和测试方法。
[TestFixture()]
public class AddressTest : PersistentObjectTest<Address> {
public override void FillSample(Address sample) {
base.FillSample(sample);
Random r = new Random();
string[] states = {"IL", "IN", "KY", "MI"};
sample.City = "CITY" + DateTime.Now.Ticks.ToString();
sample.State = states[r.Next(0, states.Length)];
sample.StreetAddress = r.Next().ToString() + " Anywhere Street";
sample.Zip = r.Next(0, 100000).ToString("00000");
}
public override void AssertIdentical(Address expected, Address actual) {
base.AssertIdentical(expected, actual);
Assert.AreEqual(expected.City, actual.City,
"City does not match");
Assert.AreEqual(expected.State, actual.State,
"State does not match");
Assert.AreEqual(expected.StreetAddress, actual.StreetAddress,
"StreetAddress does not match");
Assert.AreEqual(expected.Zip, actual.Zip,
"Zip does not match");
}
}
[TestFixture()]
public class ClientTest : PersistentObjectTest<Client> {
public override void FillSample(Client sample) {
base.FillSample(sample);
sample.FirstName = "FIRST" + DateTime.Now.Ticks.ToString();
sample.MiddleName = "MIDDLE" + DateTime.Now.Ticks.ToString();
sample.LastName = "LAST" + DateTime.Now.Ticks.ToString();
sample.AddressUID = new AddressTest().GetSample
(SampleSaveStatus.SAVED_SAMPLE).UID;
}
public override void AssertIdentical(Client expected, Client actual) {
base.AssertIdentical(expected, actual);
Assert.AreEqual(expected.FirstName, actual.FirstName,
"FirstName does not match");
Assert.AreEqual(expected.MiddleName, actual.MiddleName,
"MiddleName does not match");
Assert.AreEqual(expected.LastName, actual.LastName,
"LastName does not match");
Assert.AreEqual(expected.AddressUID, actual.AddressUID,
"AddressUID does not match");
}
}
ClientTest
的示例生成器使用 AddressTest.GetSample()
在填充虚拟示例 Client
时创建虚拟 Address
。这种通用模式在此类测试套件中经常使用。任何需要虚拟对象的测试都只需调用相应的示例生成器。
运行测试时,NUnit 会查找任何标记有 [TestFixture()]
属性的类。它会创建一个类的实例并运行任何标记有 [Test()]
属性的方法。[ExpectedException()]
属性告诉 NUnit 给定的方法应该抛出给定的异常。测试代码本身使用 NUnit 的 Assert
对象来验证预期的属性是否成立。
任何继承自抽象基类的测试夹具还会“继承”[2]任何测试方法。因此,AddressTest
,一个具体的测试夹具,从 PersistentObjectTest
继承了 SaveAndFetch()
、EditAndFetch()
和 Delete()
测试方法。请注意,派生类可以覆盖这些测试方法,例如,如果其相应的 BLL 类不支持删除
[Test()]
public override void Delete() {
Assert.Ignore("This object does not support deleting");
}
继承
现在我们已经实现了基本的测试套件,假设需求发生变化,我们需要添加一个代表首选客户的类,该客户可获得折扣和特殊信用。首先,我们将创建一个从 Client
派生的 PreferredClient
类
public class PreferredClient : Client {
private double _discountRate = 1;
private decimal _accountCredit = 0.00M;
//...
public override void Save() {
base.Save();
// call DAL to save this object's fields
}
//...
}
接下来,我们必须创建一个从 ClientTest
派生的 PreferredClientTest
测试夹具。但这会产生一个问题:ClientTest
继承自 PersistentObjectTest<Client>
,但我们需要 PreferredClientTest
间接继承自 PersistentObjectTest<PreferredClient>
,以便 PersistentObjectTest
的方法使用正确的对象类型。解决方案是将泛型签名“向下移动到层次结构”到 ClientTest
/// <summary>
/// Generic tester for classes derived from Client
/// </summary>
public class ClientTest<DerivedClientType>
: PersistentObjectTest<DerivedClientType>
where DerivedClientType : Client, new() {
public override void FillSample(DerivedClientType sample) {
base.FillSample(sample);
sample.FirstName = "FIRST" + DateTime.Now.Ticks.ToString();
sample.MiddleName = "MIDDLE" + DateTime.Now.Ticks.ToString();
sample.LastName = "LAST" + DateTime.Now.Ticks.ToString();
sample.AddressUID = new AddressTest().GetSample
(SampleSaveStatus.SAVED_SAMPLE).UID;
}
public override void AssertIdentical
(DerivedClientType expected, DerivedClientType actual) {
base.AssertIdentical(expected, actual);
Assert.AreEqual(expected.FirstName, actual.FirstName,
"FirstName does not match");
Assert.AreEqual(expected.MiddleName, actual.MiddleName,
"MiddleName does not match");
Assert.AreEqual(expected.LastName, actual.LastName,
"LastName does not match");
Assert.AreEqual(expected.AddressUID, actual.AddressUID,
"AddressUID does not match");
}
}
但我们需要保留非泛型测试程序,以便 Client's
测试仍然可以运行
/// <summary>
/// Non-generic tester for base Client type
/// </summary>
[TestFixture()]
public class ClientTest : ClientTest<Client> {
// add Client-specific tests as needed
}
最后,我们将 PreferredClientTest
定义为 ClientTest
的泛型版本。
[TestFixture()]
public class PreferredClientTest : ClientTest<PreferredClient> {
public override void FillSample(PreferredClient sample) {
base.FillSample(sample);
Random r = new Random();
// some random dollars and cents
sample.AccountCredit = ((Decimal)r.Next()) + .25M;
sample.DiscountRate = r.NextDouble();
}
public override void AssertIdentical
(PreferredClient expected, PreferredClient actual) {
base.AssertIdentical(expected, actual);
Assert.AreEqual(expected.AccountCredit, actual.AccountCredit,
"AccountCredit does not match");
Assert.AreEqual(expected.DiscountRate, actual.DiscountRate,
"DiscountRate does not match");
}
}
请注意,FillSample()
和 AssertIdentical()
方法只是扩展了它们的基类对应方法。随着应用程序的增长,可以很容易地看出这种类型的扩展如何继续进行;这仅仅是添加一个子类并实现相应方法的问题。
缺点
主键
这个假设的测试套件做了一个显而易见的假设:它假设 PersistentObject
是真实类的一个有效基类。这个假设在 Fetch
/Fill
方法中最为明显,这些方法总是接受一个 long
作为唯一的数据库标识符。通常,真实世界的数据库不会被规范化,以至于所有数据都有一个 bigint
主键(如果只有!)。可以通过扩展 PersistentObjectTest
和 PersistentObject.Fetch()
的泛型签名来包含派生类唯一标识符的类型来解决此问题。
虚拟数据重载
由于其对示例生成器的依赖,这种形式的测试套件会在数据库中创建大量虚拟数据。这是可以接受的,因为测试数据库驱动的应用程序的很大一部分是验证数据是否正确保存和检索。但是,这意味着开发应用程序必须有一个专用的测试数据库服务器,该服务器会定期重置到某个已知状态,以防止虚拟数据掩盖有效数据。此外,示例生成器的递归性质可能导致无限的示例生成循环,这可能很快导致数据库(更不用说堆栈帧)不堪重负。
随机性
我概述的实现假定随机虚拟数据通常足以满足大多数使用生成对象的测试。换句话说,示例对象的使用者必须确保生成对象满足所需的先决条件。通常可以通过参数化示例生成器来实现随机性约束,例如下面的示例
/// <summary>
/// Return a client with one of the given first names
/// </summary>
/// <param name="firstNames">
/// The list of possible first names
/// </param>
public static Client GetBoundedSample
(string[] firstNames, SampleSaveStatus saveStatus) {
Client toReturn = new ClientTest().GetSample(SampleSaveStatus.UNSAVED_SAMPLE);
Random r = new Random();
toReturn.FirstName = firstNames[r.Next(0, firstNames.Length)];
if (saveStatus == SampleSaveStatus.SAVED_SAMPLE) {
toReturn.Save();
}
return toReturn;
}
但是,对于示例生成器来说,没有一种通用、易于实现的万博来控制随机性或返回所有可能样本的完整列表。事实上,穷举测试生成是一个 持续研究的问题。
结论
我概述的假设测试套件架构对于测试分层、数据库驱动的应用程序非常有用,在这些应用程序中,合理、随机的示例数据通常是必需的。通过使用测试夹具继承和示例生成器,随着应用程序的增长,可以非常轻松地扩展测试套件。它还减少了测试数据库驱动的应用程序最重要的方面所需的代码量:数据是否正确地进出数据库。此测试实现的各种变体在多个 .NET 应用程序中表现良好,这些应用程序包含几十到几千个类。
脚注
- 在实际中,
Save
、Fill
和Delete
通常会包装受保护的可覆盖方法,如DoSave
、DoFill
和DoDelete
。这将允许基类定义通用的数据库操作前置和后置步骤,同时让派生类处理自己的数据。此外,Delete 通常会设置一个“Ignore
”标志,而不是完全从数据库中删除数据。无论如何,我们可以在本文中忽略这些复杂性。只需假设派生类会以显而易见的方式覆盖Save
、Fill
和Delete
,如果该类支持相应的数据库操作。 - 这不是真正的继承。NUnit 使用 反射来查找任何标记有
[Test()]
属性的方法,无论该方法在类层次结构中的位置如何。此外,覆盖测试方法不会保留[Test()]
属性。