Tricks with Test Data Builders: Defining Common State
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
.