Tricks with Test Data Builders: Defining Common State

A builder with a plan

Using separate Test Data Builders to construct objects with common state leads to duplication and can make the test code harder to read and maintain. For example:

Invoice invoiceWith10PercentDiscount = new InvoiceBuilder()
    .withLine("Deerstalker Hat", new PoundsShillingsPence(0, 3, 10))
    .withLine("Tweed Cape", new PoundsShillingsPence(0, 4, 12))
    .withDiscount(0.10)
    .build();

Invoice invoiceWith25PercentDiscount = new InvoiceBuilder()
    .withLine("Deerstalker Hat", new PoundsShillingsPence(0, 3, 10))
    .withLine("Tweed Cape", new PoundsShillingsPence(0, 4, 12))
    .withDiscount(0.25)
    .build();

Instead, you can initialise a single builder with the common state and then repeatedly call its build method after defining values that apply only to the built objects:

InvoiceBuilder products = new InvoiceBuilder()
    .withLine("Deerstalker Hat", new PoundsShillingsPence(0, 3, 10))
    .withLine("Tweed Cape", new PoundsShillingsPence(0, 4, 12));

Invoice invoiceWith10PercentDiscount = products
    .withDiscount(0.10)
    .build();

Invoice invoiceWith25PercentDiscount = products
    .withDiscount(0.25)
    .build();

This can make tests much easier to read because there is less code and you can give the builder a descriptive name.

However, you have to be careful if the built objects need different fields to be initialised. Because the withXXX methods change the state of the shared builder, objects built later will be created with the same state as those created earlier unless it is explicitly overridden. For example, in the following code, the second invoice has both a discount and a gift voucher, which is not what the code appears to communicate at first glance.

InvoiceBuilder products = new InvoiceBuilder()
    .withLine("Deerstalker Hat", new PoundsShillingsPence(0, 3, 10))
    .withLine("Tweed Cape", new PoundsShillingsPence(0, 4, 12));

Invoice invoiceWithDiscount = products
    .withDiscount(0.10)
    .build();

Invoice invoiceWithGiftVoucher = products
    .withGiftVoucher("12345")
    .build();

A solution is to add a method or copy constructor to the builder that copies state from another builder:

InvoiceBuilder products = new InvoiceBuilder()
    .withLine("Deerstalker Hat", new PoundsShillingsPence(0, 3, 10))
    .withLine("Tweed Cape", new PoundsShillingsPence(0, 4, 12));

Invoice invoiceWithDiscount = new InvoiceBuilder(products)
    .withDiscount(0.10)
    .build();

Invoice invoiceWithGiftVoucher = new InvoiceBuilder(products)
    .withGiftVoucher("12345")
    .build();

Alternatively, you could add a factory method to the builder that returns a new builder with a copy of the builder's state:

InvoiceBuilder products = new InvoiceBuilder()
    .withLine("Deerstalker Hat", new PoundsShillingsPence(0, 3, 10))
    .withLine("Tweed Cape", new PoundsShillingsPence(0, 4, 12));

Invoice invoiceWithDiscount = products.but().withDiscount(0.10)
    .build();

Invoice invoiceWithGiftVoucher = products.but().withGiftVoucher("12345")
    .build();

The safest option is to make every with method create an entirely new copy of the builder instead of returning this.

Copyright © 2007 Nat Pryce. Posted 2007-12-12. Share it.