The Meaning of Unit Testing and Test-Driven Development

When we talk about software testing, this refers to a whole host of different kinds of testing that can take place, such as unit testing, acceptance testing, exploratory testing, performance testing, and scalability testing, to name several. To set the stage for this chapter, it's helpful to start with a shared understanding of what is meant by unit testing—the subject of this section.

Defining Unit Testing

You can practice unit testing in a variety of ways, and everybody who has done it tends to have an opinion on how best to go about it. In our experience, the following attributes tend to be present in most long-term successful unit testing:

  • Testing small pieces of production code (“units”)
  • Testing in isolation from the rest of the production code
  • Testing only public endpoints
  • Running the tests gets an automated pass/fail result

Each of these rules and how they impact the way you write unit tests are examined in the following sections.

Testing Small Pieces of Code

When writing a unit test, you're often looking for the smallest piece of functionality that you can reasonably test. In an object-oriented language like C#, this usually means nothing larger than a class, and in most cases, you're testing a single method of a class. The use of testing small pieces of code is that it allows you to quickly write simple tests. The tests need to be easy to understand so that you can verify that you're accurately testing what you intend to.

Source code is read far more often than it is written; this is especially important in unit tests, which attempt to codify the expected rules and behaviors of the software. When a unit test fails, the developer should be able to very quickly read the test to understand what has failed and why, so he or she can better understand how to fix what's broken. Testing small pieces of code with small tests greatly enhances this critical comprehensibility.

Testing in Isolation

Another important aspect of a unit test is that it should very accurately pinpoint where problems are when they arise. Writing code against small pieces of functionality is an important aspect of this, but it's not enough. You need to isolate your code from any other complex code with which it may interact, so that you can be fairly sure a test failure is due to bugs in the code you're testing rather than bugs in collaborating code.

Testing in isolation has an additional benefit in that the code with which you will eventually interact with may not yet exist. This is particularly true when you're working on larger teams with several active developers; several teams may handle interacting pieces of functionality and develop them in parallel. Testing your components in isolation not only allows you to make progress before other components are available, but it also works to help you better understand how components will be interacting with one another, and catch design mistakes before integrating those components together.

Testing Only Public Endpoints

Many developers who first start unit testing often feel the most pain when it comes time to change internal implementations of a class. A few changes to code can cause multiple unit tests to fail, and developers can become frustrated trying to maintain the unit tests while making those production changes. A common source of this frustration comes from unit tests that know too much about how the class they're testing works.

When writing unit tests, if you limit yourself to the public endpoints of the product (the integration points of a component) you are isolating the unit tests from many of the internal implementation details of the component. This means that changing the implementation details will break your unit tests far less often.

Automated Results

Given that you'll write tests against small pieces of code, it's pretty clear that you'll eventually have a large number of unit tests. To gain the benefits of unit tests, you will want to run them frequently as you develop them, to ensure that you're not breaking existing functionality while you do your work. If this process is not automated, it can result in a big productivity drain on the developer (or worse, it becomes an activity that the developer actively avoids). It's also important that the result of unit tests be a simple pass or fail judgment; unit test results should not be open to interpretation.

To help the automation process, developers usually resort to using a unit testing framework. Such frameworks generally allow the developer to write tests in their preferred programming language and development environment, and then create a set of pass/fail rules that the framework can evaluate to determine whether or not the test was successful. Unit testing frameworks generally come with a piece of software called a runner, which discovers and executes unit tests in your projects. There are generally a large variety of such runners; some integrate into Visual Studio, some run from a command line, and others come with a GUI, or even integrate with automated build tools (like build scripts and automated build servers).

Unit Testing as a Quality Activity

Most developers choose to write unit tests because it increases the quality of their software. In this situation, unit testing acts primarily as a quality assurance mechanism, so it's fairly common for the developer to write the production code first, and then write the unit tests afterwards. Developers use their knowledge of the production code and the desired end-user behavior to create the list of tests that help assure them that the code behaves as intended.

Unfortunately, there are weaknesses with this ordering of tests after production code. It's easy for developers to overlook some piece of the production code that they've written, especially if the unit tests are written long after the production code was written. It's not uncommon for developers to write production code for days or weeks before getting around to the final part of unit testing, and it requires an extremely detail-oriented person to ensure that every avenue of the production code is covered with an appropriate unit test. Test-driven-development works to solve some of those shortcomings.

Defining Test-Driven-Development

Test-Driven-Development is the process of using unit tests to drive the design of your production code by writing the tests first, and then writing just enough production code to make the tests pass. On its surface, the end result of traditional unit testing and Test-Driven Development is the same: production code along with unit tests that describe the expected behavior of that code, which you can use to prevent behavior regression. If both are done correctly, it can often be impossible to tell by looking at the unit tests whether the tests came first or the production code came first.

When we talk about unit testing being a quality activity, we are speaking primarily of the quality activity of reducing bugs in the software. Practicing TDD achieves this goal, but it is a secondary goal; the primary purpose of TDD is to increase the quality of the design. By writing the unit tests first, you describe the way you want components to behave before you've written any of the production code. You cannot accidentally tie yourself to any specific implementation details of the production code because those implementation details don't yet exist. Rather than peeking inside the innards of the code under test, the unit tests become consumers of the production code in much the same way that any eventual collaborator components will consume it. These tests help to shape the API of components by becoming the first users of the APIs.

