skip to content

A MockK assertion `verify(exactly = 2) { repo.save(any()) }` fails, reporting a different number of matching calls than you expected. How do you work out whether the production code or the assertion is wrong?

level: seniorimportance: should knowfreq 35%

answer

  1. read the message: expected call, match count, recorded calls
  2. count is over matching calls — matcher breadth sets the population
  3. narrow the matchers to split the count
  4. overloads, equals()-based matching, retries, uncleared mocks
  5. empty recorded list = wrong mock instance

basics

~20 s

Read the failure output first: it names the expected call with its matchers, how many matching calls it found, and the calls actually recorded on the mock. Then check matcher breadth — any() folds distinct arguments into one count — plus overloads, argument equality, and calls arriving from setup or retries.

solid answer

~60 s

Start with the message, not the code. MockK renders the expected call with its matchers, the number of matching calls found, and the calls it did record on that mock. Comparing the recorded list against your matchers usually ends the investigation in seconds. Then work a checklist: - **Matcher breadth.** The count is over *matching* calls. `any()` collapses different arguments into one population; narrowing to `eq(user)` or `match { }` splits it and tells you whether the extra call had different arguments. - **Overloads.** `save(user)` and `save(user, opts)` are different methods; you may be counting the wrong one. - **Argument equality.** Matching uses `equals()`. A class without a value-based `equals()` matches only the identical instance, so real calls can fail to be counted. - **Extra invocations.** Retry wrappers, a listener registered twice, a framework re-invoking a handler, or shared setup exercising the collaborator. - **Mock state.** A mock shared across tests keeps its recorded calls until they are cleared, so counts drift as the suite grows. Decide by intent: if the extra call is real work the system does, the assertion was wrong; if it duplicates an effect, the code is.

code

kotlin · 9 lines
kotlin
// fails: 3 matching calls, expected 2 — but which ones?
verify(exactly = 2) { repo.save(any()) }

// split the population to find out
verify(exactly = 2) { repo.save(match { it.type == PRIMARY }) }
verify(exactly = 1) { repo.save(match { it.type == AUDIT }) }

// equality trap: no equals() override -> only the identical instance matches
verify(exactly = 1) { repo.save(match { it.id == expected.id }) }

go deeper

for a junior

Read the failure output and compare the recorded calls with what you expected before changing anything.

for a middle

Explain that the count is over calls matching your matchers, and use narrower matchers to find out how the surplus calls differ.

for a senior

Run the full checklist — matcher breadth, overloads, equality semantics, retry paths, shared setup, mock reuse, timing — and then judge whether the assertion or the code was wrong.

for a principal

Push the fix upstream: narrow matchers so failures name arguments rather than numbers, and per-test mock lifetimes so counts can never be inherited from another test.

## Read the failure before touching the code MockK's verification failure text carries three pieces of information: the **expected call**, rendered with the matchers you wrote (`any()`, `eq(...)`, `match { }` appear as such); **how many matching calls** it found; and the **calls it actually recorded** on that mock. Most count mismatches are solved by lining the recorded list up against the expected call — the arguments differ, or a method you forgot about shows up, or the list is empty because the mock was never the one the code used. ## The count is over matching calls, not invocations This is the point candidates most often miss. `verify(exactly = 2) { repo.save(any()) }` does not ask "was `save` called twice"; it asks "how many recorded calls match `save` with an argument matched by `any()`". Matcher breadth defines the population you are counting: - `any()` counts every `save`, whatever the argument. Two saves of different users are two matching calls. - `eq(userA)` counts only saves of `userA`. If production saved `userA` once and `userB` once, `exactly = 2` with `eq(userA)` fails at 1 while `any()` passes at 2. So the first diagnostic move after reading the message is to *narrow* the matchers deliberately and see how the count splits. That tells you whether the surplus calls carried different arguments — usually the difference between "the code does more work than I thought" and "the code does the same work twice". ## The rest of the checklist **Overloads and default arguments.** Overloads are distinct methods; a stub or verification on one is blind to the other. Kotlin default arguments can also route through a synthetic overload, so a call site that looks identical to your verification may not be. **Argument equality.** Matching a literal argument uses `equals()`. Data classes give structural equality; a plain class without an `equals()` override degrades to reference equality, so a call with an equal-but-not-identical object is *not* counted. If a count reads lower than the recorded list suggests, suspect this and switch to `match { }` on the fields you care about. **Where the extra calls come from.** Common real sources: a retry or resilience wrapper duplicating a lower-level retry; a listener or subscriber registered in two places; a framework invoking a handler more than once per event; shared `@BeforeEach` setup that exercises the collaborator before the test body runs; a loop that was meant to batch. **Mock state across tests.** Recorded calls accumulate on a mock for as long as that mock lives. A mock created once for the whole class and not reset carries calls from earlier tests, so counts creep upward as the suite grows and the failure looks order-dependent. Recreating the mock per test, or clearing it between tests, removes this whole class of confusion. **Timing.** If the call is made on another thread or from a coroutine that has not completed when `verify` runs, the count reflects whatever had been recorded at that instant. Diagnose it as a timing problem rather than a count problem — the fix belongs to how you wait for the work, not to the number in the assertion. ## Deciding who is wrong Once you know what the extra (or missing) calls are, the judgement is about intent: - The surplus call is **real, correct work** you had not modelled — the assertion was over-specified. Either widen it, or split it into two assertions with narrower matchers so the test states what it means. - The surplus call is a **duplicate effect** — a second charge, a second email, a second publish. The code is wrong and the assertion just earned its place. - The count is **lower** than expected and the recorded list shows the calls happened — matching is the problem: wrong matchers, wrong overload, equality semantics. - The recorded list is **empty** — you are verifying a mock the code never used, which is a wiring problem, not a counting one. ## The habit that prevents most of these Write the narrowest matchers the assertion actually needs. `verify(exactly = 1) { repo.save(match { it.id == order.id }) }` fails with a message about *which* save was expected, whereas `any()` with a count only tells you a number moved. Narrow matchers turn count failures into argument failures, and argument failures are much easier to read.

  • The recorded calls list in the failure is empty, but you can see the method run in a debugger. What is going on?
    You are verifying a different object from the one the code used. Typically the class under test constructs its own collaborator, a field was reassigned after the mock was injected, or a factory handed out a fresh instance. Nothing about counts will help — check identity of the collaborator the code actually calls, then fix the wiring so the mock is the instance in play.
  • How can a count assertion pass in isolation but fail when the whole class runs?
    The mock is shared across tests and keeps its recorded calls, so earlier tests contribute to the count. Creating the mock fresh per test, or resetting its recorded calls between tests, removes the coupling. The symptom to recognise is order-dependence: run the single test green, run the class red.

saying these in an interview costs you the question

  • Changing the expected number until the test passes, without reading the recorded calls
  • Believing the count covers every invocation of the method regardless of the matchers written
  • Ignoring overloads and Kotlin default-argument synthetic methods
  • Assuming argument matching works by identity, or forgetting a class has no value-based equals()
  • Blaming MockK for order-dependent counts caused by a mock shared across tests

context