skip to content

A MockK test captures a collaborator's argument into a slot, but the assertion on slot.captured fails even though the production code demonstrably passed the right data. Given how capture stores what it sees, what are the likely causes and how do you get a trustworthy snapshot?

level: seniorimportance: should knowfreq 28%

answer

  1. slot holds the reference, not a copy
  2. buffer cleared / builder reset after the call
  3. snapshot in answers: firstArg<...>().toList()
  4. last matching call wins — check the count first
  5. non-data class → equals fails on identity

basics

~20 s

A slot stores the reference, not a copy. If production mutates, clears or reuses that object after the call, the slot shows the later state. Take a snapshot inside an answers block (copy the argument) instead of capturing the live object — or check whether a later call simply overwrote the slot.

solid answer

~50 s

Three causes, checked in order. **Aliasing.** `capture` stores the reference it was given. If the production code passes a reusable buffer, a builder, or a list it clears after the call, `slot.captured` is that same object and reflects its state *at assertion time*, not at call time. The fix is to snapshot during the call: `every { writer.write(any()) } answers { snapshots += firstArg<List<Row>>().toList() }`. **Overwrite.** More than one call matched and the slot kept the last. Capture into a `MutableList` and assert on the whole sequence, or narrow the matchers so only the call you mean matches. **Over-broad matching.** `capture` matches like `any()`, so it also picks up calls you did not have in mind — a different code path, a retry, a flush. Adding a discriminating matcher alongside the capture, or asserting the count with `verify(exactly = n)` first, tells you immediately which of the three you are looking at.

code

kotlin · 11 lines
kotlin
// FAILS: production clears the buffer after write()
val slot = slot<MutableList<Row>>()
every { writer.write(capture(slot)) } just Runs
batcher.flush()
assertEquals(3, slot.captured.size)   // sees 0

// WORKS: freeze the value at call time
val snapshots = mutableListOf<List<Row>>()
every { writer.write(any()) } answers { snapshots += firstArg<List<Row>>().toList() }
batcher.flush()
assertEquals(3, snapshots.single().size)

go deeper

for a junior

Know that the slot holds the same object the production code passed, so later mutation shows up in the assertion.

for a middle

List the causes — aliasing, overwrite by a later call, over-broad matching — and show the snapshot-in-answers fix.

for a senior

Diagnose in order (count first, then identity vs contents, then equality), and argue for immutable arguments or a house rule that mutable types are always snapshotted.

for a principal

Treat it as a suite-wide reliability issue: capture of mutable state is a latent flake that appears the day someone adds buffer reuse, so define the convention and the review signal that prevents it.

## Capture stores a reference MockK writes into your slot exactly what the caller passed. For a value type or an immutable data class that is the whole story. For anything mutable, the slot is an alias: it points at the same object the production code still holds, so the assertion sees whatever state that object is in at the moment you read `captured` — which is *after* the code under test has finished. ## Symptom one: the object changed after the call The classic shapes are a batching component that writes a buffer and then clears it, a builder that is reset and reused for the next item, a `StringBuilder` appended to across iterations, or a mutable domain entity whose status is advanced right after the collaborator call. The test asserts three rows and sees zero, or asserts `PENDING` and sees `SETTLED`. Nothing is wrong with MockK — the reference is faithful, the object simply moved on. The remedy is to take the snapshot at call time, which means doing the copying inside an answers block rather than letting capture hand you the live object: ```kotlin val snapshots = mutableListOf<List<Row>>() every { writer.write(any()) } answers { snapshots += firstArg<List<Row>>().toList() } ``` `toList()`, `copy()`, or extracting the two fields you actually care about are all fine — the point is that the value you assert on is frozen at the moment of the call. When the argument is deeply mutable, copy the specific fields rather than attempting a deep clone; tests read better asserting on `captured.map { it.id }` computed at call time than on a reconstructed graph. ## Symptom two: a later call overwrote the slot A single `CapturingSlot` keeps the most recent matching argument. A retry, a flush at the end of a batch, or a second code path all overwrite it silently. Capture into a `MutableList` and the whole sequence becomes visible, which usually turns a puzzling assertion failure into an obvious "oh, it is called twice". ## Symptom three: the capture matched more than you meant `capture(slot)` matches like `any()`. If the collaborator has several methods, or the same method is called on another path, the slot is populated by whichever matching call came last. Two habits keep this honest: assert the count first (`verify(exactly = 1) { writer.write(capture(slot)) }` fails loudly if two calls matched) and pair the capture with a discriminating matcher on another parameter so only the intended call qualifies. ## Symptom four (less common): equality, not identity If the assertion compares against a freshly constructed expected object, it is comparing by `equals`. A class that is not a data class and does not override `equals` fails no matter how correct the values are — the captured instance is a different object. Either make the type a data class, assert on the fields you care about, or use a `match { }` matcher in the verify block instead of capturing. ## A diagnosis order that works 1. Assert the interaction count first — `verify(exactly = n)` — to rule out overwrite and over-broad matching. 2. Log or assert something about the captured object's identity versus its contents; if the object is a collection or builder, suspect mutation immediately. 3. Switch the capture to a snapshot in an `answers` block and see whether the failure disappears; if it does, it was aliasing. 4. If values look right but equality fails, check `equals` on the type. ## Prevention Where you control the production code, passing immutable arguments across module boundaries removes this class of failure entirely and is usually the better design anyway. Where you do not, make snapshotting the house style for mutable argument types, and reserve plain `capture` for values and data classes. A comment is not enough — a test that reads a mutable object after the fact is a latent flake, because it starts failing the day someone adds buffer reuse for performance.

  • How would you prove that mutation, rather than a wrong argument, is the cause?
    Replace the capture with a snapshot taken inside an `answers` block — copy the collection or the fields at call time — and re-run. If the assertion now passes, the argument was correct when it was passed and something mutated it afterwards. Asserting the interaction count first also rules out the competing explanation that a later call simply overwrote the slot.
  • When would you avoid capture entirely for this kind of argument?
    When the assertion can be expressed as a predicate, `verify { writer.write(match { it.size == 3 }) }` evaluates at match time, so it sees the argument's state during the call and is immune to later mutation. It also fails with a clearer message about the interaction rather than about a stale object, at the cost of showing you less detail when it does fail.

saying these in an interview costs you the question

  • Believing MockK snapshots or deep-copies the captured argument
  • Concluding the production code is wrong without first checking how many calls matched
  • Trying to fix aliasing by clearing the slot or the mock between assertions
  • Comparing a captured non-data-class instance to a freshly built expected object and blaming MockK when it differs
  • Adding a sleep or reordering assertions instead of snapshotting at call time

context