Unit Test Design Patterns
Test Harness Design PatternsThe Microsoft® .NET Framework provides you with many ways to write software test automation. But in conversations with my colleagues I discovered that most engineers tend to use only one or two of the many fundamental test harness design patterns available to them. Most often this is true because many developers and testers simply aren't aware that there are more possibilities.
Furthermore I discovered that there is some confusion and debate about when to use a lightweight test harness and when to use a more sophisticated test framework like the popular NUnit. In this month's column James Newkirk, the original author of NUnit, joins me to explain and demonstrate how to use fundamental lightweight test harness patterns and also show you their relation to the more powerful NUnit test framework. The best way to show you where the two of us are headed is with three screen shots. Suppose you are developing a .NET-based application for Windows®. The screen shot in Figure 1 shows the fairly simplistic but representative example of a poker game. The poker application references a PokerLib.dll library that has classes to create and manipulate various poker objects. In particular there is a Hand constructor that accepts a string argument like "Ah Kh Qh Jh Th" (ace of hearts through 10 of hearts) and a Hand.GetHandType method that returns an enumerated type with a string representation like RoyalFlush.
Figure 1 System Under Test
Now suppose you want to test the underlying PokerLib.dll methods for functional correctness. Manually testing the library would be time-consuming, inefficient, error-prone, and tedious. You have two better testing strategies. A first alternative to manual testing is to write a lightweight test harness that reads test case input and expected values from external storage, calls methods in the library, and compares the actual result with the expected result. When using this approach, you can employ one of several basic design patterns. Figure 2 shows a screen shot of a test run that uses the simplest of the design patterns. Notice that there are five test cases included in this run; four cases passed and one failed. The second alternative to manual testing is to use a test framework. Figure 3 shows a screen shot of a test run which uses the NUnit framework.
Figure 2 Lightweight Test Harness Run
In the sections that follow, we will explain fundamental lightweight test harness design patterns, show you a basic NUnit test framework approach, give you guidance on when each technique is most appropriate, and describe how you can adapt each technique to meet your own needs. You'll learn the pros and cons of multiple test design patterns, and this information will be a valuable addition to your developer, tester, and manager skill sets.
Figure 3 NUnit Test Framework Run
The Six Basic Lightweight Test Harness Patterns
It is useful to classify lightweight data-driven test harness design patterns into six categories based on type of test case storage and test case processing model. There are three fundamental types of test case storage: flat file, hierarchical, and relational. Additionally, there are two fundamental processing models: streaming and buffered. This categorization leads to six test harness design patterns, the cross-product of the storage types with the processing models.
Of course you can think of many other possibilities, but these six categories give you a practical way to think about structuring your lightweight test harnesses. Notice that this assumes that the test case storage is external to the test harness code. In general, external test case storage is better than embedding test case data with the harness code because external storage can be edited and shared more easily than embedded data. However, as we'll explain later, the test-driven approach is primarily a developer activity and typically uses embedded test case data which does have certain advantages over external data. Separately, NUnit can be used with external test case storage and can support both streaming and buffered processing models.
Figure 4 Flat Test Case Data
At a minimum every test case has an ID, one or more inputs, and one or more expected results. There is nothing profound about how to store test case data. Examples of flat data are text files, Excel worksheets, and individual tables in a database. Examples of hierarchical data stores are XML files and some .ini files. SQL Server™ databases and Access databases are examples of relational data stores when multiple tables are used in conjunction through relationships. Here you can see we're using a simple text file with a test case ID field, a single input field, and a single expected result field—simple and effective. We will discuss the pros and cons of each of the three storage types later in this column.
This pseudocode shows the basic streaming processing model:
The code in Figure 5 shows the main loop. The algorithm is implemented in Visual Basic® .NET, but any .NET-targeted language could be used. The complete source code for all examples is available in the code download that accompanies this column.
Figure 5 Streaming Flat Data Design
Notice that we echo test results to the command shell with a Console.WriteLine statement and write test results to an external text file with a call to StreamWriter.WriteLine. In general, it makes sense to save test case results to the same type of storage as your test case data, but this is considered to be more a matter of consistency than a technical issue.
We call the algorithm a streaming model because it resembles the .NET input-output streaming model; there is a continuous stream of test case input and test results. Now let's look at the buffered model. The pseudocode in Figure 6 is what we'll call the buffered processing model.
Figure 6 Buffered Algorithm
With the buffered test harness model we read all test case data into memory before executing any test cases. All test results are saved to an in-memory data store and then emitted to external storage after all test cases have been executed. In other words, test case input and results are buffered through the test system rather than streamed through the system. The code snippet in Figure 7 shows you how we implemented the buffered model using the test case data file that is shown in Figure 4.
Figure 7 Flat Data Buffered Design
If you compare the streaming processing model with the buffered model, it's pretty clear that the streaming model is both simpler and shorter. So why would you ever want to use the buffered model? There are two common testing scenarios where you should consider using the buffered processing model instead of the streaming model. First, if the aspect in the system under test involves file input/output, you often want to minimize the test harness file operations. This is especially true if you are monitoring performance. Second, if you need to perform some pre-processing of your test case input or post-processing of your test case results (for example aggregating various test case category results), it's almost always more convenient to have all results in memory where you can process them. The NUnit test framework is very flexible and can use external test case storage primarily with the buffered processing models. However, a complete discussion of how to use NUnit in these ways would require an entire article by itself and is outside the scope of this column.
Because XML is so flexible there are many hierarchical structures we could have chosen. For example, the same test cases could have been stored as follows:
Just as with flat test case data, you can use a streaming processing model or a buffered model. In each case the algorithm is the same as shown in the basic streaming processing model algorithm and in the buffered algorithm that was shown in Figure 6. Interestingly though, the XML test case data model implementations are quite different from their flat data counterparts. Figure 8 shows key code from a C#-based streaming model implementation.
Figure 8 XML Data Streaming Design
With a streaming model, we use an XmlTextReader object to read one XML node at a time. But because XML is hierarchical it is a bit tricky to keep track of exactly where we are within the file, especially when the nested becomes more extreme (in this particular example, the data is little more than a flat file, but it could be significantly more complex). We use an XmlTextWriter object to save test results in XML form. Now we'll show you a buffered approach for XML test case data. Figure 9 shows key code from a buffered processing model implementation.
Figure 9 XML Data Buffered Design
We use an XmlSerializer object from the System.Xml.Serialization namespace to read the entire XML test case file into memory with a single line of code and also to write the entire XML result file with a single line of code. Of course, this requires us to prepare appropriate collection classes (TestCases and TestResults in the code) to hold the data. Unlike flat test case data, with XML data the buffered model test harness code tends to be shorter and simpler. So when might you consider using a streaming model in conjunction with XML test case data? Most often you will want to use a streaming model when you have a lot of test cases to deal with. Reading a huge amount of test case data into memory all at once may not always be possible, especially if you are running stress tests under conditions of reduced internal memory.
Figure 10 SQL-based Test Case Data
Just as with flat test case data and hierarchical data, you can use a streaming processing model or a buffered model. The basic streaming and buffered models described earlier will be the same. The streaming model implementation is included in this column's download. If you examine the code you'll see that for a streaming model we like to use a SqlDataReader object and its Read method. For consistency we insert test results into a SQL table rather than save to a text file or XML file. We prefer to use two SQL connections—one to read test case data and one to insert test results. As with all the techniques in this column, there are many alternatives available to you.
The code for the buffered processing model can be downloaded from the MSDN Magazine Web site. Briefly, we connect to the test case database, fill a DataSet with all the test case data, iterate through each case, test, store all results into a second DataSet, and finally emit all results to a SQL table.
Using relational test case data in conjunction with ADO.NET provides you with many options. Assuming memory limits allow, we typically prefer to read all test case data into a DataSet object. Because all the test case data is in a single table, we could also have avoided the relatively expensive overhead of a DataSet by just using a DataTable object. However in situations where your test case data is contained in multiple tables, reading into a DataSet gives you an easy way to manipulate test case data using a DataRelation object. Similarly, to hold test case results we create a second DataSet object and a DataTable object. After running all the test cases we open a connection to the database that holds the results table (in this example it's the same database that holds the test case data) and write results using the SqlDataAdapter.Update method.
Recall that when using flat test case data, a streaming processing model tends to be simpler than a buffered model, but that when using hierarchical XML data, the opposite is usually true. When using test case data stored in a single table in SQL Server, a streaming processing model tends to be simpler than a buffered model and the technique of choice. When test case data spans multiple tables, you'll likely want to use a buffered processing model.
Figure 12 NUnit Approach with External XML Test Cases
Figure 11 NUnit Approach with Embedded Test Cases
You may be wondering whether it's better to use NUnit or to write a custom test harness. The best answer is that it really depends on your scenarios and environment, but using both test techniques together ensures a thorough test effort. The NUnit framework and lightweight test harnesses are designed for different testing situations. NUnit was specifically designed to perform unit testing in a test-driven development (TDD) environment, and it is a very powerful tool. A lightweight test harness is useful in a wide range of situations, such as when integrated into the build process, and is more traditional than the NUnit framework in the sense that a custom harness assumes a conventional spiral-type software development process (code, test, fix).
A consequence of NUnit's TDD philosophy is that test case data is typically embedded with the code under test. Although embedded test case data cannot easily be shared (for example when you want to test across different system configurations), embedded data has the advantage of being tightly coupled with the code it's designed to test, which makes your test management process easier. Test-driven development with NUnit helps you write code and test it. This is why embedded tests with NUnit are acceptable—you change your tests as you change your code. Now this is not to say that the two test approaches are mutually exclusive; in particular NUnit works nicely in a code-first, test-later environment, and can utilize an external test case data source. And a lightweight test harness can be used in conjunction with a TDD philosophy.
The NUnit framework and custom lightweight test harnesses have different strengths and weaknesses. Some of NUnit's strengths are that it is a solid, stable tool, it is a nearly a de-facto standard because of widespread use, and it has lots of features. The strengths of custom test harnesses are that they are very flexible, allowing you to use internal or external storage in a variety of environments, test for functionality as well as performance, stress, security and other types of testing, and execute sets of individual test cases or multiple state change test scenarios.
Let's briefly summarize. When writing a data-driven lightweight test harness in a .NET environment you can choose one of three types of external test case data storage: flat data (typically a text file), hierarchical data (typically an XML file), or relational data (typically a SQL Server database). Often you will have no choice about the type of data store to use because you will be working in an already existing development environment. Flat data is good for simple test case scenarios, hierarchical data works very well for technically complex test case scenarios, and relational data is best for large test efforts. When writing a lightweight test harness you can employ either a streaming processing model or you can choose a buffered processing model. A streaming processing model is usually simpler except when used with truly hierarchical or relational data, in which case the opposite is true. A streaming model is useful when you have a very large number of test cases, and a buffered model is most appropriate when you are testing for performance or when you need to process test cases and results. Using a test framework like NUnit is particularly powerful for unit testing when you are employing a TDD philosophy. With the .NET environment and powerful .NET-based tools like NUnit, it's possible to write great test automation quickly and efficiently. The release of Visual Studio® 2005 will only enhance your ability to write test automation and the Team System version in Visual Studio 2005 will have many NUnit-like features. With software systems increasing in complexity, testing is more important than ever. Knowledge of these test harness patterns as well as of frameworks like NUnit will help you test better and produce better software systems.
source: James McCaffrey and James Newkirk - http://msdn.microsoft.com/en-us/magazine/cc163752.aspx