Chapter 22. Constructing Complex Test Data

Many attempts to communicate are nullified by saying too much.

—Robert Greenleaf

Introduction

If we are strict about our use of constructors and immutable value objects, constructing objects in tests can be a chore. In production code, we construct such objects in relatively few places and all the required values are available to hand from, for example, user input, a database query, or a received message. In tests, however, we have to provide all the constructor arguments every time we want to create an object:

image

The code to create all these objects makes the tests hard to read, filling them with information that doesn’t contribute to the behavior being tested. It also makes tests brittle, as changes to the constructor arguments or the structure of the objects will break many tests. The object mother pattern [Schuh01] is one attempt to avoid this problem. An object mother is a class that contains a number of factory methods [Gamma94] that create objects for use in tests. For example, we could write an object mother for orders:

Order order = ExampleOrders.newDeerstalkerAndCapeOrder();

An object mother makes tests more readable by packaging up the code that creates new object structures and giving it a name. It also helps with maintenance since its features can be reused between tests. On the other hand, the object mother pattern does not cope well with variation in the test data—every minor difference requires a new factory method:

image

Over time, an object mother may itself become too messy to support, either full of duplicated code or refactored into an infinity of fine-grained methods.

Test Data Builders

Another solution is to use the builder pattern to build instances in tests, most often for values. For a class that requires complex setup, we create a test data builder that has a field for each constructor parameter, initialized to a safe value. The builder has “chainable” public methods for overwriting the values in its fields and, by convention, a build() method that is called last to create a new instance of the target object from the field values.1 An optional refinement is to add a static factory method for the builder itself so that it’s clearer in the test what is being built. For example, a builder for Order objects might look like:

1. This pattern is essentially the same as a Smalltalk cascade.

image

Tests that just need an Order object and are not concerned with its contents can create one in a single line:

Order order = new OrderBuilder().build();

Tests that need particular values within an object can specify just those values that are relevant and use defaults for the rest. This makes the test more expressive because it includes only the values that are relevant to the expected results. For example, if a test needed an Order for a Customer with no postcode, we would write:

image

We find that test data builders help keep tests expressive and resilient to change. First, they wrap up most of the syntax noise when creating new objects. Second, they make the default case simple, and special cases not much more complicated. Third, they protect the test against changes in the structure of its objects. If we add an argument to a constructor, then all we have to change is the relevant builder and those tests that drove the need for the new argument.

A final benefit is that we can write test code that’s easier to read and spot errors, because each builder method identifies the purpose of its parameter. For example, in this code it’s not obvious that “London” has been passed in as the second street line rather than the city name:

TestAddresses.newAddress("221b Baker Street", "London", "NW1 6XE");

A test data builder makes the mistake more obvious:

image

Creating Similar Objects

We can use builders when we need to create multiple similar objects. The most obvious approach is to create a new builder for each new object, but this leads to duplication and makes the test code harder to work with. For example, these two orders are identical apart from the discount. If we didn’t highlight the difference, it would be difficult to find:

image

Instead, we can initialize a single builder with the common state and then, for each object to be built, define the differing values and call its build() method:

image

This produces a more focused test with less code. We can name the builder after the features that are common, and the domain objects after their differences.

This technique works best if the objects differ by the same fields. If the objects vary by different fields, each build() will pick up the changes from the previous uses. For example, it’s not obvious in this code that orderWithGiftVoucher will carry the 10% discount as well as a gift voucher:

Order orderWithDiscount = hatAndCape.withDiscount(0.10).build();
Order orderWithGiftVoucher = hatAndCape.withGiftVoucher("abc").build();

To avoid this problem, we could add a copy constructor or a method that duplicates the state from another builder:

image

Alternatively, we could add a factory method that returns a copy of the builder with its current state:

Order orderWithDiscount = hatAndCape.but().withDiscount(0.10).build();
Order orderWithGiftVoucher = hatAndCape.but().withGiftVoucher("abc").build();

For complex setups, the safest option is to make the “with” methods functional and have each one return a new copy of the builder instead of itself.

Combining Builders

Where a test data builder for an object uses other “built” objects, we can pass in those builders as arguments rather than their objects. This will simplify the test code by removing the build() methods. The result is easier to read because it emphasizes the important information—what is being built—rather than the mechanics of building it. For example, this code builds an order with no postcode, but it’s dominated by the builder infrastructure:

image

We can remove much of the noise by passing around builders:

image

Emphasizing the Domain Model with Factory Methods

We can further reduce the noise in the test code by wrapping up the construction of the builders in factory methods:

image

As we compress the test code, the duplication in the builders becomes more obtrusive; we have the name of the constructed type in both the “with” and “builder” methods. We can take advantage of Java’s method overloading by collapsing this to a single with() method, letting the Java type system figure out which field to update:

image

Obviously, this will only work with one argument of each type. For example, if we introduce a Postcode, we can use overloading, whereas the rest of the builder methods must have explicit names because they use String:

image

This should encourage us to introduce domain types, which, as we wrote in “Domain Types Are Better Than Strings” (page 213), leads to more expressive and maintainable code.

Removing Duplication at the Point of Use

We’ve made the process of assembling complex objects for tests simpler and more expressive by using test data builders. Now, let’s look at how we can structure our tests to make the best use of these builders in context. We often find ourselves writing tests with similar code to create supporting objects and pass them to the code under test, so we want to clean up this duplication. We’ve found that some refactorings are better than others; here’s an example.

First, Remove Duplication

We have a system that processes orders asynchronously. The test feeds orders into the system, tracks their progress on a monitor, and then looks for them in a user interface. We’ve packaged up all the infrastructure so the test looks like this:

image

There’s an obvious duplication in the way the orders are created, sent, and tracked. Our first thought might be to pull that into a helper method:

image

This refactoring works fine when there’s a single case but, like the object mother pattern, does not scale well when we have variation. As we deal with orders with different contents, amendments, cancellations, and so on, we end up with this sort of mess:

image

We think a bit harder about what varies between tests and what is common, and realize that a better alternative is to pass the builder through, not its arguments; it’s similar to when we started combining builders. The helper method can use the builder to add any supporting detail to the order before feeding it into the system:

image

Then, Raise the Game

The test code is looking better, but it still reads like a script. We can change its emphasis to what behavior is expected, rather than how the test is implemented, by rewording some of the names:

image

We started with a test that looked procedural, extracted some of its behavior into builder objects, and ended up with a declarative description of what the feature does. We’re nudging the test code towards the sort of language we could use when discussing the feature with someone else, even someone non-technical; we push everything else into supporting code.

Communication First

We use test data builders to reduce duplication and make the test code more expressive. It’s another technique that reflects our obsession with the language of code, driven by the principle that code is there to be read. Combined with factory methods and test scaffolding, test data builders help us write more literate, declarative tests that describe the intention of a feature, not just a sequence of steps to drive it.

Using these techniques, we can even use higher-level tests to communicate directly with non-technical stakeholders, such as business analysts. If they’re willing to ignore the obscure punctuation, we can use the tests to help us narrow down exactly what a feature should do, and why.

There are other tools that are designed to foster collaboration across the technical and non-technical members in a team, such as FIT [Mugridge05]. We’ve found, as have others such as the LiFT team [LIFT], that we can achieve much of this while staying within our development toolset—and, of course, we can write better tests for ourselves.

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

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