Test for fun and profit, Part 2: Unit testing

Learn the ins and outs of testing in a Java environment

Can a programmer function as an effective tester? I believe so -- but it requires us to don a cap of a different color and think about interacting with our application in a very different way. In October's How-To Java I outlined the principles of software testing and tried to convey its importance in the development of high-quality software. This month I assume a slightly more aggressive stance to decimate any notions you may have that testing is best done in an informal, ad hoc fashion. Unpleasant as testing may be, it must be done.

Let's face it: we're developers. We want to create, not test. Any testing we do typically consists of taking our creation for a spin around the track and fixing the resulting flat tires. Once we turn the car over to someone else ... well, as they say, Out of sight, out of mind.

Unfortunately, what I've just described is not testing -- it's not even a close cousin. Testing as an endeavor requires intellectual effort and methodical preparation. Remember, successful tests uncover defects. I hope, when you've finished reading, you understand the challenge behind devising successful tests, because you will then understand why testing tools must be both complete (so you can build thorough tests) and as easy to use and unobtrusive as possible (so they don't themselves complicate the process). Next month we'll begin to build a test harness of our own.

Unit testing

Last month you learned a little bit about many different types of tests and testing strategies: unit and component tests, integration tests, system tests, and so on. This month, we'll focus our attention on the first of these: unit (and component) tests.

I think the best way to get a handle on the notion of unit testing is to consider the simplest programmatic unit in a Java program -- a single class.

public
class Foo {
  private
  HashMap _hashmap = new HashMap();
  private
  Service _service = null;
  public
  Foo(Service service) {
    _service = service;
  }
  public
  Handle
  add(Node node) {
    Handle handle = _service.register(node);
    _hashmap.put(handle, node);
    return handle;
  }
  public
  void
  nestedToString(String stringPrefix, StringBuffer stringbuffer)
  throws EvaluationException {
    stringbuffer.append(stringPrefix);
    stringbuffer.append(evalutate());
    stringbuffer.append('\n');
      .
      .
      .
  }
  public
  abstract
  Object
  evaluate()
  throws EvaluationException {
    .
    .
    .
  }
}

Keep that class in mind as we continue our discussion.

Philosophically, unit testing looks at a programmatic unit, such as a class, as a box with a set of inputs and a set of outputs through which all communication between the box and its environment must flow.

Unit testing: The concept

The figure above depicts a class loosely modeled as one such idealized box. Theoretically, we should be able to apply a set of one or more inputs to a unit, observe the outputs in each case, and thereby determine whether the unit is functioning correctly. The unit-test approach thus presents a nice, simple, logically sound model.

As an aside, we'll see that if we modify this simple notion slightly and examine the changes in the box's internal state, we can greatly simplify the test development for units whose internal states are well reflected in the values of their outputs.

Inputs and outputs

In Java, the principal vehicle by which we apply inputs and obtain outputs is the method invocation. After all, we're ultimately interested in testing a unit's behavior, and methods contain the code that makes the unit do the behaving.

Two ways to apply inputs

The code above illustrates the two primary ways that inputs are applied during a method invocation. Let's consider each in turn.

  • A method's parameter list provides the most obvious input path:

      public
      void
      add(Node node) {
        .
        .
        .
    
  • The invocation of a method on an external object provides the other input path. I'll explain what an external object is shortly. What's important is that a method invocation introduces information that can affect the functioning of the unit being tested and therefore qualifies as an input:

      Handle handle = _service.register(node);
    

Four ways to obtain outputs

The code for a single class also illustrates the four primary ways outputs are obtained as the result of a method invocation. Let's consider each in turn.

  • The most common way to return an output from a method is to supply a return value:

      return handle;
    
  • The second way to provide output is through the parameter list. When a method is invoked, the caller supplies it with parameters as inputs. Many times the method leaves the parameters unchanged (for primitive types, which are passed by value, this is always the case). But a method may change the parameters if they permit it -- if they are not immutable (such as an instance of the String class, for example):

      stringbuffer.append(stringPrefix);
    
  • The third way to provide output from a method is through the exception mechanism:

      throws EvaluationException...
    
  • Finally, the forth way to provide output is through method calls made on an external object:

      Handle handle = _service.register(node);
    

Internal and external objects considered

You'll notice that in the discussion above I use the term external object. Let's consider the difference between internal and external objects in a little more detail since it affects testing significantly; whether an object is internal or external can also affect the amount of work required to design a suite of tests. The line between internal and external objects is not always well defined, but qualitatively it amounts to the following:

  • An internal object is an entity whose entire scope of effect is constrained by the enclosing object. A private member variable containing the name of an instance, for example, is an internal object. An object's methods can manipulate the internal object and can call methods on the internal object, but neither of those operations exerts any effect on the environment outside the enclosing object. Internal objects are sometimes said to compose the enclosing object.

  • An external object is an entity whose entire scope of effect is not completely constrained by the enclosing object. This set typically includes objects that were passed in during construction or initialization and that are held in member variables. Classes with static methods also fall within the scope of this definition.

The effects that external objects will have as inputs and outputs must be taken into account when you develop tests.

State

When we perform a test on a unit, we apply a set of inputs and observe what happens at the outputs.

Consider once again the single-class code. When one of the class's methods is invoked, several things happen. First, the method takes the input parameters (node), combines them with the state variables (_hashmap), and produces a result. Second, the state of the object changes (that is, _hashmap holds one more node). For any particular method call both of those things may occur, or only one.

I mentioned earlier that it is often convenient to extend the list of what we observe to include the unit's internal state. That is the case for two reasons:

  1. The internal states of some units aren't well reflected in the values of the outputs. In theory, you can make a determination of the unit's internal state, given a large enough input set. However, by opening the box and observing the state of the unit, you can often dramatically reduce the size of the required test set.

  2. Testing is (or should be) the first step in a larger defect-fixing process. That process can be facilitated if the tests provide information about where an error was introduced.

Design for testability

As I stated in the introduction, testing can't be effectively done if it's an afterthought. It deserves as much thought and preparation as the coding itself.

One key component of a testing strategy, therefore, is a methodology and framework that supports the testing process. We'll look at one such framework next month.

Designing for testability represents another important component. Simply stated, designing for testability takes into account a central fact: the design of the units to be tested has a great impact on the ease of testing those units. Therefore, I want to emphasize the importance not just of testing but also of designing applications and their components with testing in mind.

In practice, this boils down to following a few simple rules, some of which you're already probably familiar with:

  1. Design nice, clean, well-documented interfaces between components

  2. Observe the rules of encapsulation

  3. Break complex operations into small, easy-to-manage (and easy-to-test) steps

Conclusion

If you've stayed with me this long, I hope you've come to better understand both the importance of testing and the importance of doing it thoroughly. Remember, to test how a unit behaves, you must both identify and control all of its inputs and identify and observe all of its outputs.

Next month we'll begin work on a simple testing framework that will allow you to test your code.

Todd Sundsted has been writing programs since computers became available in convenient desktop models. Though originally interested in building distributed applications in C++, Todd moved on to the Java programming language when it became the obvious choice for that sort of thing. In addition to writing, Todd is an architect with ComFrame Software Corporation.

Learn more about this topic