November 26, 2024, Tuesday, 330

Test Flexibly With AspectJ And Mock Objects

From NeoWiki

Revision as of 10:00, 5 March 2007 by Neo (Talk | contribs)
Jump to: navigation, search
Enhance unit testing with test-only behavior

Nicholas Lesiecki, Principal software engineer, eBlox, Inc.

01 May 2002

Programmers who have incorporated unit testing into their development process know the advantages it brings: cleaner code, courage to refactor, and higher velocity. But even the most die-hard unit testers can falter when faced with testing a class that relies on system state for its behavior. Nicholas Lesiecki, a respected Java programmer and leader in the XP community, introduces the problems surrounding test-case isolation and shows us how to use mock objects and AspectJ to develop precise and robust unit tests.


The recent attention to Extreme Programming (XP) has spilled over onto one of its most portable practices: unit testing and test-first design. As software shops have adopted XP's practices, many developers have seen the increase in quality and speed that comes from having a comprehensive unit-test suite. But writing good unit tests takes time and effort. Because each unit cooperates with others, writing a unit test can involve a significant amount of setup code. This makes tests more expensive, and in certain cases (such as code that acts as a client to a remote system) such tests can be almost impossible to implement.

In XP, unit tests complement integration and acceptance tests. These latter two test types may be undertaken by a separate team or as a separate activity. but unit tests are written simultaneously with the code to be tested. Facing the pressure of an impending deadline and a headache-inducing unit test, it is tempting to write a haphazard test or not bother with the test at all. Because XP relies on positive motivation and self-sustaining practices, it's in the best interest of the XP process (and the project!) to keep the tests focused and easy to write.

