Test Flexibly With AspectJ And Mock Objects
From NeoWiki
- 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.
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.
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
Mock objects keep unit tests from testing too much. Mock object tests replace real collaborators with mock implementations. And mock implementations allow easy verification that the tested class and the collaborator are interacting correctly. I'll demonstrate how this works with a simple example.
The code we're testing deletes a list of objects from a client-server data management system. Listing 4 shows the method we're testing:
Listing 4. A test method
public interface Deletable { void delete(); } public class Deleter { public static void delete(Collection deletables){ for(Iterator it = deletables.iterator(); it.hasNext();){ ((Deletable)it.next()).delete(); } } }
A naive unit test might create an actual Deletable and then verify that it disappeared after calling Deleter.delete(). To test the Deleter class using mock objects, however, we write a mock object that implements Deletable, as shown in Listing 5:
Listing 5. A mock object test
public class MockDeletable implements Deletable{ private boolean deleteCalled; public void delete(){ deleteCalled = true; } public void verify(){ if(!deleteCalled){ throw new Error("Delete was not called."); } } }
Next, we use the mock object in Deleter's unit test, as shown in Listing 6:
Listing 6. A test method that uses a mock object
public void testDelete() { MockDeletable mock1 = new MockDeletable(); MockDeletable mock2 = new MockDeletable(); ArrayList mocks = new ArrayList(); mocks.add(mock1); mocks.add(mock2); Deleter.delete(mocks); mock1.verify(); mock2.verify(); }
Upon execution, this test verifies that Deleter successfully called delete() on each of the objects in the collection. In this manner, mock object tests precisely control the surroundings of the tested class and verify that the unit interacts with them correctly.
The limitations of mock objects
Object-oriented programming limits the influence of mock object tests on the execution of the tested class. For instance, if we were testing a slightly different delete() method -- perhaps one that looked up a list of deletable object before removing them -- our test could not supply mock objects so easily. The following method would be difficult to test using mock objects:
Listing 7. A method that would be hard to mock
public static void deleteAllObjectMatching(String criteria){ Collection deletables = fetchThemFromSomewhere(criteria); for(Iterator it = deletables.iterator(); it.hasNext();){ ((Deletable)it.next()).delete(); } }
Proponents of the mock object testing method claim that a method such as the one above should be refactored in order to make it more "mock friendly." Such refactoring often leads to a cleaner, more flexible design. In a well-designed system, each unit interacts with its context through well-defined interfaces that support a variety of implementations, including mock implementations.
But even in well-designed systems, there are cases where a test cannot easily influence context. This occurs whenever code calls on some globally accessible resource. For instance, calls to static methods are difficult to verify or replace, as is object instantiation using the new operator.
Mock objects can't help with global resources because mock-object testing relies on the manual replacement of domain classes with test classes that share a common interface. Because static method calls (and other types of global resource access) cannot be overridden, calls to them cannot be "redirected" the way instance methods can.
You can pass in any Deletable to the method in Listing 4; however, short of loading a different class in the place of the real thing, you cannot replace a static method call with a mock method call using the Java language.
A refactoring example
Often some refactoring can steer your application code toward an elegant solution that's also easily testable -- but this isn't always the case. Refactoring to enable testing does not make sense if the resulting code is harder to maintain or understand.
EJB code can be particularly tricky to refactor into a state that allows easy mock testing. For instance, one type of mock-friendly refactoring would change the following sort of code:
// in EJBNumber1 public void doSomething(){ EJBNumber2 collaborator = lookupEJBNumber2(); // do something with collaborator }
into this sort:
public void doSomething(EJBNumber2 collaborator){ // do something with collaborator }
In a standard object-oriented system, this refactoring example increases flexibility by allowing callers to provide collaborators to a given unit. But such refactoring could be undesirable in an EJB-based system. For performance reasons, remote EJB clients need to avoid as many remote method calls as possible. The second approach requires that a client first look up and then create an instance of EJBNumber2, a process that involves several remote operations.
In addition, well-designed EJB systems tend toward a "layered" approach, where the client layer does not necessarily know about implementation details such as the existence of EJBNumber2. The preferred means of getting an EJB instance is to look up a factory (the Home interface) from a JNDI context, and then call a creation method on the factory. This strategy gives EJB applications much of the flexibility intended by the refactored code sample. Because application deployers can swap in a completely different implementation of EJBNumber2 at deployment time, the behavior of the system can be easily adjusted. JNDI bindings, however, cannot be easily changed at run time. Therefore, mock object testers are faced with the choice of redeploying in order to swap in a mock for EJBNumber2 or abandoning the entire testing model.
Fortunately, AspectJ offers a workaround.