| name | refactoring-test-setup-to-fixturepresets-and-testapi |
| description | Refactor Kotlin/JUnit scenario tests to use `*Fixture` + `*FixturePresets` + `*TestApi` (and `Mock*Server`) for complex setup, so test cases stay thin and use `*TestApi` only for fixture setup and observation/asserts. |
Refactor Test Setup To *FixturePresets And *TestApi
Goal
Move complex scenario setup out of test cases into *Fixture + *FixturePresets.
Prefer *TestApi for reusable fixture setup and observation/asserts.
Centralize stubbing in Mock*Server wrappers.
Keep test cases as thin scripts: arrange, act, assert.
When Setup Is “Complex Enough” To Extract
Treat setup as complex enough if at least one applies.
- The same setup sequence appears in more than one test.
- A test needs to create a graph of objects across multiple resources.
- A test needs both data insertion and stubbing of external calls.
- The Arrange block is longer than the Act and Assert blocks combined.
- The test case touches low-level infrastructure directly (SQL, HTTP clients, WireMock registration).
Boundaries: *TestApi vs *FixturePresets
Use these decision rules to keep the test fixture layer maintainable.
- Keep each
*TestApi scoped to a single resource.
- If a setup operation must write to multiple resources, implement it in
*FixturePresets, not in *TestApi.
- Smell: a
*TestApi constructor depends on multiple repos/clients from different resources.
- Avoid “god” fixture components that aggregate many fixture beans only to make injection convenient.
Refactoring Algorithm
- Identify the scenario and list the minimal state and stubs it requires.
- Create a
*Fixture type that describes the required graph declaratively.
- Create or update
*FixturePresets that can build typical *Fixture graphs for the scenario.
- If a fixture setup or observation sequence is reused, create or update
*TestApi facades for it.
- Create or update
Mock*Server wrappers that own all stubs required by the fixture.
- Implement
insertFixture(fixture: *Fixture) in *FixturePresets and materialize the fixture via production calls (directly or via *TestApi) and Mock*Server.
- Replace inline setup in test cases with
*FixturePresets usage.
- Keep assertions in tests or in domain assertion helpers, but do not move business assertions into
*FixturePresets.
*Fixture Design Checklist
- Keep fixtures minimal and scenario-focused.
- Prefer explicit IDs and references in the fixture so relationships are visible in code review.
- Provide small helper accessors like
theX() only when they reduce noise without hiding important structure.
- Keep fixtures free of side effects and insertion logic.
- Put defaults and convenience constructors in fixture builders or presets, not in
insertFixture.
Example shape (declarative fixture graph)
Keep the graph in the *Fixture type, and keep insertion as a thin materialization step.
data class NewOrderFixture(
val order: Order = OrdersObjectMother.order(),
val lines: List<OrderLine> = LinesObjectMother.lines(order.ref(), count = 2),
)
@Component
class OrdersFixturePresets(
private val ordersRepo: OrdersRepo,
private val linesRepo: LinesRepo,
) {
fun insertFixture(fixture: NewOrderFixture): NewOrderFixture {
val savedOrder = ordersRepo.save(fixture.order)
val savedLines = linesRepo.saveAll(fixture.lines)
return fixture.copy(order = savedOrder, lines = savedLines)
}
}
Fixture Insertion Checklist (*FixturePresets.insertFixture)
- Insert data in a stable order that respects foreign keys and ownership boundaries.
- If a production call is used only inside one
*FixturePresets, it may be called directly from the preset.
- If a production call sequence is reused across multiple tests or presets, extract it to
*TestApi.
insertFixture materializes only what the fixture describes, except for ID resolution when IDs are DB-generated.
- Do not use SQL scripts for scenario setup, except for a minimal ubiquitous baseline fixture.
- Configure stubs only through
Mock*Server wrappers and keep defaults centralized.
- Keep insertion fast, deterministic, and repeatable across tests.
IDs Strategy
Preferred: UUID-first fixtures
If the production model uses stable IDs (for example UUID), build the entire graph in memory as a single *Fixture.
Insert the graph in one pass via *FixturePresets.insertFixture, using the IDs already present in the fixture.
Fallback: DB-generated IDs
If IDs are generated by the database and cannot be chosen in the fixture, use a two-phase approach.
- Define the fixture in terms of “handles” that can be resolved after insertion.
- Insert the root objects first via
*TestApi and capture their generated IDs.
- Build the dependent objects using the captured IDs.
- Insert the dependent objects via
*TestApi.
- Store the resolved IDs in the resulting fixture handle object returned by
insertFixture.
The test case still stays declarative by using the returned handle object, not by threading IDs manually.
Done Gate
External scenario test cases do not call production code directly.
Internal scenario tests call the SUT directly and use *TestApi only for fixture setup and observation/asserts.
Scenario test cases do not assemble complex object graphs inline.
Scenario test cases do not register stubs ad-hoc and rely on Mock*Server.
Fixture insertion is reusable through *FixturePresets and uses *TestApi only when reuse warrants it.
The suite remains within the speed budgets or deviations are explicitly recorded.
If you created new files, git status does not show them as untracked.
References
../../concepts/testing-testcode-architecture.md.
../../concepts/testing-speed-budgets.md.
../../ergo/tech/kotlin/testing.md.