You've likely encountered a number of traditional forms of testing. Your quality assurance staff may run automated or manual tests to validate behavior and appearance. Load tests may be run to establish that performance metrics are acceptable. Your product group might run user acceptance tests to validate that systems do what the customers expect. Unit testing takes another view. Unit tests are written to ensure that code performs as the programmer expects.
Unit tests are generally focused at a lower level than other testing, establishing that underlying features work as expected. For example, an acceptance test might walk a user through an entire purchase. A unit test might verify that a ShoppingCart class correctly defends against adding an item with a negative quantity.
Unit testing is an example of white box testing, where knowledge of internal structures is used to identify the best ways to test the system. This is a complementary approach to black box testing, where the focus is not on implementation details but on overall functionality compared to specifications. You should leverage both approaches to effectively test your applications.
A common reaction to unit testing is to resist the approach because the tests seemingly make more work for a developer. However, unit testing offers many benefits that may not be obvious at first.
The act of writing tests often uncovers design or implementation problems. The unit tests serve as the first users of your system and will frequently identify design issues or functionality that is lacking. Once a unit test is written, it serves as a form of documentation for the use of the target system. Other developers can look to an assembly's unit tests to see example calls into various classes and members.
Perhaps one of the most important benefits is that a well-written test suite provides the original developer with the freedom to pass the system off to other developers for maintenance and further enhancement. Should those developers introduce a bug in the original functionality, there is a strong likelihood that those unit tests will detect that failure and help diagnose the issue. Meanwhile, the original developer can focus on current tasks.
It takes the typical developer time and practice to become comfortable with unit testing. Once a developer has been saved enough time by unit tests, he or she will latch on to them as an indispensable part of the development process.
Unit testing does require more explicit coding, but this cost will be recovered, and typically exceeded, when you spend much less time debugging your application. In addition, some of this cost is typically already hidden in the form of test console- or Windows-based applications. Unlike these informal testing applications, which are frequently discarded after initial verification, unit tests become a permanent part of the project, run each time a change is made to help ensure that the system still functions as expected. Tests are stored in source control very near to the code they verify and are maintained along with the code under test, making it easier to keep them synchronized.
Unit tests are an essential element of regression testing. Regression testing involves retesting a piece of software after new features have been added to make sure that errors or bugs are not introduced. Regression testing also provides an essential quality check when you introduce bug fixes in your product.
It is difficult to overstate the importance of comprehensive unit test suites. They enable a developer to hand off a system to other developers with confidence that any changes they make should not introduce undetected side effects. However, because unit testing only provides one view of a system's behavior, no amount of unit testing should ever replace integration, acceptance, and load testing.
Because unit tests are themselves code, you are generally unlimited in the approaches you can take when writing them. However, we recommend that you follow some general guidelines:
Always separate your unit test assemblies from the code you are testing. This separation enables you to deploy your application code without unit tests, which serve no purpose in a production environment.
Avoid altering the code you are testing solely to allow easier unit testing. A common mistake is to open accessibility to class members to allow unit tests direct access. This compromises design, reduces encapsulation, and broadens interaction surfaces. You will see later in this chapter that Team System offers features to help address this issue.
Each test should verify a small slice of functionality. Do not write long sequential unit tests that verify a large number of items. While creating focused tests will result in more tests, the overall suite of tests will be easier to maintain. In addition, identifying the cause of a problem is much easier when you can quickly look at a small failed unit test, immediately understand what it was testing, and know where to search for the bug.
All tests should be autonomous. Avoid creating tests that rely on other tests to be run beforehand. Tests should be executable in any combination and in any order. To verify that your tests are correct, try changing their execution order and running them in isolation.
Test both expected behavior (normal workflows) and error conditions (exceptions and invalid operations). This often means that you will have multiple unit tests for the same method, but remember that developers will always find ways to call your objects that you did not intend. Expect the unexpected, code defensively, and test to ensure that your code reacts appropriately.
The final proof of your unit testing's effectiveness will be when they save you more time during development and maintenance than you spent creating them. In our experience, you will realize this savings many times over.
Test-driven development (TDD) is the practice of writing unit tests before writing the code that will be tested. The logic behind writing tests against code that does not exist is difficult to grasp at first. Our experience has shown that TDD can be a challenge to introduce to developers and that the best way to learn it is by applying it on a small sample or noncritical project. Only after working with TDD will its advantages be fully understood and appreciated.
TDD encourages following a continuous cycle of development involving small and manageable steps. Figure 14-1 illustrates the steps involved in test-driven development.
First, you generate an initial list of tests that apply to your task. Tests should verify all expected functionality, a variety of inputs, and error handling. Your initial list will rarely, if ever, include all of the tests you will need to write. As you work, you'll realize other inputs or scenarios that need testing. As this happens, simply add them to the list. Think of this list of tests as the requirements of your application. If your code needs to implement a certain behavior, make sure you have one or more unit tests to enforce it.
Once your initial list is ready, you now enter the cycle of TDD, shown in Figure 14-1. As shown at the top of the diagram, select one test from the list and begin writing the unit test. Of course, this cannot compile because the code you're testing does not exist. Write just enough application code to successfully compile and then run the test. As expected, it will fail because the implementation isn't complete. Add enough implementation logic to allow the test to pass. Resist the temptation to write all of the code you think will eventually be needed; instead, focus only on the current test. The code will be completed later as you implement all of the tests.
Once you have a passing test, you can refactor your code. As you learned in Chapter 11, refactoring is the process of making small changes to code that improve the design of the code but do not alter its functionality. Refactoring is critical when following TDD, as your code might not be as clean or organized as it might otherwise be when following a traditional design-first, top-town development model. Refactor to remove duplication and reduce complexity, and to improve maintainability and overall design.
Make sure your refactoring changes are done in small steps, building and running tests with each change. Once refactoring is complete, resume your cycle at the top by selecting the next test. When the list of tests is empty, you're ready to move on to your next task, confident that the functionality you've implemented has been tested and will be much easier to maintain.
This process is often abbreviated to what is known as "red, green, refactor." The "red" means you begin with a failing test. Once the implementation code is written, the test will pass, giving you the "green" result. Finally, the code is refactored to improve design.
You know from Chapter 11 that Visual Studio helps you perform refactoring in efficient and less error- prone ways. Later in this chapter, you'll see how Team System can facilitate a TDD approach through its integrated unit testing features and by offering code generation from within unit tests.
Unit testing is not a new concept. Before Team System introduced integrated unit testing, developers needed to rely on third-party frameworks. The de facto standard for .NET unit testing has been an open- source package called NUnit. NUnit has its original roots as a .NET port of the Java-based JUnit unit testing framework. JUnit is itself a member of the extended xUnit family.
There are many similarities between NUnit and the unit testing framework in Team System. The structure and syntax of tests and the execution architecture are conveniently similar. If you have existing suites of NUnit-based tests, it is generally easy to convert them for use with Team System.
James Newkirk, who led development of NUnit 2.0 and is now employed at Microsoft, has written a tool to convert your NUnit tests to Team System's syntax. At the time of writing, this tool, called the "NUnit Converter," is available from GotDotNet at http://www.workspaces.gotdotnet.com/nunitaddons.
Team System's implementation of unit testing is not merely a port of NUnit. Microsoft has added a number of features that are unavailable with the version of NUnit available at the time of this writing. Among these are IDE integration, code generation, new attributes, enhancements to the Assert class, and built-in support for testing nonpublic members. We describe all of these in detail later in this chapter.