Tools clipart.png Tip: Required background
This article focuses on unit testing with AspectJ, so the article assumes you are familiar with basic unit testing techniques. If you aren't familiar with AspectJ, it would probably help to read my introduction to AspectJ before you go any further (see Resources). The AspectJ techniques presented here aren't very complex, but aspect-oriented programming requires a little getting used to. In order to run the examples, you will need to have Ant installed on your test machine. You do not, however, need any special Ant expertise (beyond what's required for a basic install) to work with the examples. For more information or to download Ant, see the Resources section.

Mock objects can help to solve this dilemma. Mock object tests replace domain dependencies with mock implementations used only for testing. This strategy does, however, present a technical challenge in certain situations, such as unit testing on remote systems. AspectJ, an aspect-oriented extension to the Java language, can take unit testing the rest of the way by allowing us to substitute test-only behavior in areas where traditional object-oriented techniques would fail.

In this article we'll examine a common situation where writing unit tests is both difficult and desirable. We'll start by running a unit test for the client component of an EJB-based application. We'll use the example as a springboard to discuss some of the problems that can arise with unit testing on remote client objects. To solve these problems, we'll develop two new test configurations that rely on AspectJ and mock objects. By the end of the article you should have an appreciation of common unit-testing problems and their solutions, as well as a window into some of the interesting possibilities afforded by AspectJ and mock object testing.

In order to follow the code samples we'll be working with throughout this article, you might want to install the example application now.

Contents

A unit testing example

The example consists of a test for an EJB client. Many of the issues raised in this case study could also be applied to code that calls Web services, JDBC, or even a "remote" part of the local application through a facade.

The server-side CustomerManager EJB performs two functions: it looks up the names of customers and registers new customer names with the remote system. Listing 1 shows the interface that CustomerManager exposes to its clients:

Listing 1. CustomerManager's remote interface

public interface CustomerManager extends EJBObject {

  /**
   * Returns a String[] representing the names of customers in the system
   * over a certain age.
   */
  public String[] getCustomersOver(int ageInYears) throws RemoteException;

  /**
   * Registers a new customer with the system. If the customer already 
   * exists within the system, this method throws a NameExistsException.
   */
  public void register(String name) 
    throws RemoteException, NameExistsException;
}

The client code, called ClientBean, essentially exposes the same methods, delegating their implementation to the CustomerManager, as shown in Listing 2.

Listing 2. The EJB client code

public class ClientBean {
  private Context initialContext;
  private CustomerManager manager;

  /**
   * Includes standard code for referencing an EJB.
   */
  public ClientBean() throws Exception{
    initialContext = new InitialContext();
    Object obj = initialContext.lookup("java:comp/env/ejb/CustomerManager");
    CustomerManagerHome managerHome = (CustomerManagerHome)obj;

    /*Resin uses Burlap instead of RMI-IIOP as its default
     *  network protocol so the usual RMI cast is omitted.
     *  Mock Objects survive the cast just fine.
     */
    manager = managerHome.create();
  }

  public String[] getCustomers(int ageInYears) throws Exception{
    return manager.getCustomersOver(ageInYears);
  }

  public boolean register(String name) {
    try{
      manager.register(name);
      return true;
    }
    catch(Exception e){
      return false;
    }
  }
}

I've kept this unit deliberately simple so that we can focus on the test. The ClientBean's interface differs only slightly from the CustomerManager's interface. Unlike the ClientManager, the ClientBean's register() method returns a boolean and does not throw an exception if the customer already exists. These are the functions that a good unit test should verify.

The code shown in Listing 3 implements the test for ClientBean with JUnit. There are three test methods, one for getCustomers() and two for register() (one for success and one for failure). The test presumes getCustomers() will return a 55-item list and register() will return false for EXISTING_CUSTOMER and true for NEW _CUSTOMER.

Listing 3. The unit test for ClientBean

//[...standard JUnit methods omitted...]

public static final String NEW_CUSTOMER = "Bob Smith";
public static final String EXISTING_CUSTOMER = "Philomela Deville";
public static final int MAGIC_AGE = 35;

public void testGetCustomers() throws Exception {
  ClientBean client = new ClientBean();
  String[] results = client.getCustomers(MAGIC_AGE);
  assertEquals("Wrong number of client names returned.", 55, results.length);
}

public void testRegisterNewCustomer() throws Exception{
  ClientBean client = new ClientBean();
  //register a customer that does not already exist
  boolean couldRegister = client.register(NEW_CUSTOMER);
  assertTrue("Was not able to register " + NEW_CUSTOMER, couldRegister);
}

public void testRegisterExistingCustomer() throws Exception{
  ClientBean client = new ClientBean();

  // register a customer that DOES exist
  boolean couldNotRegister = ! client.register(EXISTING_CUSTOMER);
  String failureMessage = "Was able to register an existing customer ("
                          + EXISTING_CUSTOMER + "). This should not be " +
                          "possible."
  assertTrue(failureMessage, couldNotRegister);
}

If the client returns the expected result, the tests will pass. While this test is very simple, you can easily imagine how the same procedure would apply to a more complex client, such as a servlet that generates output based on calls to the EJB component.

If you've already installed the sample application, try running this test a few times with the command ant basic in the example directory.

Tools clipart.png Tip: Higher-level testing
This article focuses on unit testing, however integration or functional tests are just as important to rapid development and high quality. In fact, the two types of test complement each other. Higher-level tests validate the system's end-to-end integrity while low-level unit tests validate the individual components. Both are useful in different situations. For instance, a functional test might pass while a unit test ferrets out a bug that occurs only in rare circumstances. Or vice versa: the unit tests could pass while the functional tests reveal that the individual components aren't wired together correctly. With functional tests it can make more sense to do data-dependent testing, as the goal is to verify the aggregate behavior of the system.

Problems with data-dependent testing

After you've run the above test a few times you will notice inconsistent results: sometimes the tests pass, sometimes they don't. This inconsistency is due to the EJB component's implementation -- not the client's. The EJB component in the example simulates an uncertain system state. Inconsistencies in test data pose a real problem when it comes to implementing simple, data-centric testing. Another big problem is the tendency to duplicate test efforts. We'll address both of these issues here.

Data management

The easy way to overcome uncertainties in the data is to manage the state of the data. If we could somehow guarantee that there were 55 customer records in the system before we ran our unit test, we could be sure that any failures in our getCustomers() test would indicate flaws in our code, rather than data issues. But managing the state of the data introduces its own set of problems. Before each test runs, you have to ensure that the system is in the correct state for that particular test. If you're not vigilant, the results of one test can change the system's state in such a way that the next test will fail.

To cope with this burden you can use shared setup classes or a batch-input process. But both these approaches represent a significant investment in infrastructure. If your application persists its state to some type of storage, you may be in for further problems. Adding data to the storage system could be complicated, and frequent insertions and deletions could slow test execution.

Worse than encountering problems with state management is encountering a situation where such management is downright impossible. You may find yourself in this sort of situation when testing client code for a third-party service. Read-only type services might not expose the ability to change the system state, or you may be discouraged from inserting test data for business reasons. For instance, it's probably a bad idea to send a test order to a live processing queue.

Duplicated effort

Even if you have complete control over the system state, state-based testing can still produce an unwanted duplication of test effort -- and you don't want to write the same test twice.

Let's take our test application as an example. If I control the CustomerManager EJB component, I presumably already have a test that verifies that it behaves correctly. My client code doesn't actually perform any of the logic involved in adding a new customer to the system; it simply delegates the operation to the CustomerManager. So, why should I retest the CustomerManager here?

If someone changes the implementation of CustomerManager so that it gives different responses to the same data, I would have to alter two tests in order to track the change. This smells of an overcoupling of tests. Fortunately, this duplication is unnecessary. If I can verify that ClientBean is communicating correctly with the CustomerManager, I have sufficient confirmation that ClientBean is working as it should. Mock object testing allows you to perform exactly this sort of verification.

Mock object testing