Test Structure, Fixtures, and Object Mothers
Learn Java Formal Methods, Testing, Benchmarking, and Performance Engineering - Part 006
Struktur test, fixture, object mother, test data builder, custom assertion, dan strategi data uji untuk Java codebase besar agar test readable, maintainable, deterministic, dan tidak brittle.
Part 006 — Test Structure, Fixtures, and Object Mothers
Tujuan bagian ini: membangun test yang mudah dibaca, murah diubah, tidak brittle, dan tetap jujur terhadap domain. Kita akan fokus pada struktur test, fixture, object mother, test data builder, DSL kecil, dan custom assertions.
Banyak test suite gagal bukan karena kurang test. Mereka gagal karena test menjadi “kode legacy kedua”.
Gejalanya:
- setiap perubahan kecil memecahkan puluhan test;
- test sulit dibaca tanpa membuka banyak helper;
- object mother berubah menjadi monster;
- mock setup lebih panjang dari behavior yang diuji;
- fixture terlalu besar;
- assertion terlalu mekanis;
- test data tidak merepresentasikan domain;
- failure message tidak membantu;
- test membuat engineer takut refactor.
Tujuan kita bukan membuat test “cantik”. Tujuan kita membuat test menjadi instrument engineering yang memberi feedback cepat dan jelas.
1. The Core Shape of a Good Test
Struktur minimal yang paling stabil:
Arrange -> Act -> Assert
Atau dalam bahasa behavior:
Given -> When -> Then
Keduanya sama-sama berguna. Pilih yang paling jelas untuk tim.
Contoh:
@Test
void closed_case_cannot_be_escalated() {
// Arrange
var caseFile = CaseFileBuilder.aClosedCase().build();
var command = EscalateCommand.requestedBy("supervisor-1");
// Act
var result = workflow.handle(caseFile, command);
// Assert
assertThat(result)
.isRejected()
.hasReasonCode("CLOSED_CASE_CANNOT_BE_ESCALATED");
}
Dalam test pendek, komentar Arrange/Act/Assert tidak selalu perlu. Tetapi struktur mentalnya harus ada.
Anti-pattern:
@Test
void closed_case_cannot_be_escalated() {
var service = newService();
assertThrows(Exception.class, () -> {
var id = createCase();
close(id);
service.escalate(id);
assertEquals("CLOSED", service.find(id).status());
});
}
Masalah:
- act dan assert tercampur;
- setup menyembunyikan state;
- exception terlalu generik;
- assertion setelah exception mungkin tidak pernah jalan;
- behavior utama tidak terbaca.
Good test has one dominant reason to fail.
2. Test as a Small Story, Not a Script Dump
Test yang baik adalah cerita pendek:
Given a closed case,
when escalation is requested,
then the workflow rejects it with a stable reason code,
and no audit/event side effect is produced.
Kode:
@Test
void closed_case_cannot_be_escalated_and_produces_no_side_effect() {
var caseFile = givenClosedCase();
var events = new RecordingEventPublisher();
var workflow = workflowWith(events);
var result = workflow.handle(caseFile, EscalateCommand.requestedBy("alice"));
assertThat(result)
.isRejected()
.hasReasonCode("CLOSED_CASE_CANNOT_BE_ESCALATED");
assertThat(events.published()).isEmpty();
}
Notice the important part:
absence of side effects is behavior.
For enterprise systems, many severe bugs are not wrong return values. They are unwanted emails, duplicate audit records, duplicate messages, wrong state transition, inconsistent database writes, or irreversible external calls.
3. Fixture: The World Required by the Test
Fixture is everything needed to execute a test:
- domain objects;
- dependencies;
- fake repositories;
- clocks;
- security principal;
- configuration;
- database rows;
- external service responses;
- queues/messages;
- files;
- environment assumptions.
Good fixture is:
- minimal;
- explicit;
- deterministic;
- close to the test;
- named in domain language;
- easy to vary;
- hard to accidentally share.
Bad fixture is:
- global;
- huge;
- mutable;
- implicit;
- shared across tests;
- loaded from unclear file;
- full of irrelevant defaults;
- impossible to reason about.
The fixture rule:
The reader should understand the relevant initial state without executing the helper mentally.
4. The Minimal Valid Object Principle
When creating domain objects for tests, prefer the smallest valid object that satisfies invariant.
Bad:
var caseFile = new CaseFile(
"CASE-123",
"REG-9981",
CaseStatus.OPEN,
RiskLevel.HIGH,
List.of(evidence1, evidence2, evidence3),
List.of(actor1, actor2, actor3),
List.of(comment1, comment2),
Map.of("channel", "email", "priority", "urgent"),
Instant.now(),
Instant.now(),
"created-by",
"assigned-to",
true,
false,
null
);
The test probably does not care about most of those fields.
Better:
var caseFile = CaseFileBuilder.anOpenCase()
.withRiskLevel(RiskLevel.HIGH)
.withoutRequiredEvidence()
.build();
The builder owns domain defaults. The test owns the variation.
Principle:
Defaults belong in builders. Differences belong in tests.
5. Object Mother: Useful, Then Dangerous
Object Mother is a test helper that creates common objects.
Example:
public final class CaseFileMother {
private CaseFileMother() {}
public static CaseFile openCase() {
return CaseFileBuilder.anOpenCase().build();
}
public static CaseFile closedCase() {
return CaseFileBuilder.aClosedCase().build();
}
public static CaseFile highRiskCaseWithMissingEvidence() {
return CaseFileBuilder.anOpenCase()
.withRiskLevel(RiskLevel.HIGH)
.withoutRequiredEvidence()
.build();
}
}
Good object mother:
- creates named, meaningful examples;
- delegates variation to builders;
- remains small;
- returns immutable objects;
- does not hide important test-specific choices.
Bad object mother:
CaseFileMother.caseForScenario17WithSupervisorOverrideAndMissingEvidenceAndLegacyFlag()
or:
CaseFileMother.createDefault()
createDefault() is often a smell because default means nothing. Better names:
anOpenCase()
aClosedCase()
aCasePendingSupervisorReview()
aHighRiskCaseMissingEvidence()
Object Mother should provide semantic starting points, not become a second domain model.
6. Test Data Builder: The Workhorse Pattern
A test data builder makes variation cheap.
public final class CaseFileBuilder {
private CaseId id = CaseId.of("CASE-001");
private CaseStatus status = CaseStatus.OPEN;
private RiskLevel riskLevel = RiskLevel.MEDIUM;
private List<Evidence> evidence = List.of(EvidenceMother.validIdentityEvidence());
private Instant createdAt = Instant.parse("2026-07-02T10:00:00Z");
private Actor assignedTo = Actor.of("investigator-1");
private CaseFileBuilder() {}
public static CaseFileBuilder aCase() {
return new CaseFileBuilder();
}
public static CaseFileBuilder anOpenCase() {
return aCase().withStatus(CaseStatus.OPEN);
}
public static CaseFileBuilder aClosedCase() {
return aCase().withStatus(CaseStatus.CLOSED);
}
public CaseFileBuilder withStatus(CaseStatus status) {
this.status = status;
return this;
}
public CaseFileBuilder withRiskLevel(RiskLevel riskLevel) {
this.riskLevel = riskLevel;
return this;
}
public CaseFileBuilder withoutRequiredEvidence() {
this.evidence = List.of();
return this;
}
public CaseFileBuilder createdAt(String instant) {
this.createdAt = Instant.parse(instant);
return this;
}
public CaseFile build() {
return new CaseFile(id, status, riskLevel, evidence, createdAt, assignedTo);
}
}
Usage:
var caseFile = CaseFileBuilder.anOpenCase()
.withRiskLevel(RiskLevel.HIGH)
.withoutRequiredEvidence()
.createdAt("2026-07-01T09:00:00Z")
.build();
Good builder API reads like domain setup.
Bad builder API mirrors constructor mechanically:
.withField1(...)
.withField2(...)
.withField3(...)
Mechanical builders reduce constructor noise but do not improve test meaning.
7. Builder Defaults Must Be Valid
A builder should produce a valid object by default.
CaseFile caseFile = CaseFileBuilder.aCase().build();
This should not create an invalid half-object unless explicitly named:
CaseFileBuilder.anInvalidCase().build();
Why?
Because invalid defaults create subtle tests:
var caseFile = CaseFileBuilder.aCase()
.withStatus(CaseStatus.OPEN)
.build();
If aCase() secretly has no required evidence, the test may fail for wrong reason.
Builder invariant:
A builder's default build() should satisfy domain invariants unless the builder name says otherwise.
8. Builders Should Not Become Production Factories
Test builders live in test source set:
src/test/java/.../support/CaseFileBuilder.java
Do not leak them into production code unless they represent real domain construction semantics.
Production factory:
public final class CaseFactory {
public CaseFile openNewCase(NewCaseRequest request, Clock clock) {
// real creation rules
}
}
Test builder:
CaseFileBuilder.anOpenCase().withoutRequiredEvidence().build();
Different purpose:
| Type | Purpose |
|---|---|
| Production factory | enforce real creation workflow |
| Test builder | create controlled fixture states |
| Object mother | named examples |
| Fixture DSL | scenario setup across boundaries |
Do not confuse them.
9. Copy-and-Modify Builders
For immutable objects, copy builders are useful.
public static CaseFileBuilder from(CaseFile caseFile) {
return new CaseFileBuilder()
.withId(caseFile.id())
.withStatus(caseFile.status())
.withRiskLevel(caseFile.riskLevel())
.withEvidence(caseFile.evidence())
.createdAt(caseFile.createdAt());
}
Usage:
var original = CaseFileBuilder.anOpenCase().build();
var closed = CaseFileBuilder.from(original)
.withStatus(CaseStatus.CLOSED)
.build();
This helps test state transitions:
assertThat(closed.id()).isEqualTo(original.id());
assertThat(closed.status()).isEqualTo(CaseStatus.CLOSED);
But avoid using copy builders to patch random fields until object meaning disappears.
10. Domain-Specific Fixture DSL
For workflow or integration-level tests, a DSL can improve readability.
@Test
void supervisor_can_approve_escalated_case_after_required_evidence_is_submitted() {
var scenario = CaseScenario.start()
.givenCase().isOpen().isHighRisk().hasMissingEvidence()
.whenEvidenceIsSubmitted("identity-proof")
.andCaseIsEscalated()
.andSupervisorApproves()
.thenCase()
.hasStatus(CaseStatus.APPROVED)
.hasAuditTrail("EVIDENCE_SUBMITTED", "CASE_ESCALATED", "SUPERVISOR_APPROVED");
}
This can be powerful, but dangerous.
DSL is good when:
- scenario spans many operations;
- repeated setup is genuinely complex;
- domain language is stable;
- failure messages remain clear;
- DSL is thin over real operations;
- test still shows important differences.
DSL is bad when:
- it hides too much;
- every test reads the same;
- debugging requires stepping through 20 helper methods;
- test authors add magic flags;
- DSL behavior differs from production behavior.
Good DSL is a readable script over explicit operations, not a parallel implementation of the system.
11. Fixture Scope: Local, Class, Package, Module
Not all fixture helpers should have the same visibility.
local helper : only this test class needs it
nested helper : this context needs it
package helper : many tests in same module need it
module test-support: many modules need it, high stability required
Default to local until duplication proves otherwise.
Bad instinct:
Create global TestFixtures module on day one.
Better progression:
duplicate tiny setup -> local helper -> package helper -> module support library
Why?
Shared fixture libraries become high-coupling infrastructure. Every change can break many tests.
Use shared fixtures for stable domain concepts:
- valid identity evidence;
- open/closed case;
- known actor roles;
- stable workflow command builders;
- deterministic clock.
Avoid shared fixtures for scenario-specific arrangements.
12. External Fixture Files: Use Sparingly and Name Well
JSON/XML fixtures are useful for contract, parser, compatibility, and regression tests.
Example:
@Test
void parses_legacy_case_event_v2() {
var json = fixture("events/case-event-v2-legacy-missing-optional-field.json");
var event = objectMapper.readValue(json, CaseEvent.class);
assertThat(event.caseId()).isEqualTo(CaseId.of("CASE-9001"));
assertThat(event.schemaVersion()).isEqualTo(2);
}
Good fixture filename:
events/case-event-v2-legacy-missing-optional-field.json
Bad fixture filename:
test1.json
sample.json
data.json
payload_new_final.json
External files are good when:
- payload is large;
- exact formatting matters;
- compatibility history matters;
- file is a real production-like artifact;
- fixture is used by parser/contract tests.
External files are bad when:
- object could be built clearer in code;
- test reader must inspect file for every assertion;
- file contains lots of irrelevant data;
- tests mutate the file;
- fixture update is done by blind snapshot replacement.
Rule:
Use fixture files for artifacts, not for hiding setup.
13. Snapshot Testing: Useful, But Dangerous in Java Services
Snapshot testing compares output to a stored expected artifact.
Useful for:
- generated reports;
- generated OpenAPI fragments;
- HTML/email templates;
- serialization compatibility;
- complex diagnostics output;
- golden master during legacy refactor.
Dangerous for:
- core domain logic;
- volatile timestamps/ids;
- unreadable large JSON;
- outputs with arbitrary order;
- tests where reviewers approve changed snapshots without understanding semantic impact.
If using snapshots:
- normalize volatile fields;
- sort unordered collections;
- keep files small;
- review snapshot diffs carefully;
- assert important semantic facts separately;
- do not let snapshot be the only oracle for critical behavior.
Example:
var report = reportGenerator.generate(caseFile);
assertThat(report)
.hasCaseId("CASE-123")
.hasTotalViolationCount(3)
.hasRiskLevel(RiskLevel.HIGH);
assertThat(normalize(report.toJson()))
.isEqualTo(fixture("reports/high-risk-case-report.snapshot.json"));
The semantic assertions tell what matters. The snapshot catches accidental structural change.
14. Custom Assertions: Make Tests Speak Domain
Raw assertions can obscure meaning.
assertEquals(CaseStatus.ESCALATION_REVIEW, caseFile.status());
assertTrue(caseFile.auditTrail().stream().anyMatch(e -> e.code().equals("CASE_ESCALATED")));
assertTrue(caseFile.validationErrors().isEmpty());
Custom assertion:
assertThat(caseFile)
.hasStatus(CaseStatus.ESCALATION_REVIEW)
.hasAuditEvent("CASE_ESCALATED")
.hasNoValidationErrors();
Implementation sketch with AssertJ:
public final class CaseFileAssert extends AbstractAssert<CaseFileAssert, CaseFile> {
private CaseFileAssert(CaseFile actual) {
super(actual, CaseFileAssert.class);
}
public static CaseFileAssert assertThat(CaseFile actual) {
return new CaseFileAssert(actual);
}
public CaseFileAssert hasStatus(CaseStatus expected) {
isNotNull();
if (actual.status() != expected) {
failWithMessage("Expected case status to be <%s> but was <%s>", expected, actual.status());
}
return this;
}
public CaseFileAssert hasAuditEvent(String code) {
isNotNull();
var found = actual.auditTrail().stream()
.anyMatch(event -> event.code().equals(code));
if (!found) {
failWithMessage("Expected audit event <%s> but audit trail was <%s>", code, actual.auditTrail());
}
return this;
}
public CaseFileAssert hasNoValidationErrors() {
isNotNull();
if (!actual.validationErrors().isEmpty()) {
failWithMessage("Expected no validation errors but found <%s>", actual.validationErrors());
}
return this;
}
}
Usage:
import static com.acme.testassertions.CaseFileAssert.assertThat;
assertThat(caseFile)
.hasStatus(CaseStatus.ESCALATION_REVIEW)
.hasAuditEvent("CASE_ESCALATED")
.hasNoValidationErrors();
Custom assertion benefits:
- less duplication;
- better failure messages;
- domain language;
- centralized assertion semantics;
- easier refactor of object internals.
Danger:
- assertion class becomes too broad;
- custom assertion hides important details;
- assertion duplicates production logic;
- failure messages become generic.
Custom assertions should check observable facts, not recompute business decisions.
15. Assertion Granularity: State, Event, Interaction
Tests can observe different things:
state : what changed in object/database
return value : what call returned
event : what was emitted
audit : what was recorded
interaction : what dependency was called
absence : what did not happen
Choose observation based on contract.
State assertion
assertThat(caseRepository.find(caseId))
.hasStatus(CaseStatus.CLOSED);
Use when durable state is the behavior.
Event assertion
assertThat(eventPublisher.publishedEvents())
.containsExactly(new CaseClosed(caseId));
Use when emitted event is a contract.
Interaction assertion
verify(notificationGateway).sendSupervisorNotification(caseId);
Use when calling that dependency is itself the behavior.
Do not verify interaction just because it is easy.
Bad:
verify(repository).save(any());
Better:
assertThat(repository.find(caseId)).hasStatus(CaseStatus.ESCALATION_REVIEW);
State tells user-visible truth. Interaction tells implementation detail unless the interaction is the external contract.
16. Fixture Builders for Persistence Tests
Database tests need different fixture strategy.
Bad:
jdbcTemplate.execute("insert into cases values (... many columns ...)");
Better:
caseTable.insert(CaseRowBuilder.anOpenCase()
.withId("CASE-123")
.withRiskLevel("HIGH")
.build());
Example:
public final class CaseTableFixture {
private final JdbcTemplate jdbc;
public CaseTableFixture(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public void insert(CaseRow row) {
jdbc.update("""
insert into cases(case_id, status, risk_level, created_at)
values (?, ?, ?, ?)
""",
row.caseId(),
row.status(),
row.riskLevel(),
Timestamp.from(row.createdAt())
);
}
}
Persistence fixture should know schema details. Test should know domain scenario.
@Test
void repository_maps_closed_case_with_audit_count() {
caseTable.insert(CaseRowBuilder.aClosedCase().withId("CASE-123").build());
auditTable.insert(AuditRowBuilder.forCase("CASE-123").withCode("CASE_CLOSED").build());
var caseFile = repository.findById(CaseId.of("CASE-123"));
assertThat(caseFile)
.hasStatus(CaseStatus.CLOSED)
.hasAuditEvent("CASE_CLOSED");
}
17. Fixture Isolation for Database Tests
Database test isolation options:
| Strategy | Pros | Cons |
|---|---|---|
| Transaction rollback per test | Fast, simple | Does not catch commit behavior, async side effects may escape |
| Truncate tables per test | Clear isolation | Slower, FK ordering complexity |
| Schema/database per test | Strong isolation | Expensive |
| Unique IDs per test | Good for parallelism | Data accumulation, cleanup needed |
| Container per suite | Realistic | Suite-level contamination possible |
For most integration tests:
container per suite + unique IDs per test + cleanup/truncate when necessary
Avoid relying only on rollback when testing:
- transaction boundaries;
- outbox pattern;
- async consumers;
- database locks;
- unique constraints under concurrent commits;
- triggers/listeners;
- connection pool behavior.
18. Deterministic IDs, Time, and Randomness
Fixture data must be deterministic unless the test explicitly checks random behavior.
Bad:
var id = UUID.randomUUID();
var now = Instant.now();
Better:
var id = CaseId.of("CASE-123");
var now = Instant.parse("2026-07-02T10:00:00Z");
For tests needing uniqueness, derive deterministic uniqueness:
static CaseId caseIdFor(TestInfo testInfo) {
var slug = testInfo.getDisplayName()
.toLowerCase(Locale.ROOT)
.replaceAll("[^a-z0-9]+", "-");
return CaseId.of("CASE-" + slug.hashCode());
}
Or use seeded random:
var random = new Random(42);
Rule:
Randomness in tests must be reproducible.
If a randomized test fails, the failure must print seed/input.
19. Test Fixtures for Time-Based Rules
Time rules are common in enterprise systems:
- SLA deadline;
- escalation after N hours;
- monthly reporting window;
- retention period;
- business day calendar;
- cutoff time;
- grace period.
Bad:
@Test
void overdue_case_is_escalated() {
var caseFile = CaseFileBuilder.anOpenCase()
.createdAt(Instant.now().minus(3, DAYS))
.build();
assertThat(policy.evaluate(caseFile).escalate()).isTrue();
}
This test changes meaning over time and timezone.
Better:
@Test
void case_created_more_than_48_hours_ago_is_overdue() {
var clock = Clock.fixed(Instant.parse("2026-07-02T10:00:00Z"), ZoneOffset.UTC);
var caseFile = CaseFileBuilder.anOpenCase()
.createdAt("2026-06-30T09:59:59Z")
.build();
var decision = policy.evaluate(caseFile, clock);
assertThat(decision).requiresEscalation().hasReasonCode("SLA_EXCEEDED");
}
Boundary tests:
@ParameterizedTest
@CsvSource({
"2026-06-30T10:00:00Z, false",
"2026-06-30T09:59:59Z, true"
})
void sla_boundary_is_exact(String createdAt, boolean expectedOverdue) {
var clock = Clock.fixed(Instant.parse("2026-07-02T10:00:00Z"), ZoneOffset.UTC);
var caseFile = CaseFileBuilder.anOpenCase().createdAt(createdAt).build();
assertThat(policy.evaluate(caseFile, clock).overdue()).isEqualTo(expectedOverdue);
}
20. Security and Actor Fixtures
Many business rules depend on actor identity:
- role;
- department;
- jurisdiction;
- delegation;
- ownership;
- separation of duties;
- conflict of interest;
- approval authority.
Avoid vague users:
var user = UserMother.defaultUser();
Prefer named actors:
var investigator = ActorMother.investigator("investigator-1");
var supervisor = ActorMother.supervisor("supervisor-1");
var conflictedSupervisor = ActorMother.supervisor("supervisor-2")
.withConflictOfInterest(caseFile.subjectId());
Test:
@Test
void conflicted_supervisor_cannot_approve_case() {
var caseFile = CaseFileBuilder.anEscalatedCase().build();
var actor = ActorBuilder.aSupervisor()
.withConflictOfInterest(caseFile.subjectId())
.build();
var decision = approvalPolicy.evaluate(caseFile, actor);
assertThat(decision)
.isRejected()
.hasReasonCode("CONFLICT_OF_INTEREST");
}
Security fixture must make authority explicit. Otherwise tests accidentally validate “any user can do anything”.
21. Fixture for Failure Paths
Good tests need failure fixtures too:
- repository throws transient error;
- dependency times out;
- downstream returns 409/429/500;
- malformed payload;
- duplicate key;
- stale version;
- lock timeout;
- partial write;
- message publish failure.
Example fake repository:
public final class FailingCaseRepository implements CaseRepository {
private final RuntimeException failure;
public FailingCaseRepository(RuntimeException failure) {
this.failure = failure;
}
@Override
public Optional<CaseFile> findById(CaseId id) {
throw failure;
}
}
Test:
@Test
void transient_repository_failure_is_classified_as_retryable() {
var repository = new FailingCaseRepository(new SQLTransientConnectionException("db down"));
var service = new CaseApplicationService(repository, eventPublisher, clock);
var ex = assertThrows(ApplicationFailureException.class,
() -> service.escalate(CaseId.of("CASE-123"))
);
assertThat(ex)
.hasReasonCode("CASE_REPOSITORY_UNAVAILABLE")
.isRetryable();
}
Failure fixtures should be intentional and named. Do not use random mocks that throw generic exceptions.
22. Mocks vs Fakes as Fixture Strategy
Mock:
var repository = mock(CaseRepository.class);
when(repository.findById(caseId)).thenReturn(Optional.of(caseFile));
Fake:
var repository = new InMemoryCaseRepository();
repository.save(caseFile);
When to prefer fake:
- dependency is a repository/port with stable behavior;
- many tests need realistic behavior;
- state matters;
- interaction verification is not the main concern;
- you want refactor-resistant tests.
When to prefer mock:
- dependency behavior is small;
- error path needs precise trigger;
- external interaction is the contract;
- verifying call parameters is the behavior;
- fake would be too expensive or misleading.
Example in-memory repository:
public final class InMemoryCaseRepository implements CaseRepository {
private final Map<CaseId, CaseFile> cases = new ConcurrentHashMap<>();
@Override
public Optional<CaseFile> findById(CaseId id) {
return Optional.ofNullable(cases.get(id));
}
@Override
public void save(CaseFile caseFile) {
cases.put(caseFile.id(), caseFile);
}
public CaseFile getRequired(CaseId id) {
return findById(id).orElseThrow();
}
}
A fake must not be more permissive than real infrastructure for important constraints.
If real DB has unique constraint, fake should detect duplicates if duplicate behavior matters.
public void save(CaseFile caseFile) {
var existing = cases.putIfAbsent(caseFile.id(), caseFile);
if (existing != null) {
throw new DuplicateCaseException(caseFile.id());
}
}
23. Test Data Explosion and Scenario Control
As systems grow, combinations explode:
status × risk level × actor role × evidence state × deadline × jurisdiction × assignment × feature flag
Do not create one fixture for every combination manually.
Use layered strategy:
example tests : important named scenarios
parameterized tests : small explicit matrices
property tests : broad generated exploration
formal models : state-space/protocol reasoning
Fixture builders should make combinations cheap, but strategy decides which combinations matter.
Example:
@ParameterizedTest
@MethodSource("approvalScenarios")
void approval_policy_matches_expected_decision(ApprovalScenario scenario) {
var decision = approvalPolicy.evaluate(scenario.caseFile(), scenario.actor(), scenario.now());
assertThat(decision).hasOutcome(scenario.expectedOutcome());
}
Scenario record:
record ApprovalScenario(
String name,
CaseFile caseFile,
Actor actor,
Instant now,
ApprovalOutcome expectedOutcome
) {
@Override
public String toString() {
return name;
}
}
Readable source:
static Stream<ApprovalScenario> approvalScenarios() {
return Stream.of(
scenario("supervisor can approve escalated case")
.caseFile(anEscalatedCase().build())
.actor(aSupervisor().build())
.expect(APPROVED),
scenario("investigator cannot approve own case")
.caseFile(anEscalatedCase().assignedTo("investigator-1").build())
.actor(anInvestigator("investigator-1").build())
.expect(REJECTED_SELF_APPROVAL)
);
}
24. Fixture Governance in Large Codebases
For large teams, fixture code needs ownership.
Recommended structure:
src/test/java
com.acme.caseworkflow
CaseWorkflowTest.java
support
CaseFileBuilder.java
ActorBuilder.java
CaseFileAssert.java
RecordingEventPublisher.java
src/integrationTest/java
com.acme.caseworkflow
CaseRepositoryIT.java
support
CaseTableFixture.java
PostgresCaseFixture.java
For multi-module repository:
case-domain-test-support
case-api-test-support
case-persistence-test-support
But create shared support only when needed.
Governance rules:
- keep builders small;
- remove unused builder methods;
- do not add scenario-specific one-off methods to global builders;
- prefer domain names over technical field names;
- review fixture changes like production code;
- require good failure messages for custom assertions;
- keep integration fixtures separate from unit fixtures;
- avoid global mutable fixture state.
25. Bad Fixture Smells
25.1 The God Builder
CaseFileBuilder.aCase()
.withStatus(...)
.withEverything(...)
.withLegacyMode(...)
.withFeatureFlagA(...)
.withFeatureFlagB(...)
.withDatabaseRow(...)
.withKafkaEvent(...)
The builder now controls too many layers.
Split by boundary:
CaseFileBuilder -> domain object
CaseRowBuilder -> database row
CaseEventBuilder -> message/event
CaseScenario -> orchestration DSL
25.2 The Lying Fake
Fake repository allows impossible states that real DB rejects.
Fix: implement important constraints in fake or use real integration test for those cases.
25.3 Fixture Mutation Leak
static List<Evidence> evidence = new ArrayList<>();
One test mutates it, another test fails.
Fix: immutable values and per-test instances.
25.4 Random Default Values
private CaseId id = CaseId.of(UUID.randomUUID().toString());
Failure is harder to reproduce.
Fix: deterministic default and explicit uniqueness when needed.
25.5 Over-Abstracted Setup
setupScenario(SCENARIO_42);
Nobody knows what scenario 42 means.
Fix: named scenario methods or visible builder chain.
26. In Action: Refactoring a Brittle Test
Original test:
@Test
void testApprove() {
var repo = mock(CaseRepository.class);
var audit = mock(AuditService.class);
var notification = mock(NotificationGateway.class);
var service = new CaseService(repo, audit, notification, Clock.systemUTC());
var c = new CaseFile(
"1", "OPEN", "HIGH", true, false,
"u1", "u2", Instant.now(), null, List.of(), List.of()
);
when(repo.findById("1")).thenReturn(Optional.of(c));
service.approve("1", "u2");
verify(repo).save(any());
verify(audit).record(any());
verify(notification).send(any());
}
Problems:
- constructor noise;
- stringly typed status;
- real time;
- weak oracle;
- interaction overspecification;
- no check of final state;
- user roles unclear;
- impossible to know why approval is valid.
Refactored:
@Test
void supervisor_can_approve_high_risk_open_case_after_required_evidence_is_present() {
var caseId = CaseId.of("CASE-123");
var repository = new InMemoryCaseRepository();
var events = new RecordingEventPublisher();
var clock = Clock.fixed(Instant.parse("2026-07-02T10:00:00Z"), ZoneOffset.UTC);
repository.save(CaseFileBuilder.anOpenCase()
.withId(caseId)
.withRiskLevel(RiskLevel.HIGH)
.withRequiredEvidence()
.build());
var service = new CaseApplicationService(repository, events, clock);
var supervisor = ActorBuilder.aSupervisor().withId("supervisor-1").build();
var result = service.approve(caseId, supervisor);
assertThat(result).isAccepted();
assertThat(repository.getRequired(caseId))
.hasStatus(CaseStatus.APPROVED)
.hasAuditEvent("CASE_APPROVED");
assertThat(events.published())
.containsExactly(CaseApproved.of(caseId, supervisor.id(), clock.instant()));
}
This test is longer, but better.
It exposes:
- exact case state;
- actor authority;
- deterministic time;
- final durable state;
- emitted event contract;
- no irrelevant mock choreography.
Clarity beats clever brevity.
27. Fixture Decision Matrix
Use this matrix when choosing fixture style:
| Need | Preferred Tool |
|---|---|
| Create simple valid domain object | Test data builder |
| Create named canonical example | Object mother delegating to builder |
| Vary many fields across scenarios | Builder + parameterized scenario source |
| Test persistence mapping | Row/table fixture |
| Test API payload compatibility | External fixture file |
| Test workflow across multiple operations | Scenario DSL |
| Test failure of dependency | Fake or mock with named failure |
| Verify domain object state repeatedly | Custom assertion |
| Test broad input space | Property-based generator |
| Test system protocol | Formal model / model-based testing |
28. Review Checklist for Test Structure and Fixtures
When reviewing tests, ask:
- Can I understand the relevant initial state from the test body?
- Are defaults valid and deterministic?
- Does the builder expose domain meaning or only fields?
- Is object mother small and semantic?
- Is fixture shared only where sharing is justified?
- Are custom assertions improving meaning and failure messages?
- Are mocks used only where interaction matters?
- Does fake behavior match important real constraints?
- Are external fixture files named and scoped clearly?
- Is there hidden time, randomness, global state, or IO?
- Would this test survive a safe internal refactor?
- Does the test have one dominant reason to fail?
29. Practical Defaults
For Java enterprise teams:
Use test data builders for domain objects.
Use object mothers only for named canonical examples.
Keep fixture data deterministic.
Keep important setup visible in the test.
Use custom assertions for domain-heavy checks.
Prefer fakes over mocks for stable stateful ports.
Use mocks for precise external interactions or failure injection.
Use external files for real artifacts, not hidden setup.
Avoid global mutable fixtures.
Review fixture code as production-quality code.
A healthy test suite feels like a second API for understanding the system.
30. Key Takeaways
- Test structure is a design problem, not formatting preference.
- Fixture is the world required by the test; keep it minimal and explicit.
- Builders should produce valid deterministic defaults.
- Object Mothers are good for semantic examples, bad as dumping grounds.
- Custom assertions turn repeated low-level checks into domain language.
- Fakes can make tests refactor-resistant, but only if they preserve important constraints.
- Snapshot tests are useful for artifacts but weak as sole correctness oracle.
- Time, IDs, randomness, security context, and failure behavior must be controlled.
- Shared fixture libraries are coupling points and need governance.
Next, we will move into mocking, stubbing, spies, fakes, and test doubles more deeply: not from library syntax, but from the question “what kind of evidence does this double actually provide?”
You just completed lesson 06 in start here. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.