skip to content

Your generated mock pins the exact sequence of calls a reconciler makes, and a behaviour-preserving refactor turns the suite red. What do you change?

level: seniorimportance: should knowfreq 42%

answer

  1. ask what a user would notice
  2. the report names method and arguments
  3. the path moved, the outcome did not
  4. reads loose, writes pinned
  5. regenerating does not touch expectations

basics

~20 s

Read the unmet-expectation report to confirm the outcome is unchanged and only the call path moved, then rewrite the expectations to assert outcomes: allow reads any number of times and in any order, pin only the writes the contract promises, and match on the argument fields that matter rather than whole structs.

solid answer

~50 s

First separate the two failures the report can mean. A generated mock's end-of-test check fires for an expected call that never happened or an unexpected one that did, and it names the method and arguments — so compare that against what the refactor changed. If the reconciler now caches a lookup, batches two updates into one, or reorders two independent calls, the observable contract is intact and the test is wrong. The fix is to stop treating the call sequence as the specification: allow the read methods any number of times, drop ordering unless the contract genuinely requires it (create before attach, say), assert the writes — how many, with which fields — and compare only the spec fields the behaviour is about instead of a whole struct that carries incidental state. Keep exactly one ordering assertion where order is a real promise, and delete the rest.

go deeper

for a junior

Recall that a mock can be told exactly which calls to expect, and that asserting on the result the code produced is usually sturdier than asserting on the calls it made.

for a middle

Explain what the end-of-test verification checks — counts, arguments, order, unexpected calls — and name refactors such as caching or batching that change those without changing behaviour.

for a senior

Show the triage: read the unmet-expectation report against the diff, decide whether the contract or only the path changed, then rewrite expectations around outcomes and defend that loosening in review.

for a principal

Own the standard for the repository: which interactions count as contract, how reviewers judge a loosened expectation, and how you stop a suite that reports every refactor as a regression from training people to ignore it.

## What actually went red A strict generated mock records, per method, an expectation: how many times it must be called, with what arguments, and in what position relative to other expectations. At the end of the test it verifies all of that and reports the mismatch — usually through a `t.Errorf` registered with `t.Cleanup`, which is why the failure appears *after* the test body's own output and confuses people the first time. So the report says one of two things: - **an expected call was not made** — the code stopped calling something; or - **an unexpected call was made** — the code called something the test did not set up, or called it once too often, or with different arguments. That report is the diagnostic. Read it against the diff. The question it answers is: *did the observable contract change, or only the path the code took to honour it?* ## The refactors that break over-specified tests without changing behaviour With a reconciler that compares a desired-state spec against what a platform API reports and issues the calls needed to converge, all of these are behaviour-preserving: - **Caching or deduplicating a read.** The reconciler used to call `Get` twice; now it reads once and reuses the value. Expected-call-count two, got one. - **Batching writes.** Two `Update` calls collapse into one carrying both changes. The final state is identical; the counts are not. - **Reordering independent calls.** Two updates on unrelated objects swap, because a loop over a map replaced a hardcoded pair — and Go's map iteration order is randomised, so the test may even fail intermittently. - **An extra defensive read.** A re-read after a write to confirm convergence adds a call the strict mock never expected. - **Moving a call behind an early return.** Nothing changed for the caller; the mock sees a call vanish. In every one of these, the desired state at the end is what the contract promises, and it is unchanged. The test failed because it specified the *implementation*. ## What to assert instead Rewrite the expectations around what the reconciler promises, in roughly this priority: 1. **The outcome.** After `Reconcile`, what does the platform hold? If the double can carry state — the spec last written to it — assert on that with a plain comparison and `t.Errorf`. This survives caching, batching and reordering by construction. 2. **The writes, loosely counted.** Mutations usually *are* part of the contract: "exactly one `Update`, carrying `DesiredCount` 3" is worth pinning. "Exactly two `Update` calls in this order" almost never is. 3. **The absence of writes.** "When the observed state already matches the spec, `Update` is never called" is an interaction assertion that is genuinely behavioural — it is the idempotence promise, and it should stay strict. 4. **The arguments that matter.** Match `spec.Name` and `spec.DesiredCount`, not the whole struct including a timestamp, a generation counter or a map whose order is incidental. Whole-struct matching with `reflect.DeepEqual` is where tests acquire their fragility quietly. And loosen the rest: reads allowed any number of times, ordering unconstrained unless the order is itself the promise (create the object before attaching a policy to it; acquire before release). ## Deciding, not just loosening The discipline is to ask, expectation by expectation: *if this call count or order changed, would a user of this package notice?* If not, it is not a specification and it should not be in the test. That question is answerable in review, and it is the one to bring to the pull request that loosened the suite — otherwise the loosening reads as "the author weakened the test to make it pass". Two things not to do: - **Do not regenerate the mock to fix this.** Regeneration rewrites the mock type from the interface. The expectations are hand-written in the test; the generator never touches them. Re-pinning the new sequence just reinstates the same fragility one refactor later. - **Do not delete the test.** The reconciler's convergence behaviour is worth testing. It is the *level of description* that was wrong, not the existence of the test. ## The signal you keep A test suite that pins call sequences reports every refactor as a regression, which trains the team to update expectations mechanically until nobody reads them. A suite that asserts outcomes stays quiet through refactors and goes red when convergence genuinely breaks — and that is the only failure signal worth paging on.

  • Which interaction assertions would you keep strict even after loosening the rest?
    Ones that are the contract itself: that no write happens when observed state already matches the spec, since that is the idempotence promise; and a genuine ordering requirement such as creating an object before attaching something to it. Both would be visible to a user of the package if broken, which is the test to apply.
  • The failure appears after the test body has finished. Why, and how does that change your reading of it?
    Generated mocks usually register their verification with `t.Cleanup`, so it runs after the test function returns. The failure line therefore describes the whole test's interactions, not the statement it appears next to. Read it as a summary of expected-versus-actual calls and correlate it with the diff, not with the last assertion printed.
  • Why can pinning the exact order of two updates make a test flaky rather than merely fragile?
    If the two calls come from a loop over a map, Go randomises map iteration order, so the sequence differs between runs. The mock's ordered expectation then passes or fails at random. Either sort the keys in the code where order is genuinely part of the contract, or drop the ordering expectation.

saying these in an interview costs you the question

  • Regenerates the mock hoping it will fix the expectations
  • Re-pins the new call sequence, recreating the fragility
  • Deletes the test rather than changing what it asserts
  • Matches whole argument structs including incidental fields
  • Treats every unexpected-call report as a genuine regression
  • Pins the order of calls produced by a loop over a map