DEV Community

Cover image for 🧱 Your Test Fixtures Are Lying About What Matters
Kyryl
Kyryl

Posted on

🧱 Your Test Fixtures Are Lying About What Matters

Open a test file with a thirty-argument constructor call and try to spot the one value the test actually cares about. You cannot do it at a glance. Every field looks equally important, because the constructor treats them that way.

Here is the domain object in question, a fairly ordinary User:

public class User {
    private final UUID id;
    private final String email;
    private final String firstName;
    private final String lastName;
    private final String phone;
    private final Address address;
    private final Role role;
    private final AccountStatus status;
    private final boolean emailVerified;
    private final boolean twoFactorEnabled;
    private final String sessionToken;
    private final Instant tokenIssuedAt;
    private final Instant createdAt;
    private final Instant lastLoginAt;
    // ... 16 more fields exactly like this

    public User(UUID id, String email, String firstName, String lastName,
                String phone, Address address, Role role, AccountStatus status,
                boolean emailVerified, boolean twoFactorEnabled, String sessionToken,
                Instant tokenIssuedAt, Instant createdAt, Instant lastLoginAt
                /* ...16 more parameters */) {
        // assign everything
    }
}
Enter fullscreen mode Exit fullscreen mode

Now here is a test that exercises exactly one behavior: a session with an expired token gets rejected.

@Test
void expiredTokenIsRejected() {
    User user = new User(
        UUID.randomUUID(),
        "jane.doe@example.com",
        "Jane",
        "Doe",
        "+1-555-0100",
        Address.of("221B Baker St", "London", "NW1", "UK"),
        Role.CUSTOMER,
        AccountStatus.ACTIVE,
        true,
        false,
        "expired-token-abc123",           // <- the one value this test cares about
        Instant.now().minus(Duration.ofDays(30)),
        Instant.now(),
        null
        // ... 16 more arguments exactly like this
    );

    assertThat(sessionService.isValid(user)).isFalse();
}
Enter fullscreen mode Exit fullscreen mode

The fact under test, a token issued thirty days ago, sits at position eleven out of thirty. A reviewer has to read the whole argument list to find it, and count commas to be sure they found the right one. Six months from now, when User grows a preferredLanguage field, every one of these constructor calls either breaks or silently gets a null nobody reviewed.

Why this happens

Nobody designs a test this way on purpose. It happens because the constructor is the only tool available, and the constructor's job is to build a fully valid object, not to communicate what a specific test is checking. The constructor is honest about the shape of User. It says nothing about which of those thirty values is the point.

That is the actual problem, and it is not verbosity. It is signal-to-noise. A fixture constructor forces every test to restate the entire object graph, every time, whether that test cares about eviction dates, marketing consent, or nothing but a single boolean.

The pattern

A test data builder inverts the default. It ships with sensible, valid values for everything, and exposes named methods for the one or two things a given test needs to override.

public class UserTestDataBuilder {
    private UUID id = UUID.randomUUID();
    private String email = "jane.doe@example.com";
    private String firstName = "Jane";
    private String lastName = "Doe";
    private Role role = Role.CUSTOMER;
    private AccountStatus status = AccountStatus.ACTIVE;
    private String sessionToken = "valid-token";
    private Instant tokenIssuedAt = Instant.now();
    // ... every other field, defaulted to something valid

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

    public UserTestDataBuilder withExpiredToken() {
        this.sessionToken = "expired-token-abc123";
        this.tokenIssuedAt = Instant.now().minus(Duration.ofDays(30));
        return this;
    }

    public User build() {
        return new User(id, email, firstName, lastName, /* ... */ sessionToken, tokenIssuedAt, /* ... */);
    }
}
Enter fullscreen mode Exit fullscreen mode

And the test collapses to this:

@Test
void expiredTokenIsRejected() {
    User user = aUser().withExpiredToken().build();

    assertThat(sessionService.isValid(user)).isFalse();
}
Enter fullscreen mode Exit fullscreen mode

Everything except the token is defaulted. The reader does not scan fourteen positional arguments looking for the relevant one, they read withExpiredToken() and move on. When User gains a preferredLanguage field next sprint, it gets a default inside the builder once, and every existing test keeps compiling, untouched.

The honest trade-off

This is not free. Someone has to write the builder class, and someone has to keep its defaults sensible as the domain object evolves. That is a second place User lives, and it can drift out of sync with real constraints if nobody updates it when validation rules change.

There is a subtler cost too. A "just valid enough" default can silently satisfy a constraint the test author never thought about. If AccountStatus.ACTIVE is the builder's default and a bug only reproduces for AccountStatus.PENDING_VERIFICATION, the builder hides that gap exactly as effectively as a thirty-argument constructor call hides the one field that matters. Defaults reduce noise, they do not replace judgment about what a test should actually cover.

For a domain object with three or four fields, none of this is worth it, a plain constructor is fine. It starts paying for itself once an object crosses somewhere around ten to fifteen fields, or once it shows up in more than a handful of tests.

What you actually gain

Reviewers read intent, not positions. New fields do not break unrelated tests. And the one thing under test is loud, instead of buried in a wall of arguments a reviewer has learned to skim past, which is its own kind of risk: skimmed code is where the real bug in position eleven goes unnoticed.

What do you reach for once your domain objects outgrow a plain constructor call: an Object Mother, a builder like the one above, or a random test-data library like Instancio or EasyRandom? Curious what actually holds up at scale versus what looks good in a blog post.

Top comments (0)