Tricks with Test Data Builders: Refactoring Away Duplicated Logic Creates a Domain Specific Embedded Language for Testing
Test Data Builders remove a lot of duplication from test code, but there can often still be duplicated logic at the point at which the built objects are used. Many different tests will have very similar code that creates an object using a builder and then passes it to the code under test. We can address this duplication by factoring out test scaffolding that works with builders, not system objects. Doing so produces a higher level testing API that more clearly communicates the intent of the test and hides away unimportant details of how the system is being tested.
For example, consider a system to process orders. Orders are
sent into our system and processed asynchronously. To perform an
end-to-end system test, the test must must create an order, send
the order to our system and track the processing of the order by
waiting for correlated events to appear on the system's
monitoring topic and driving the client through its user interface.
That would look something like the following (where the
requestSender
and progressMonitor
do lots
of behind the scenes magic with JMS connections, sessions, message
producers and consumers, message properties and correlation
IDs).
@Test public void reportsTotalSalesOfOrderedProducts() { Order order1 = anOrder() .withLine("Deerstalker Hat", 1) .withLine("Tweed Cape", 1) .withCustomersReference(1234) .build(); requestSender.send(order1); progressMonitor.waitForConfirmation(order1); progressMonitor.waitForCompletion(order1); Order order2 = anOrder() .withLine("Deerstalker Hat", 1) .withCustomersReference(5678) .build(); requestSender.send(order2); progressMonitor.waitForConfirmation(order2); progressMonitor.waitForCompletion(order2); TotalSalesReport report = gui.openSalesReport(); report.displaysTotalSalesFor("Deerstalker Hat", equalTo(2)); report.displaysTotalSalesFor("Tweed Cape", equalTo(1)); }
It is tempting pull this duplication into a "helper" method that builds and uses an object. For example:
@Test public void reportsTotalSalesOfOrderedProducts() { submitOrderFor("Deerstalker Hat", "Tweed Cape"); submitOrderFor("Deerstalker Hat"); TotalSalesReport report = gui.openSalesReport(); report.displaysTotalSalesFor("Deerstalker Hat", equalTo(2)); report.displaysTotalSalesFor("Tweed Cape", equalTo(1)); } void submitOrderFor(String ... products) { OrderBuilder orderBuilder = anOrder() .withCustomersReference(customersReference++); for (String product : products) { orderBuilder = orderBuilder.withLine(product, 1); } Order order = orderBuilder.build(); requestSender.send(order); progressMonitor.waitForConfirmation(order); progressMonitor.waitForCompletion(order); } private int customersReference = 1;
However, this refactoring leaves us with the same difficulties that we encountered with the Object Mother when we have to vary data in different tests. We will need to submit orders with different properties and submit different kinds of events - orders, order amendments, order cancellations, etc. The helper method has the very same problems we found with the Object Mother, and that we avoided by using builders to create our test data.
void submitOrderFor(String ... products) { ... } void submitOrderFor(String product, int count) { ... } void submitOrderFor(String product, int count, String otherProduct, int otherCount) { ... } void submitOrderFor(String product, double discount) { ... } void submitOrderFor(String product, String giftVoucherCode) { ... } ... etc ...
Instead, we can pass an order builder to the method that sends an order into the system, just as we do when combining builders. That method can add properties through the builder before building the order sending it into the system.
@Test public void reportsTotalSalesOfOrderedProducts() { sendAndProcess(anOrder() .withLine("Deerstalker Hat", 1) .withLine("Tweed Cape", 1)); sendAndProcess(anOrder() .withLine("Deerstalker Hat", 1)); TotalSalesReport report = gui.openSalesReport(); report.displaysTotalSalesFor("Deerstalker Hat", equalTo(2)); report.displaysTotalSalesFor("Tweed Cape", equalTo(1)); } void sendAndProcess(OrderBuilder orderDetails) { Order order = orderDetails .withDefaultCustomersReference(customersReference++) .build(); requestSender.send(order); progressMonitor.waitForConfirmation(order); progressMonitor.waitForCompletion(order); } private int customersReference = 1;
Finally, a bit of judicious renaming can change the language of the test so that it communicates more about what behaviour is being tested than how the system implements that behaviour.
@Test public void reportsTotalSalesOfOrderedProducts() { havingReceived(anOrder() .withLine("Deerstalker Hat", 1) .withLine("Tweed Cape", 1)); havingReceived(anOrder() .withLine("Deerstalker Hat", 1)); TotalSalesReport report = gui.openSalesReport(); report.displaysTotalSalesFor("Deerstalker Hat", equalTo(2)); report.displaysTotalSalesFor("Tweed Cape", equalTo(1)); } @Test public void takesAmendmentsIntoAccountWhenCalculatingTotalSales() { Customer theCustomer = aCustomer().build(); havingReceived(anOrder().from(theCustomer) .withCustomerReference(10) .withLine("Deerstalker Hat", 1) .withLine("Tweed Cape", 1)); havingReceived(anOrderAmendment().from(theCustomer) .withCustomerReference(10) .withLine("Deerstalker Hat", 2)); TotalSalesReport report = gui.openSalesReport(); report.displaysTotalSalesFor("Deerstalker Hat", equalTo(2)); report.displaysTotalSalesFor("Tweed Cape", equalTo(1)); }
Test Data Builders are a foundation upon which we can define higher-level testing APIs that better communicates the intent of our tests in a language that is closer to that used by non-technical project stakeholders and so greatly help communication within the project.
Update: Thanks to David Peterson and Michael Hunger for helpful feedback. I've fixed typos in the test code and improved the test names. Hopefully the code is easier to follow now.