How do you stop a shared test-data builder's defaults from leaking state between tests?
answer
- Interference travels through shared setup
- Ask what outlives one case
- When is a default evaluated
- Copy before deriving a variant
- Shuffle the order to prove it
basics
~20 sHand every test a fresh builder, compute time-dependent and unique defaults at build time rather than once at load, deep-copy nested objects, and derive variants by copying the builder instead of mutating a shared one.
solid answer
~50 sThree leaks account for nearly all of it. **A shared builder instance**: if a suite-level constant is reused and each setter mutates and returns the same object, a value set in one case survives into the next, and the failure moves when the run order changes - so expose a factory function that returns a new builder per call. **Defaults captured once**: a timestamp or a unique reference evaluated when the helper is first loaded is the same in every case, so identifiers collide and time-sensitive cases drift as the run gets longer. Compute those in the build step. **Shared nested state**: a default collection or child object handed out by reference is mutated by one test and seen by the next, so copy it on build. For derived variants, give the builder an explicit copy operation and branch from the copy.
code
pseudocode · 16 lines// leaks: one shared instance, defaults frozen at load
SHARED = consignmentBuilder().acceptedAt(clock.now()).reference("TRK-1")
// safe: fresh builder per call, defaults computed at build
function aConsignment():
return new ConsignmentBuilder()
class ConsignmentBuilder:
function build():
return Consignment(
reference = ref ?: "TRK-" + nextSequence(),
acceptedAt = acceptedAt ?: clock.now(),
events = copyOf(events))
function copy():
return new ConsignmentBuilder(fields) // derive variants from herego deeper
Remember the simplest guard: ask for a new builder in each test rather than reusing one that other tests have already touched, and never modify a helper's returned object in place.
You should be able to name the three leaks - shared instance, defaults captured at load, nested state shared by reference - and give the fix for each, including computing time and identifiers at build time.
Demonstrate diagnosis: shuffle the order, run the case alone, and read uniqueness violations as a captured-once default rather than as instability to be retried away.
Frame it as a construction contract for the whole codebase: freshness and immutability defaults everyone inherits, so no team has to rediscover these leaks one suite at a time.
## Why a builder leaks at all A builder holds mutable state on purpose - that is how a chain of overrides accumulates. The danger is the moment that mutable state outlives one test case. Then the pattern that was supposed to make cases independent becomes the channel through which they interfere, and the resulting failures look nondeterministic: green alone, red in the full run, or red only when the suite is shuffled. Consider a parcel-tracking gateway suite. Its consignment builder is exposed as a suite-level constant so every test can reach it. One case sets the customs flag; the next case, which never mentions customs, now builds a held consignment and fails an assertion about routing. Nothing in the failing test explains the failure, because its cause is three files away. ## Leak one: one builder shared by every case If the builder is a single long-lived object and each setter mutates it and returns itself, every override is permanent for the rest of the run. The fix is trivial once seen: publish a **factory function** - the thing tests call returns a brand-new builder each time. A mother method that returns a pre-loaded builder must likewise build a new one per call rather than handing out a cached one. A stricter variant makes the builder immutable: each setter returns a **new** builder carrying the change, so nothing can be modified after the fact and sharing a starting point is safe. It costs an allocation per override and buys away an entire class of failure. ## Leak two: defaults frozen at load time The subtlest failures come from defaults that are evaluated once, when the helper type is first initialised, instead of once per build. - A default **timestamp** captured at load is identical for every consignment in the run, and it is already stale by the time a late test builds against it. On a parcel-tracking suite this shows up as a clock-skew artefact: a case that asserts an acceptance time falls inside a service window passes in a short run and fails once the suite grows long enough for the gap between load and assertion to cross the window boundary. The test looks flaky; it is not - it is a fixed value racing a moving clock. - A default **unique identifier** captured once is not unique. Two consignments share a tracking reference, a uniqueness constraint rejects the second, and the test that fails is whichever happened to run second. Both are fixed the same way: evaluate them in the build step. Take the clock from an injected time source the test can control, and take identifiers from a per-build generator. That also makes an override meaningful - a case that pins a specific acceptance time is visibly saying so. ## Leak three: nested objects shared by reference A default that is a collection, a child record or a mutable value object gets handed to every object the builder produces. If one test appends a tracking event to that collection, every later test sees it. Copy nested state during the build, and return collections the caller cannot modify. If the domain type is itself immutable, this problem disappears - which is a decent argument for immutable test data generally. ## Derived variants without mutation Half the appeal of builders is deriving one case from another: take the standard consignment, change the destination, build. That is safe only when the derivation does not disturb its source. Give the builder an explicit **copy** operation and branch from the copy; then a shared starting point can be handed to several cases without any of them stepping on the others. Watch the direction of derivation too. Deriving three cases from one base is readable. Deriving a chain five deep - each variant built from the previous one - means a reader must follow the whole chain to know what the last case actually contains, and a change to the base silently rewrites cases nobody looked at. Keep derivations one level from a named starting state. ## Proving it rather than asserting it The convincing part of an answer is how you catch these. **Randomise the run order** and see whether failures move; **run the suspect case alone** and see whether it passes; and, once workers run in parallel, watch for uniqueness violations, which are almost always a captured-once default. A build step that stamps each object with a per-build sequence number makes collisions obvious rather than mysterious. None of this is about writing more elaborate builders. It is one rule with three faces: **nothing a builder hands out may be shared, and nothing it defaults may be older than the build.**
- A case in a long suite asserts an acceptance time falls inside a service window and fails only when the whole suite runs. What do you suspect first?A default timestamp evaluated once when the helper loaded, then compared against a clock that keeps moving. In a short run the gap is small enough to stay inside the window; in a long run it crosses the boundary. Confirm by running the case alone, then move the default into the build step and take time from an injectable source so the case pins what it depends on.
- How would you make derived variants safe without rewriting the builder as immutable?Add an explicit copy operation and require every derivation to start from a copy, so setters can stay mutating. Then a shared starting state is only ever read. Keep derivations one level deep from a named state; chains where each variant builds on the last force a reader to trace the whole chain to know what the final object holds.
saying these in an interview costs you the question
- Exposes one builder instance as a suite-wide constant
- Computes default timestamps once when the helper loads
- Uses a constant identifier as a unique-field default
- Hands out the same default collection by reference
- Calls the resulting failures inherently flaky and adds a retry
- Derives variants by mutating the shared starting builder