Complex Test Data Builders
The Test Data Builder Pattern works incredibly well for constructing test data in a consistent and readable way. It's easy to set up for simple domain objects without many constraints. However, it becomes trickier when dealing with domain objects that enforce strict rules or state transitions. This post explores how to build a more complex Test Data Builder that respects domain rules and disallows invalid configurations.
Example
Consider a domain that models a state machine with four steps. Each step sets a corresponding field in the domain object:
public enum State {
STEP_1,
STEP_2,
STEP_3,
STEP_4
}
public class StateMachine {
private State state;
private final String step1;
private Optional<String> step2;
private Optional<String> step3;
private Optional<String> step4;
public static StateMachine of(String step1) {
return new StateMachine(
State.STEP_1,
step1,
Optional.empty(),
Optional.empty(),
Optional.empty()
);
}
public void transitionTo2(String step2) {
if (state != State.STEP_1) {
throw new IllegalArgumentException("Only step 1 can go to step 2");
}
state = State.STEP_2;
this.step2 = Optional.of(step2);
}
public void transitionTo3(String step3) {
if (state != State.STEP_2) {
throw new IllegalArgumentException("Only step 2 can go to step 3");
}
state = State.STEP_3;
this.step3 = Optional.of(step3);
}
public void transitionTo4(String step4) {
if (state != State.STEP_3) {
throw new IllegalArgumentException("Only step 3 can go to step 4");
}
state = State.STEP_4;
this.step4 = Optional.of(step4);
}
}
A test might want to create a StateMachine
already in step 4, ignoring the logic of how it got there. It should only set the data relevant to the test, in this case, the value for step 4. Ideally, the test would look like this:
final var stateMachineInStep4 = StateMachineTestData.builder()
.setStep4("Highly relevant information for this specific test")
.build();
This presents a challenge: how do you set up a Test Data Builder that allows setting step4
directly, without having to transition through all the previous steps? Let’s look at two approaches.
Approach 1: Default Step Methods
Rather than using a single entry point for the builder, you can define multiple static methods that start at a specific state:
final var stateMachineInStep4 = StateMachineTestData.defaultStep4("Highly relevant information for this specific test")
// Optionally override other steps if relevant
.build();
This allows you to encapsulate the setup logic for each state:
public class StateMachineTestData {
private State state;
private String step1;
private Optional<String> step2;
private Optional<String> step3;
private Optional<String> step4;
public static StateMachineTestData defaultStep1(String step1) {
return new StateMachineTestData(
State.STEP_1,
step1,
Optional.empty(),
Optional.empty(),
Optional.empty()
);
}
public static StateMachineTestData defaultStep2(String step2) {
return new StateMachineTestData(
State.STEP_2,
"Default step 1 data",
Optional.of(step2),
Optional.empty(),
Optional.empty()
);
}
public static StateMachineTestData defaultStep3(String step3) {
return new StateMachineTestData(
State.STEP_3,
"Default step 1 data",
Optional.of("Default step 2 data"),
Optional.of(step3),
Optional.empty()
);
}
public static StateMachineTestData defaultStep4(String step4) {
return new StateMachineTestData(
State.STEP_4,
"Default step 1 data",
Optional.of("Default step 2 data"),
Optional.of("Default step 3 data"),
Optional.of(step4)
);
}
public StateMachineTestData setStep1(String step1) {
this.step1 = step1;
return this;
}
public StateMachineTestData setStep2(String step2) {
this.step2 = Optional.of(step2);
return this;
}
public StateMachineTestData setStep3(String step3) {
this.step3 = Optional.of(step3);
return this;
}
public StateMachineTestData setStep4(String step4) {
this.step4 = Optional.of(step4);
return this;
}
public StateMachine build() {
return new StateMachine(state, step1, step2, step3, step4);
}
}
Each of the four methods defaultStep1
, defaultStep2
, defaultStep3
and defaultStep4
starts with sensible defaults and can be further customized if needed.
Caveat
This approach is slightly error-prone. For example, the following compiles but creates invalid data:
final var stateMachineInStep4 = StateMachineTestData.defaultStep1("Step 1")
.setStep4("Step 4 data")
.build();
This bypasses the necessary intermediate steps (step 2 and 3), which would result in an invalid state. A robust domain model might catch this, but it depends on the chosen construction method. You could solve this by using a typed builder pattern with chained transitions, but that adds significant complexity.
Approach 2: Automatic Completion on Build
Another option is to let the builder figure out what state you're in based on the fields you've set:
public class StateMachineTestData {
private Optional<String> step1 = Optional.empty();
private Optional<String> step2 = Optional.empty();
private Optional<String> step3 = Optional.empty();
private Optional<String> step4 = Optional.empty();
public static StateMachineTestData builder() {
return new StateMachineTestData();
}
public StateMachineTestData setStep1(String step1) {
this.step1 = Optional.of(step1);
return this;
}
public StateMachineTestData setStep2(String step2) {
this.step2 = Optional.of(step2);
return this;
}
public StateMachineTestData setStep3(String step3) {
this.step3 = Optional.of(step3);
return this;
}
public StateMachineTestData setStep4(String step4) {
this.step4 = Optional.of(step4);
return this;
}
public StateMachine build() {
final var state = calculateState();
final var step1 = this.step1.orElse("STEP 1 DATA");
final var step2 = this.step2.or(
() -> state.ordinal() >= State.STEP_2.ordinal()
? Optional.of("STEP 2 DATA")
: Optional.empty()
);
final var step3 = this.step3.or(
() -> state.ordinal() >= State.STEP_3.ordinal()
? Optional.of("STEP 3 DATA")
: Optional.empty()
);
final var step4 = this.step4.or(
() -> state.ordinal() >= State.STEP_4.ordinal()
? Optional.of("STEP 4 DATA")
: Optional.empty()
);
return new StateMachine(state, step1, step2, step3, step4);
}
private State calculateState() {
if (step4.isPresent()) return State.STEP_4;
if (step3.isPresent()) return State.STEP_3;
if (step2.isPresent()) return State.STEP_2;
return State.STEP_1;
}
}
This approach automatically fills in default values and determines the final state based on which fields are set. It simplifies usage in tests and reduces manual setup.
The downside is that more logic now lives in the builder. In complex scenarios, ensuring that the state transitions and defaults remain correct becomes harder and more error-prone.
Conclusion
Here’s a quick comparison of the two approaches:
Strategy | Pros | Cons |
---|---|---|
Default step methods | Easy to implement, minimal logic | Error-prone if the wrong method is called |
Automatic completion | Easier to use in tests | More logic and maintenance in the builder |
Choosing the right strategy depends on your domain's complexity and how strict your validation needs to be. Either way, using Test Data Builders in more complex domains can still provide a clean and maintainable way to set up test data.