Open the test suite of an order system after two years of development and you usually see the same scene: every test is thirty lines long, the first twenty only build a Customer, an Address and some OrderLines and assemble them into an Order, and exactly one line holds what the test actually wants to check. When the Order constructor gains one parameter, hundreds of tests stop compiling. This article covers the Test Data Builder, the approach Nat Pryce and Steve Freeman proposed for building test data that is short and durable, and sets it next to the Object Mother and the five kinds of test double (dummy, stub, spy, mock, fake). All code runs on JDK 21 with JUnit 5.10.5, Mockito 5.11.0 and AssertJ 3.25.3; the output in this article is pasted verbatim from that run.
This article follows Anti-patterns in Java: God object, anemic model, service locator and Virtual threads in Java 21: when to replace a thread pool. If you are not yet comfortable creating objects with a Builder, review Builder pattern: from Effective Java to Lombok and records, because the Test Data Builder is a variant of it with a very different purpose.
The problem: building test data with constructors
Suppose OrderService.checkout validates the order, deducts stock, applies a discount (VIP gets 5%, the GIAM10 code gives 10%, and the higher one wins rather than stacking) and then calls the payment gateway. Order is a record with seven components. The first test we write usually looks like this (file NaiveOrderServiceTest.java):
Clock clock = Clock.fixed(Instant.parse("2026-07-01T03:00:00Z"), ZoneOffset.UTC);
Customer customer = new Customer("C1", "An Nguyen", "an@example.test",
new Address("12 Main Street", "Hanoi"), true);
Order order = new Order("DH001", customer,
List.of(new OrderLine("SKU-1", 2, new Money(new BigDecimal("100000")))),
null, Instant.parse("2026-07-01T02:55:00Z"), OrderStatus.NEW, null);
OrderService service = new OrderService(sku -> 100, (id, amount) -> new PaymentResult(true, "R"),
new FakeOrderRepository(), (email, msg) -> { }, clock);
assertThat(service.applyDiscount(order)).isEqualTo(Money.vnd(190000));This test has three problems, and they get heavier over time:
- Noise: the reader has to go through the address, the creation date and the delivery note to find the one thing that matters, which is
vip = true. - Brittleness: adding a
shippingNotefield toOrderforces a fix to everynew Order(...)call in the whole test suite. - Repetition: twenty tests for twenty variants of an order means twenty copies of this setup, each slightly different from the others.
Two design patterns appeared to solve this: the Object Mother (around the early 2000s) and the Test Data Builder (Nat Pryce, 2007). The second was popularized in the book Growing Object-Oriented Software, Guided by Tests by Freeman and Pryce.
Object Mother: a ready-made data factory
According to Martin Fowler's description, an Object Mother is a class in the test code that creates sample objects (a standard employee, a standard customer) so tests do not build them from scratch; the name came from a ThoughtWorks project in the early 2000s. The simplest implementation is a class full of static methods, one method per scenario:
/** Object Mother: one static method per scenario. */
final class OrderMother {
private OrderMother() {}
static final Instant NOW = Instant.parse("2026-07-01T03:00:00Z");
static Customer regularCustomer() {
return new Customer("C1", "An Nguyen", "an@example.test", new Address("12 Main Street", "Hanoi"), false);
}Tests that use it become very short: OrderMother.vipOrder(). But that brevity hides two weaknesses. First, the number of methods explodes combinatorially: we already have vipOrder, orderWithCoupon and vipOrderWithCoupon, and every new field (an overdue order, an order with no lines, an order from a customer in Hanoi) multiplies the number of methods. Second, the test no longer says which data it depends on: does vipOrder() have two lines or one, and at what price? To find out you have to open the Mother. Fowler raises the same issue: many tests become tightly coupled to the Mother's specific data, so changing the standard data is risky, and when the domain class changes the tests have to move with it.
Nat Pryce put it more concisely in the original post: each time slightly different data is needed, a programmer adds another method to the Mother, and the result is a class that is either full of duplicated code or full of small methods. He proposed using a Builder instead.
Test Data Builder: valid defaults, override only what matters
The idea of the Test Data Builder fits in one sentence: a Test Data Builder holds a ready-made valid default value for every field, and each test calls only the with... methods for the exact fields its scenario cares about. The QWAN analysis of test data builders stresses the same point: a full constructor buries the important values, while a Test Data Builder lets the test focus on the concept being checked. Here is the Test Data Builder for Customer:
static CustomerBuilder aCustomer() {
return new CustomerBuilder("C1", "An Nguyen", "an@example.test", new Address("12 Main Street", "Hanoi"), false);
}
CustomerBuilder withId(String v) { return new CustomerBuilder(v, name, email, address, vip); }
CustomerBuilder withName(String v) { return new CustomerBuilder(id, v, email, address, vip); }
CustomerBuilder withEmail(String v) { return new CustomerBuilder(id, name, v, address, vip); }
CustomerBuilder inCity(String city) { return new CustomerBuilder(id, name, email, new Address(address.street(), city), vip); }
CustomerBuilder vip() { return new CustomerBuilder(id, name, email, address, true); }Three design decisions deserve a mention:
- Static factory methods with "a/an" names (
aCustomer(),anOrder()). With static imports, the test reads like English:anOrder().from(aCustomer().vip()). - Methods named after business meaning:
vip()andinCity("Da Nang")are clearer thanwithVip(true), because the test scenario talks about concepts, not fields. - Immutability: every method returns a new Test Data Builder. The reason is in the common mistakes section below.
The Test Data Builder for Order does two more things: it accepts both a Customer and a CustomerBuilder (so a test can write from(aCustomer().vip()) without calling build()), and it has a withOnlyLine method to replace the default line.
static OrderBuilder anOrder() {
return new OrderBuilder("DH001", CustomerBuilder.aCustomer().build(),
List.of(new OrderLine("SKU-1", 1, Money.vnd(100000))), null, NOW, OrderStatus.NEW);
}
OrderBuilder withId(String v) { return new OrderBuilder(v, customer, lines, couponCode, createdAt, status); }
OrderBuilder from(Customer v) { return new OrderBuilder(id, v, lines, couponCode, createdAt, status); }
OrderBuilder from(CustomerBuilder v) { return from(v.build()); }
OrderBuilder withCoupon(String v) { return new OrderBuilder(id, customer, lines, v, createdAt, status); }
OrderBuilder createdAt(Instant v) { return new OrderBuilder(id, customer, lines, couponCode, v, status); }
OrderBuilder withNoLines() { return new OrderBuilder(id, customer, List.of(), couponCode, createdAt, status); }The scenario "a VIP customer uses the GIAM10 code and buys two items at 100,000 each" now fits in one readable statement:
Order order = anOrder().from(aCustomer().vip()).withCoupon("GIAM10")
.withOnlyLine("SKU-1", 2, 100_000).build();If Order gains a shippingNote field, we fix exactly one place, the build() of the Test Data Builder (in the example repo this field is passed as null there), and no test has to change. That is the core value: the default validity is defined once.
Object Mother and Test Data Builder side by side
For the same scenario, the three styles in ObjectMotherTest.java give the same result, but the information sits in different places:
| Criterion | Object Mother | Test Data Builder |
|---|---|---|
| Length of one test | Very short (one call) | Short (one call chain) |
| Does the test explain its own data | No, you have to open the Mother | Yes, the difference is visible in the test |
| Number of classes/methods vs. number of variants | Grows with combinations | Grows with the number of fields |
| Adding a new field to the domain class | Fix every Mother method | Fix one place in build() |
| Best suited for | A few standard scenarios, few variants | Many variants, objects with many fields |
The Test Data Builder and the Object Mother are not mutually exclusive. A common way to combine them is to have the Object Mother return a preconfigured Test Data Builder instead of the final object, for example OrderMother.vipOrder() returning an OrderBuilder, so tests can still override further details. When the domain class is an immutable record (exactly like Order here), the Test Data Builder has another advantage: you cannot "build and then call setters" as you would with a mutable object.
Test doubles: five roles, not one
The Test Data Builder solves only half of the data story. OrderService still has four collaborators: inventory, the payment gateway, the repository and the mail service. In tests we replace them with test doubles. Martin Fowler, following Gerard Meszaros, distinguishes five kinds:
| Kind | Role | In the example |
|---|---|---|
| Dummy | Passed in to satisfy the signature, never actually used | (e, m) -> { } in ObjectMotherTest |
| Stub | Returns a prepared answer | sku -> 100 for InventoryClient |
| Spy | A stub that records how it was called | SpyNotifier records the list of mails sent |
| Mock | Preloaded with expectations about calls, verified at the end | PaymentGateway created by Mockito |
| Fake | A working implementation that takes a shortcut | FakeOrderRepository backed by a HashMap |
The handwritten fake and spy in the example are so short they need no library; here is the fake (the SpyNotifier spy is just a List that records calls to send, see code/s6-34):
class FakeOrderRepository implements OrderRepository {
private final Map<String, Order> store = new HashMap<>();
@Override public void save(Order order) { store.put(order.id(), order); }
@Override public Optional<Order> findById(String id) { return Optional.ofNullable(store.get(id)); }One point worth noting: the repository fake lets a test ask "was the order saved with status PAID?", which checks the final state; the payment gateway mock lets it ask "was charge called exactly once with 950,000?", which checks behavior. Fowler calls these two different styles in Mocks Aren't Stubs: the classical school uses real objects when possible and verifies state, while the mockist school uses mocks for every collaborator with behavior and verifies interactions. Mockist tests are compact and localize failures better, but they are tied to the implementation and so break easily during refactoring; classical tests survive refactoring better but need more data to be built, which is exactly where the Test Data Builder shines.
Combining the Test Data Builder with test doubles in JUnit 5
The complete test suite uses JUnit 5 (@Nested to group by behavior), Mockito through MockitoExtension and AssertJ for assertions. Notice how each test states only what is different:
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
private final Clock clock = Clock.fixed(OrderBuilder.NOW.plusSeconds(5 * 60), ZoneOffset.UTC);
private final FakeOrderRepository repository = new FakeOrderRepository(); // fake
private final SpyNotifier notifier = new SpyNotifier(); // spy
private InventoryClient inventory = sku -> 100; // stub
@Mock PaymentGateway payment; // mock
private OrderService service() {
return new OrderService(inventory, payment, repository, notifier, clock);
} @Test
void thanhToanThanhCong_luuDonVaGuiMail() {
when(payment.charge(eq("DH001"), any())).thenReturn(new PaymentResult(true, "PAY-1"));
Receipt receipt = service().checkout(anOrder().withOnlyLine("SKU-1", 2, 100_000).build());
assertThat(receipt.status()).isEqualTo(OrderStatus.PAID);
assertThat(receipt.paid()).isEqualTo(Money.vnd(200_000));
assertThat(repository.findById("DH001")).get().extracting(Order::status).isEqualTo(OrderStatus.PAID);
assertThat(notifier.sent).containsExactly("an@example.test <- Order DH001 paid 200000 VND");
} @Test
void chargeDuocGoiDungMotLanVoiSoTienSauGiamGia() {
when(payment.charge(any(), any())).thenReturn(new PaymentResult(true, "PAY-2"));
service().checkout(anOrder().from(aCustomer().vip()).withOnlyLine("SKU-1", 1, 1_000_000).build());
verify(payment, times(1)).charge("DH001", Money.vnd(950_000)); // behavior verification
verifyNoMoreInteractions(payment);
}A few decisions in the snippets above deserve an explanation:
- State first, behavior second. The payment result, the order status stored in the fake and the mail in the spy are all state. Only "
chargewas called with the post-discount amount" usesverify, because that is a contract with an external system and there is no state to query. - Do not scatter
verifyNoMoreInteractions. The Mockito documentation warns that overusing it leads to tests that are too tight and hard to maintain; here it appears in only one test, to assert that the payment gateway is not called a second time. - Use real values instead of
any()when you can.verify(payment).charge("DH001", Money.vnd(950_000))catches a discount calculation bug, whereasany()would leave the test green even with a wrong amount. - The clock is a collaborator. Injecting a fixed
Clockmakes the "older than 30 minutes" test deterministic and independent of the machine's time.
Running everything with mvn -o test on an Apple M4, 16 GB RAM, JDK 21.0.12 gives the result below (the Running lines and timings are trimmed; the [immutable] and [mutable] lines are printed by the tests that illustrate the mistake in the section further down):
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 -- in demo.shop.OrderServiceTest$Checkout
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in demo.shop.OrderServiceTest$TinhGiam_gia
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in demo.shop.ObjectMotherTest
[immutable] line count a, b, c = 2, 2, 1
[mutable] line count after 2 shared uses = 3
Tests run: 16, Failures: 0, Errors: 0, Skipped: 1
BUILD SUCCESSThe actual versions are pinned by spring-boot-starter-parent 3.3.5, used only to manage dependency versions: JUnit Jupiter 5.10.5, Mockito 5.11.0, AssertJ 3.25.3 (the latest JUnit at the time of writing has moved to the 6 line, but the syntax used in this article does not change between the two lines). The line Tests run: 16 ... Skipped: 1 is the strict stubs demo test, which is enabled only when you pass -Ddemo=strict.
Strict stubs: let Mockito catch unused stubs
When you use @ExtendWith(MockitoExtension.class), Mockito runs in strict stubs mode (the Mockito documentation describes this mode as opt-in via MockitoExtension, MockitoJUnitRunner.Strict or MockitoRule): a stub that is declared but not used fails the test. This catches the "falsely green" kind of test, where we prepare a scenario for a branch of code and that branch never runs. To see it first-hand, the following test deliberately stubs too much:
@Test
void stubThua() {
when(payment.charge("DH001", Money.vnd(1))).thenReturn(new PaymentResult(true, "X"));
// the test never calls payment.charge
}Run with mvn -o test -Dtest=StrictStubsDemoTest -Ddemo=strict and Mockito reports:
org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
1. -> at demo.shop.StrictStubsDemoTest.stubThua(StrictStubsDemoTest.java:20)
Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class.When you need an unused stub on purpose (for example in a shared @BeforeEach), mark it with lenient(). The Mockito documentation also notes that strict mode can hide some other mistakes, so do not treat it as your only safety net.
When not to use it, and the trade-offs
- Objects that are too simple: a two-field
Moneydoes not need a Test Data Builder;Money.vnd(100_000)is clear enough. - A Test Data Builder is code you must maintain: QWAN admits this is an extra cost, but a small one compared with the benefit. The real cost is large if you introduce it late, because then hundreds of existing tests have to be refactored; so add it as soon as an object starts having more than three or four fields.
- Keep defaults in test code only: putting defaults in the production class makes it easy to use values that have no business meaning by mistake.
- Do not test the Test Data Builder directly: it is checked indirectly by the tests that use it; if
build()is wrong, many tests turn red at once. - Over-mocking: mocking everything makes tests mirror the implementation. For collaborators that are pure data or already have a simple fake, prefer real objects.
Common mistakes
A mutable Test Data Builder shared between tests
The most frequent mistake: declaring a sample Test Data Builder as static final to "avoid recreating it", with each with... method modifying the Test Data Builder itself and returning this. Test A adds one order line, test B adds another, and test C receives an order with three lines instead of one, going red or green depending on execution order. The test MutableBuilderPitfallTest.java reproduces exactly this:
@Test
void builderKhaBienBiNhiemBanKhiDungChung() {
SHARED_MUTABLE.withLine("SKU-2", 1, 50000); // "test A" adds a line
SHARED_MUTABLE.withLine("SKU-3", 1, 70000); // "test B" adds a line
System.out.println("[mutable] line count after 2 shared uses = " + SHARED_MUTABLE.lineCount());
assertThat(SHARED_MUTABLE.lineCount()).isEqualTo(3); // test C expects 1 line but gets 3
}
@Test
void builderBatBienKhongBiNhiem() {
Order a = SHARED_IMMUTABLE.withLine("SKU-2", 1, 50000).build();
Order b = SHARED_IMMUTABLE.withLine("SKU-3", 1, 70000).build();
Order c = SHARED_IMMUTABLE.build();
System.out.println("[immutable] line count a, b, c = " + a.lines().size() + ", " + b.lines().size() + ", " + c.lines().size());
assertThat(c.lines()).hasSize(1);
}The real output of the two tests is in the run block above: the mutable Test Data Builder has 3 lines after two shared uses, while the immutable one gives 2, 2, 1 lines for orders a, b and c, meaning each test sees exactly what it built and the default order c is not contaminated. That is why the Test Data Builder in this article returns a copy from every method, and why you should also use List.copyOf in the constructor.
Invalid defaults
If anOrder() returns an order with no lines, every test has to remember to add a line and we are back to the old situation. The rule: anX().build() always gives a valid, "most ordinary" object; special cases (withNoLines()) must be called out by name.
Checklist
- Objects with more than three or four fields, or a nested tree: create a Test Data Builder with valid defaults and name the methods after business meaning.
- Keep the Test Data Builder immutable, with every
with...returning a new instance; never keep a shared Test Data Builder that can be modified. - Keep only a few standard scenarios in the Object Mother, ideally returning a Test Data Builder.
- Pick the right double: a stub for incoming data, a fake for anything with state, a spy or mock for outgoing interactions.
- Verifying state is the default; use
verifyfor contracts with external systems and do not scatterverifyNoMoreInteractions. - Enable strict stubs (
MockitoExtension) to catch unused stubs. - Inject a
Clock; do not usenow()in test data. - Keep a group of tests against a real database alongside the fake.
In short, the Test Data Builder does not make tests shorter at any cost; it makes each test state what is different and keeps "what a valid object looks like" in a single place. Combined with choosing the right kind of test double, the test suite survives many rounds of refactoring without turning into a burden.
Next article
Once your tests are reliable enough, you can reshape legacy code without fear of breaking it. Continue with Refactoring to design patterns: a case study from real code.



