A team's MockK stubs have grown into `answers { }` blocks with branching, mutable state and assertions inside them. How do you judge when answer-block logic has gone too far, and what do you do about it?
answer
- healthy block = pure function of THIS invocation
- state across calls ⇒ that's a fake, name it
- assertions in stubs ⇒ swallowed by production catch = false green
- branching on self/method.name = duplicated dispatch
- fake is type-checked against the interface; a lambda is not
basics
~20 sAnswer blocks should compute a return value from the call, nothing more. Branching on the invocation, accumulating state, or asserting inside the block means the stub has become an untested implementation living in a lambda. Replace it with a small hand-written double, and move assertions to captures outside.
solid answer
~60 sMy rule of thumb: an `answers { }` block may **derive a value from this call's arguments**. Once it does more, it has become an implementation. Three concrete triggers: 1. **Assertions inside the block.** They throw from inside the code under test, so a broad `catch` there can swallow them and the check silently disappears. Capture the argument and assert after the exercise phase instead. 2. **Mutable state shared across calls.** A stub that maintains a map so reads reflect prior writes *is* a fake — just one with no name, no tests and no reuse. Write the fake as a class implementing the interface. 3. **Branching on invocation metadata** (`self`, `method.name`) or long argument-based branching. MockK already dispatches by method and matcher; re-implementing dispatch inside a lambda hides it. The cost of leaving it is real: this logic is untested code that runs inside every test using the fixture, and when it is wrong, tests fail for reasons that have nothing to do with the system under test. The cure is boring — a fake class in test sources, plus plain `returns` stubs elsewhere — and it usually shrinks the fixture.
code
kotlin · 7 linesval saved = mutableMapOf<Long, User>()
every { repo.save(any()) } answers {
val u = firstArg<User>()
assertTrue(u.email.contains("@")) // can be swallowed by a catch in the SUT
saved[u.id] = u
if (u.id == 0L) u.copy(id = 42) else u
}go deeper
Recognise that answer blocks are for computing a return value and that assertions belong in the test body, not inside a stub.
Name the concrete triggers — cross-call state, assertions, branching on the invocation — and know that capture plus assert-after is the alternative to asserting inside the block.
Explain the false-green mechanism when production code catches broadly, and be able to write the replacement fake and justify why a type-checked class beats a lambda.
Give a threshold the team can apply, a migration order that fixes correctness first and style later, and an honest statement of where a rich answer block is still the right tool.
## Why this is a real question `answers { }` is powerful enough to implement the collaborator. That is convenient and it is a trap: the implementation ends up inside a lambda inside a fixture, with no name, no tests of its own, and no way for a reader to find it. Interviewers ask this because the answer reveals whether a candidate treats test code as code. ## A usable threshold An answer block is healthy when it is a **pure function of this invocation**: read the arguments, return a value. Examples that pass: `answers { firstArg() }`, a two-branch computation over one argument, invoking a callback argument, or `callOriginal()` with one conditional override. It has crossed the line when any of these appear: - **State that survives across calls** — a counter, a map, a list that later answers read from. That is a state machine, i.e. a fake. - **Assertions** — the block is now a verification site in the wrong place. - **Branching on which mock or which method** (`self`, `invocation.method.name`) — MockK already selected this stub by matcher and method; the branching is duplicated dispatch. - **Length** — roughly, more than a handful of lines, or logic a reader must trace to predict the return value. - **Reuse across many tests** — a shared block used by twenty tests is a component, and components deserve a name and a file. ## The specific danger of assertions in answer blocks This one deserves its own paragraph because it produces *false green*. An exception thrown inside an answer block propagates out of the mocked call into the code under test. Production code frequently wraps collaborator calls in `try/catch` for resilience — retry, fallback, degrade — and such a catch will swallow an assertion error exactly as it swallows an IOException. The test then completes and passes while the check it was built around silently failed, or, more insidiously, the code under test takes its *failure* path and the test asserts the wrong outcome. The replacement is argument capture: record what was passed, let the exercise phase finish, then assert in the test body where failures are unambiguous and the diff of expected versus actual is printed for you. ## What replacing it looks like A hand-written fake is usually smaller than people fear: ```kotlin class FakeUserRepo : UserRepo { private val rows = mutableMapOf<Long, User>() override fun save(u: User): User { rows[u.id] = u; return u } override fun find(id: Long): User? = rows[id] } ``` Compared with an answer-block equivalent it is: named (so it appears in searches), reusable, debuggable with a breakpoint on a real line, type-checked against the interface (so it breaks at compile time when the interface changes — an answer block does not), and reviewable like any other class. The mock library is then reserved for the cases it is genuinely better at: asserting interactions and injecting failures. The usual objection is verification: "a fake can't tell me the method was called". If that assertion matters, keep a mock for that collaborator and put the fake where state matters; the two coexist in one test suite without difficulty. Alternatively let the fake expose what it recorded — but only where the interaction genuinely is the contract. ## Migration, not a rewrite An established suite will not be converted in one change, and it does not need to be. A workable sequence: 1. Ban new occurrences — a review rule, stated as "answer blocks derive a value from this call's arguments; anything else needs a fake". 2. Fix the assertion-in-stub cases first; they are correctness bugs (false green), not style. 3. Extract the highest-traffic stateful block into a named fake and point the existing fixtures at it. Traffic is the right priority signal — that block already runs in dozens of tests. 4. Leave small blocks alone. `answers { firstArg() }` is idiomatic and clear; churning it costs more than it returns. ## The judgement to voice in an interview The defensible position is not "never put logic in stubs" but "stubs stay declarative; behavior gets a name". Justify it by cost: untested logic in a fixture fails in ways that point away from the real cause, and the debugging happens under time pressure during an unrelated change. And note the exceptions honestly — a one-off conditional `callOriginal()` for legacy code you cannot restructure is a perfectly reasonable use of the same mechanism.
- Why is an assertion inside an answers block worse than a slow or ugly test?Because it can produce a passing test that checked nothing. The assertion error is thrown from inside the mocked call, and production code that wraps collaborator calls in a broad catch will absorb it just like any other exception. The test then continues down the failure path, or completes normally, while the check silently vanished. That is a correctness defect in the suite, not a style issue.
- A fake cannot assert that a method was called. How do you handle a test that needs both stateful behavior and an interaction check?Use both kinds of double in the same test: a fake for the collaborator whose state matters, and a mock for the one whose interaction is the contract. If a single collaborator needs both, the fake can expose what it recorded, but I would first ask whether the interaction assertion is really adding value, since asserting on the resulting state is usually the more robust check.
- How would you roll this out across an existing suite without a big-bang rewrite?Stop the bleeding first with a review rule that answer blocks may only derive a value from the current call. Then fix assertion-in-stub cases as bugs, because they can hide failures. Finally extract the highest-traffic stateful blocks into named fakes, leaving trivial blocks like answers { firstArg() } untouched, since churning those costs more than it returns.
saying these in an interview costs you the question
- Treating logic inside answer blocks as harmless because "it's only test code".
- Believing an assertion inside a stub always fails the test.
- Rebuilding a collaborator's state machine in a lambda rather than in a named class.
- Claiming fakes are always better than mocks, or vice versa, instead of matching the tool to the assertion.
- Proposing a suite-wide rewrite instead of a rule plus targeted extraction.