Introduction
In this article, I will describe a scalable NUnit unit test suite for use on a tiered, database-driven .NET application. The suite will define sample generators used to easily create dummy data for tests, and it will use test fixture inheritance for increased scalability and to allow for easy testing of common functionality.
I will focus on testing of the domain model of a sample application. This is usually located in the "middle" of an application and is often called the business logic layer (BLL). It uses the data access layer (DAL) to mediate data transfer to and from the database and drives the behavior of one or more user interfaces (UI) with which the user interacts or in which data is displayed.
This article assumes knowledge of .NET and C#, but does not require experience with unit testing or NUnit in particular.
BLL Implementation
In this sample application, classes in the BLL that implement database operations inherit from a base class called PersistentObject
. This class defines the following interface[1]:
public abstract class PersistentObject {
protected long _uid = long.MinValue;
public long UID {
get { return _uid; }
}
public abstract void Save();
public abstract void Fill(long uid);
public abstract void Delete();
public static ConcreteType Fetch<ConcreteType>(long uid)
where ConcreteType : PersistentObject, new() {
ConcreteType toReturn = new ConcreteType();
toReturn.Fill(uid);
return toReturn;
}
}
Say, for example, the application must save some client data and a client address that can be used elsewhere in the application. The BLL would therefore need to contain Address
and Client
classes derived from PersistentObject
.
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() {
}
public override void Fill(long uid) {
}
public override void Delete() {
}
public static Address Fetch(long addressUID) {
return PersistentObject.Fetch<Address>(addressUID);
}
}
Client
is similar, except it contains a property that returns the Client
's Address
object.
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; }
}
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;
}
}
}
To save new client data, the user would do something like the following:
Address newAddress = new Address();
newAddress.StreetAddress = StreetAddressInput.Text;
newAddress.City = CityInput.Text;
newAddress.State = StateInput.Text;
newAddress.Zip = ZipInput.Text;
newAddress.Save();
Client newClient = new Client();
newClient.FirstName = FirstNameInput.Text;
newClient.MiddleName = MiddleNameInput.Text;
newClient.LastName = LastNameInput.Text;
newClient.AddressUID = newAddress.UID;
newClient.Save();
And to retrieve client data elsewhere in the application, the user would do something like the following:
Client existingClient = Client.Fetch(clientUID);
Address clientAddress = existingClient.Address;
Unit Testing Background
The BLL implementation outlined above is relatively standard. One can verify its behavior in any number of ways. The simplest but least robust is to test the UI. Since the UI depends on the BLL, one could conceivably verify the application by running through web pages or dialog boxes by hand. But what if the application has multiple UIs? Obviously, this method is slow, difficult to repeat, prone to human error, and may miss bugs. Also, it may promote bad programming practice in that a naïve coder may fix a symptom in the UI rather than the base cause in the BLL. This is not to say that we should omit UI testing, just that we should not rely on it to verify business logic.
A better option would be to create a simple driver program that calls the BLL method under development. This option would certainly be easier to repeat, but it may be difficult to save drivers for later or run all existing drivers to verify that nothing is broken.
This is where unit tests come in. One can think of a unit test as a simple driver program that one would probably write anyway. The unit testing framework organizes the tests, provides tools to make writing tests easier, and allows one to run tests in aggregate.
Test Suite Implementation
Since this article discusses a .NET application, I will use the NUnit testing framework in the example test suite. NUnit provides several features such as a test execution GUI, built-in assertions, and test attributes that make writing and running tests very easy.
It is most intuitive to create a test fixture (that is, a class containing a series of tests) for each class in the BLL. So, in keeping with the example, we will have ClientTest
and AddressTest
classes in the example test suite. These basic test fixtures will need to verify that data is added to the database, retrieved, edited, and deleted correctly. We often need to create dummy objects, so these test fixtures will also include some sample generators. Finally, we do not want to have to repeat common test code across many different test fixtures, so we will test the common database operations in a PersistentObjectTest
class from which ClientTest
and AddressTest
both inherit.
I will explain the construction of PersistentObjectTest
in parts. First, the class declaration:
public abstract class PersistentObjectTest<PersistentObjectType>
where PersistentObjectType : PersistentObject, new() {
This shows that PersistentObjectTest
is a generic type that accepts the type of the object that its derived class tests. This type derives from PersistentObject
and has an empty constructor. This lets us create sample generators and other utilities in a type-safe, generic manner:
#region Sample Generators
public PersistentObjectType GetSample(SampleSaveStatus saveStatus) {
PersistentObjectType toReturn = new PersistentObjectType();
FillSample(toReturn);
if (saveStatus == SampleSaveStatus.SAVED_SAMPLE) {
toReturn.Save();
}
return toReturn;
}
public virtual void FillSample(PersistentObjectType sample) {
}
public virtual void AssertIdentical
(PersistentObjectType expected, PersistentObjectType actual) {
Assert.AreEqual(expected.UID, actual.UID,
"UID does not match");
}
#endregion
GetSample()
simply returns a dummy object. The implementations of FillSample()
and AssertIdentical()
are delegated to the derived classes. These three methods are used by other test fixtures to create and test sample objects. The base class uses them to verify the basic database operations in the following test methods:
#region Data Tests
[Test()]
public virtual void SaveAndFetch() {
PersistentObjectType original =
GetSample(SampleSaveStatus.SAVED_SAMPLE);
PersistentObjectType fetched =
PersistentObject.Fetch<PersistentObjectType>(original.UID);
AssertIdentical(original, fetched);
}
[Test()]
public virtual void EditAndFetch() {
PersistentObjectType modified =
GetSample(SampleSaveStatus.SAVED_SAMPLE);
FillSample(modified);
modified.Save();
PersistentObjectType fetched =
PersistentObject.Fetch<PersistentObjectType>(modified.UID);
AssertIdentical(modified, fetched);
}
[Test(),
ExpectedException(typeof(DataNotFoundException))]
public virtual void Delete() {
PersistentObjectType toDelete =
GetSample(SampleSaveStatus.SAVED_SAMPLE);
long originalUID = toDelete.UID;
toDelete.Delete();
PersistentObject.Fetch<PersistentObjectType>(originalUID);
}
#endregion
With PersistentObjectTest
doing the heavy lifting, the concrete test classes need only define how to fill a sample object and how to check if two sample objects are identical. They can also define additional sample generators, utility functions, and test methods as needed.
[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
's sample generator uses AddressTest.GetSample()
to create a dummy Address
when filling a dummy sample Client
. This general pattern is used often in this type of test suite. Any test that needs a dummy object simply calls the appropriate sample generator.
When running tests, NUnit looks for any classes marked with the attribute [TestFixture()]
. It creates an instance of the class and runs any methods marked with the attribute [Test()]
. The [ExpectedException()]
attribute tells NUnit that the given method should throw the given exception. The test code itself uses NUnit's Assert
object to verify that expected properties hold.
Any test fixture that inherits from an abstract base class also "inherits"[2] any test methods. Therefore, AddressTest
, a concrete test fixture, inherits the SaveAndFetch()
, EditAndFetch()
, and Delete()
test methods from PersistentObjectTest
. Note that a derived class can override these test methods if, for example, its corresponding BLL class does not support deleting:
[Test()]
public override void Delete() {
Assert.Ignore("This object does not support deleting");
}
Inheritance
Now that we have the basic test suite implemented, say the requirements change and we need to add a class representing a preferred client that receives discounts and special credit. First we will create a PreferredClient
class derived from Client
:
public class PreferredClient : Client {
private double _discountRate = 1;
private decimal _accountCredit = 0.00M;
public override void Save() {
base.Save();
}
}
Next, we must create a PreferredClientTest
test fixture derived from ClientTest
. But this causes a problem: ClientTest
inherits from PersistentObjectTest<Client>
, but we need PreferredClientTest
to inherit indirectly from PersistentObjectTest<PreferredClient>
so that PersistentObjectTest
's methods use the correct type of object. The solution is to move the generic signature "down the hierarchy" to ClientTest
:
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");
}
}
But we need to keep the non-generic tester so Client's
tests will still run:
[TestFixture()]
public class ClientTest : ClientTest<Client> {
}
Finally, we define PreferredClientTest
in terms of the generic version of ClientTest
:
[TestFixture()]
public class PreferredClientTest : ClientTest<PreferredClient> {
public override void FillSample(PreferredClient sample) {
base.FillSample(sample);
Random r = new Random();
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");
}
}
Note that the FillSample()
and AssertIdentical()
methods simply extend their base class counterparts. One can easily see how this type of expansion can continue as the application grows; it is simply a matter of adding a subclass and implementing the appropriate methods.
Drawbacks
Primary Keys
This hypothetical test suite makes one glaring assumption: it assumes that PersistentObject
is a valid base class for real-world classes. This assumption becomes most apparent in the Fetch
/Fill
methods which always take a long
as a unique database identifier. Often, a real-world database will not be normalized such that all data has a bigint
primary key (if only!). One can get around this problem by expanding the generic signature of PersistentObjectTest
and PersistentObject.Fetch()
to include the type of the derived class' unique identifier.
Dummy Data Overload
Because of its dependence on sample generators, the form of test suite creates a large amount of dummy data in the database. This is acceptable since a large part of testing a database-driven application is verifying that data is saved and retrieved correctly. However, it means that the development application must have a dedicated testing database server that is regularly reset to some known state to prevent dummy data from overshadowing valid data. Also, the recursive nature of the sample generators may make it possible to get into a never-ending sample generation cycle that could very quickly bring a database (not to mention the stack frame) to its knees.
Randomness
The implementation I have outlined assumes that random dummy data will often suffice for most tests that use the generated objects. In other words, the consumer of the sample object must ensure that a generated object meets the desired preconditions. Bounds on randomness can often be achieved with parameterized sample generators such as the following:
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;
}
However, there is no general, easily-implemented way for the sample generators to control randomness or return a bounded exhaustive list of all possible samples. In fact, exhaustive test generation is an ongoing research problem.
Conclusion
The hypothetical test suite architecture that I have outlined is useful for testing tiered, database-driven applications in which reasonable, random sample data is often needed. By using test fixture inheritance and sample generators, it becomes very easy to expand the test suite as the application grows. It also reduces the amount of code needed to test the most important aspect of a database-driven application: that data travels to and from the database correctly. Variations on this testing implementation have performed well for several .NET applications with several dozen to several thousand classes.
Footnotes