In unit and integration tests, the construction of test data often becomes bloated and duplicated across projects. This makes it harder to clearly express what a test is verifying and increases the maintenance burden when changes occur over time. The idea behind the Test Data Builder pattern is to introduce a common utility for constructing test data that includes only the data relevant to the specific test. The goal is to make tests as readable as possible while keeping domain objects easy to extend and change.

This is especially critical when working with a rich domain model that enforces its own invariants. In contrast, with an anemic domain model, it’s often easier to create invalid or incomplete data, which can mask potential issues.

Test Data Builder

The first step in any test is constructing the data required for the specific scenario. Consider the example of a User entity, where we want to test the ability to register a phone number. The phone number must match the country the User is registered in — it only allows local phone numbers.

To construct a User, the following information is required:

  • Name
  • Country
  • Email
  • Birth date
  • Authentication type
  • Language

A simple test for registering a phone number might look like this:

@Test
void testUsNumberToUSUser() {
    final var user = User.of(
        "Jane Doe",
        Country.US,
        "email@example.com",
        LocalDate.of(1990, 1, 1),
        BasicAuth.of("username", "password"),
        Language.ENGLISH
    );

    final var result = user.registerNumber(PhoneLocality.US, "516 987 6543");

    assertTrue(result.isSuccess());
}

@Test
void testFailedUSNumberToUKUser() {
    final var user = User.of(
        "Jane Doe",
        Country.UK, // Specifically set to UK
        "jane@doe.com",
        LocalDate.of(1990, 1, 1),
        BasicAuth.of("username", "password"),
        Language.ENGLISH
    );

    final var result = user.registerNumber(PhoneLocality.US, "516 987 6543");

    assertTrue(result.isFailed());
    assertEquals(
        Failure.of(Error.PHONE_CODE_DID_NOT_MATCH_COUNTRY, Country.UK, PhoneLocality.US),
        result.getFailure()
    );
}

This setup contains a lot of redundant information. In larger projects, this boilerplate tends to get copied across tests, making them harder to maintain. This is where the Test Data Builder pattern becomes helpful.

Here’s a simple example of a UserTestDataBuilder:

public class UserTestDataBuilder {
    private String name = "Jane Doe";
    private Country country = Country.UK;
    private String email = "jane@doe.com";
    private LocalDate birthDate = LocalDate.of(1990, 1, 1);
    private AuthMethod auth = BasicAuth.of("username", "password");
    private Language language = Language.ENGLISH;

    public static UserTestDataBuilder builder() {
        return new UserTestDataBuilder();
    }

    public UserTestDataBuilder withName(String name) {
        this.name = name;
        return this;
    }

    public UserTestDataBuilder withCountry(Country country) {
        this.country = country;
        return this;
    }

    public UserTestDataBuilder withEmail(String email) {
        this.email = email;
        return this;
    }

    // Add more "withX" methods as needed

    public User build() {
        return User.of(name, country, email, birthDate, auth, language);
    }
}

Using the builder, the test can now be simplified:

@Test
void testUsNumberToUSUser() {
    final var user = UserTestDataBuilder.builder()
        .withCountry(Country.US)
        .build();

    final var result = user.registerNumber(PhoneLocality.US, "516 987 6543");

    assertTrue(result.isSuccess());
}

@Test
void testFailedUSNumberToUKUser() {
    final var user = UserTestDataBuilder.builder()
        .withCountry(Country.UK)
        .build();

    final var result = user.registerNumber(PhoneLocality.US, "516 987 6543");

    assertTrue(result.isFailed());
    assertEquals(
        Failure.of(Error.PHONE_CODE_DID_NOT_MATCH_COUNTRY, Country.UK, PhoneLocality.US),
        result.getFailure()
    );
}

Notice how we’ve removed all irrelevant details. The only input that matters for the test—the country—is explicitly specified. This increases readability and makes the test’s intent clear.

Additionally, if new fields are ever added to the User.of method, you won’t need to update every test, since the builder already provides sensible defaults.

Pitfall: Relying on Default Values

A common issue when using test data builders is relying too much on default values. This can cause unintended coupling between tests and make them brittle. If the builder's default changes, many unrelated tests may break.

For example, the following test may seem fine:

@Test
void testUsNumberToUSUser() {
    final var user = UserTestDataBuilder.builder()
        .build(); // Uses default country

    final var result = user.registerNumber(PhoneLocality.US, "516 987 6543");

    assertTrue(result.isSuccess());
}

However, this test implicitly depends on the default country being US. If the default is changed to UK, this test will start failing. Worse, the failure will be hard to trace because the test doesn't explicitly declare the assumption it's relying on.

Every test should specify exactly what it needs, even if it matches the default, to make the test's intent self-contained and future-proof.

Conclusion

When constructing complex domain objects in tests, using a Test Data Builder improves readability, maintainability, and extensibility. By encapsulating construction logic, you ensure that tests focus only on what matters for the behavior being tested.

Here are the golden rules for using Test Data Builders effectively:

  • All test data should be constructed using builders.
  • Each test should explicitly specify only what is relevant for the scenario.
  • Builders should provide defaults, but no test should rely on them implicitly.

Done right, Test Data Builders become a cornerstone of a clean, robust, and scalable test suite.

Related reading

The following posts include more practical examples of how to use Test Data Builders: