Convenient test data builders in Java

Ürgo Ringo
Javarevisited
Published in
3 min readDec 28, 2023

--

Photo by Calvin Hanson on Unsplash

Test data builders are convenient for creating objects used in tests. This is different from using the Builder pattern in production code where it is dangerous and often used to hide away design problems.

The main difference between production builders and test data builders is that for the latter it is very beneficial to have default values. That way I only need to specify these values that are significant for the behavior being tested and can use defaults for everything else.

In languages that support named parameters and default values like Kotlin or Groovy, I don’t need to use the Builder pattern — a factory method with default values will do just fine.

For example, in Kotlin a test data factory for an Order might look like this:

fun anOrder(
lines = listOf(anOrderLine()),
customerId = Id(),
createdAt = Instant.now()
) = Order(
lines = lines,
customerId = customerId,
createdAt = createdAt)

However, in Java, I need to do a bit more work as it doesn’t have either named parameters or default values (and looks like will never have).

Using Instancio

Fortunately, there are libraries that take some boilerplate away from test data builders. Thanks to my colleague Joosep Andrespuk, I learned about a library for generating test data called Instancio. The following is all that is needed to create an Order with some random data using Instancio (static imports skipped):

var order = create(Order.class);

To specify a concrete value or constraints for some fields Insancio has a very nice fluent API:

var order = of(Order.class)
.set(field(Order::lines), ofList(OrderLine.class).size(3).create())
.set(field(Order::createdAt), Instant.now())
.set(field(Order::customerId), new Id(1L))

Note that Order::customerId is a reference to the “getter” method. If Order were a class and not a record type then it would be Order::getCustomerId.

Despite this convenient API, it’s still a bit verbose for my taste to use directly from tests. Therefore, I prefer creating a thin wrapper around Instancio like this:

public class OrderTestBuilder {
private final InstancioApi<Order> builder = of(Order.class);

public static OrderTestBuilder anOrder() {
// set defaults here so that can reuse builder methods
// instead of accessing InstancioApi directly
return new OrderTestBuilder().delivery(Delivery.FAST);
}

public OrderTestBuilder customerId(Id<Customer> customerId) {
builder.set(field(Order::customerId), customerId);
return this;
}

public OrderTestBuilder lines(OrderLine... lines) {
builder.set(field(Order::lines), asList(lines));
return this;
}

public OrderTestBuilder delivery(Delivery delivery) {
builder.set(field(Order::delivery), delivery);
return this;
}

public Order build() {
return builder.create();
}
}

Production Builder wrapper

If for whatever reason I already have Builder in production code then I can take advantage of it and create a test data factory method that sets reasonable defaults.

In the following example, OrderBuilder is a Builder in production code:

public class TestOrder {

public static OrderBuilder anOrder() {
return Order.builder()
.delivery(Delivery.FAST)
.customerId(new Id(123))
.createdAt(Instant.now());
}

}

With this setup, the test code doesn’t know or care what happens behind the scenes. It can construct an Order with defaults and some specific values like this:

var order = anOrder().delivery(Delivery.NORMAL).build();

--

--

Ürgo Ringo
Javarevisited

In software engineering for 20+ years. Worked as IC/tech lead at Wise. Currently tech lead at Inbank. Interests: product engineering and org culture.