The Red/Green Cycle

You still follow all the same guidelines for unit tests set out earlier: write small, focused tests against components in isolation, and run them in an automated fashion. Because you write the tests first, you often get into a rhythm when practicing TDD:

  • Write a unit test
  • Run it and watch it fail (because the production code is not yet written)
  • Write just enough production code to make the test pass
  • Re-run the test and watch it pass

This cycle is repeated over and over again until the production code is completed. Because most unit testing frameworks represent failed tests with red text/UI elements and passed tests with green, this cycle is often call the red/green cycle.

It's important to be diligent in this process. You're not allowed to write any new production code unless there is a failing unit test that tells you what you're doing, and once the test passes, you must stop writing new production code (until you have a new test that is failing). When practiced regularly, this acts as a forcing function to tell you when to stop writing new code. Just do enough to make a test pass, and then stop; if you're tempted to keep going, describe the new behavior you want to implement in another test. This not only gives you the later bug quality benefits of having no undescribed functionality, but it also gives you a moment for pause to consider whether you really need the new functionality and are willing to commit to supporting it long term.

You can also use the same rhythm when fixing bugs. You may need to debug around in the code to discover the exact nature of bugs, but once you've discovered it, you write a unit test that describes the behavior you want, watch it fail, and then modify the production code to correct the mistake. You'll have the benefit of the existing unit tests to help you ensure that you don't break any existing expected behavior with your change.

Refactoring

Following the pattern described here, you'll often find yourself with messy code as a result of these very small incremental code changes. You've been told to stop when the light goes green, so how do we clean up the mess we've made by piling small change on top of small change? The answer is refactoring.

The word refactoring can be overloaded, so we should be very clear that when we talk about refactoring, we mean the process of changing the implementation details of production code without changing its externally observable behavior. What that means in practical terms is that refactoring is a process you undertake only when all unit tests are passing. As you refactor and update your production code, the unit tests should continue to pass. Don't change any unit tests when refactoring; if what you're doing requires unit tests changes, then you're adding, deleting, or changing functionality, and that should first be done with the rhythm of writing tests discussed in the section “The Red/Green Cycle.” Resist the temptation to change tests and production code all at the same time. Refactoring should be a mechanical, almost mathematical process of structured code changes that do not break unit tests.

Structuring Tests with Arrange, Act, Assert

Many of the unit testing examples in this book will follow a structure called “Arrange, Act, Assert” (sometimes abbreviated as 3A). This phrase (coined by William C. Wake in http://weblogs.java.net/blog/wwake/archive/2003/12/tools_especiall.html) describes a structure for your unit tests that reads a bit like three paragraphs:

  • Arrange: Get the environment ready
  • Act: The (typically one) line of code under test
  • Assert: Ensure that what you expected to happen, happened

A unit test written in 3A style looks something like this:

[TestMethod]
public void PoppingReturnsLastPushedItemFromStack()
{
    // Arrange
    Stack<string> stack = new Stack<string>();
    string value = "Hello, World!";
    stack.Push(value);

    // Act
    string result = stack.Pop();

    // Assert
    Assert.AreEqual(value, result);
}

I've added the Arrange, Act, and Assert comments here to illustrate the structure of the test, though it is sometimes common to include them in real tests as well. The arrange in this case creates an empty stack and pushes a value onto it. These are the pre-conditions in order for the test to function. The act, popping the value off the stack, is the single line under test. Finally, the assert tests one logical behavior: that the returned value was the same as the value pushed onto the stack. If you keep your tests sufficiently small, even the comments are unnecessary; blank lines are sufficient to separate the sections from one another.

The Single Assertion Rule

When you look at the 3A stack example, you'll see only a single assert to ensure that you got back the expected value. Aren't there a lot of other behaviors you could assert there as well? For example, you know that once you pop off the value, the stack is empty; shouldn't you make sure it's empty? And if you try to pop another value, it should throw an exception; shouldn't you test that as well?

Resist the temptation to test more than one behavior in a single test. A good unit test is about testing a very small bit of functionality, usually a single behavior. The behavior you're testing here isn't the large behavior of “all properties of a recently emptied stack”; rather, it's the small behavior of popping a known value from a non-empty stack. To test the other properties of an empty stack, you should write more unit tests, one per small behavior you want to verify.

Keeping your tests svelte and single-focused means that when you break something in your production code, you're more likely to break only a single test. This, in turn, makes it much easier to understand what broke and how to fix it. If you mix several behaviors into a single unit test (or across several unit tests), a single behavior break might cause dozens of tests to fail and you'll have to sift through several behaviors in each one to figure out exactly what's broken.

Some people call this the single assertion rule. Don't confuse this with thinking that your tests should have only a single call to assert. Oftentimes, it's necessary to call Assert several times to verify one logical piece of behavior; that's perfectly fine, so long as you remember to test just one behavior at a time.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.220.184.6