skip to content

Generated Mocks and Costs

Go can generate a mock implementation from an interface, but the community default stays a hand-written fake and a plain if check. You should be able to argue when generation pays for itself.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

Go's `testing` package has no assertion API — how do you check a mock's recorded calls, and when is `t.Fatalf` wrong?

level: middleimportance: must knowfreq 62%

answer

  1. the standard library gives you if
  2. two words, in a fixed order
  3. one keeps going, one stops
  4. stopping only works on one goroutine
  5. a helper should confess it is one

basics

~20 s

You compare with a plain if and report with t.Errorf, using the got/want convention: if got != want { t.Errorf("Update calls = %d, want %d", got, want) }. t.Errorf records the failure and keeps going; t.Fatalf stops the test and is valid only on the goroutine running the test function.

solid answer

~50 s

Go's standard library deliberately ships no assertion helpers, so a check is an ordinary `if` plus a formatted failure message in the `got`/`want` shape: `if got != want { t.Errorf("Update calls = %d, want %d", got, want) }`. For composite values such as a recorded argument struct, compare with `reflect.DeepEqual` (or `slices.Equal` for slices) and print with `%+v` so the diff is readable. `t.Errorf` marks the test failed and returns, so several independent checks all report in one run; `t.Fatalf` marks it failed and stops the test function immediately, which you want when continuing would panic — a nil result or a setup error. The trap with mocks: `t.Fatalf` calls `t.FailNow`, which is documented as valid only on the goroutine running the test. Inside a mock callback on another goroutine it ends just that goroutine and the test carries on, so use `t.Errorf` there or send the failure back over a channel.

code

go · 10 lines
go
got, err := r.Reconcile(ctx, spec)
if err != nil {
	t.Fatalf("Reconcile(%+v) error = %v, want nil", spec, err)
}
if got.DesiredCount != 3 {
	t.Errorf("Reconcile(...).DesiredCount = %d, want 3", got.DesiredCount)
}
if !reflect.DeepEqual(gotCalls, wantCalls) {
	t.Errorf("recorded calls = %+v, want %+v", gotCalls, wantCalls)
}

go deeper

for a junior

Know the shape by heart: an if, then t.Errorf with got printed before want. Know that t.Logf alone leaves the test passing.

for a middle

Explain the mechanics — Errorf is Logf plus Fail, Fatalf is Logf plus FailNow, and FailNow ends the test goroutine — and say which you choose when a nil result would otherwise be dereferenced.

for a senior

Demonstrate the concurrency judgment: mock hooks often run on other goroutines, so route failures through t.Errorf or a channel, and treat a post-test log panic as evidence of a leaked goroutine rather than noise.

for a principal

Own the repo-wide convention: one message format, helpers that call t.Helper, subtests so one bad row does not mask the rest, and a clear position on whether an assertion dependency is worth its cost at all.

## Why there is nothing to import Go's `testing` package gives you a test runner, failure reporting and lifecycle hooks — and no assertion vocabulary at all. That is a design choice, not an omission: the language already has `if` and `==`, and a hand-written comparison produces a failure message tailored to the thing being tested rather than a generic `expected X to equal Y`. So an assertion in Go is two lines. ## The got/want convention The convention the standard library itself follows is: ```go if got != want { t.Errorf("Update calls = %d, want %d", got, want) } ``` Three habits make these messages good: 1. **Name the thing you measured**, ideally as the call that produced it: `Reconcile(spec).DesiredCount = 2, want 3`. When the failure line is read months later with no code in front of you, that string is all you have. 2. **Print got first, want second**, and say `want`, not `expected`. Consistency across a repository lets people scan failures quickly. 3. **Use the right verb**: `%d` for counts, `%q` for strings (so whitespace and empties are visible), `%+v` for structs so field names appear, `%v` for the rest. For values that `==` cannot compare — a slice of recorded arguments, a struct containing a slice or map — use `reflect.DeepEqual(got, want)`, or `slices.Equal` / `maps.Equal` when the element type is comparable. Note that `reflect.DeepEqual` treats a nil slice and an empty non-nil slice as different, which is a frequent surprise when a mock records calls into a slice that is sometimes never appended to. A shared checker used from several tests should call `t.Helper()` as its first statement; the testing package then reports the **caller's** line number instead of the line inside the helper. ## Errorf versus Fatalf - `t.Errorf(...)` is `t.Logf` followed by `t.Fail`: the test is marked failed, execution continues to the next statement. - `t.Fatalf(...)` is `t.Logf` followed by `t.FailNow`: the test is marked failed and the test function stops immediately (internally by ending its goroutine). The rule of thumb: **`Fatalf` when continuing is meaningless or unsafe, `Errorf` otherwise.** If a constructor returned an error, or the call under test returned `(nil, err)`, continuing would dereference a nil pointer and turn a clear failure into a panic — that is `Fatalf`. If you are checking three independent properties of the outcome, `Errorf` each of them so one run tells you all three. ```go got, err := r.Reconcile(ctx, spec) if err != nil { t.Fatalf("Reconcile(%+v) error = %v, want nil", spec, err) } if got.DesiredCount != 3 { t.Errorf("Reconcile(...).DesiredCount = %d, want 3", got.DesiredCount) } ``` ## The goroutine rule, which mocks walk straight into `FailNow` — and therefore `Fatal`/`Fatalf` — is documented as callable **only from the goroutine running the test function**. Call it from a goroutine the test spawned and it ends that goroutine only; the test function keeps running and may well finish before anyone notices. This matters here because a mock's behaviour hook is frequently invoked from a background goroutine: a reconciler loop, a worker started by the code under test, a callback fired from a driver. Inside such a hook, do one of these: - call `t.Errorf` — the reporting methods are safe to call concurrently; or - send the problem back to the test goroutine on a buffered channel and let the test decide whether to `Fatalf`. One more hazard: reporting **after the test function has returned** panics with `Log in goroutine after Test... has completed`. A mock callback on a leaked goroutine that outlives the test will crash the whole test binary, which is a genuine signal — it means the code under test did not shut its goroutine down. Fix the lifetime rather than swallowing the report. ## Structuring many checks For table-driven tests, put each case in `t.Run(name, func(t *testing.T) { ... })`. Each subtest gets its own `*testing.T`, so a `Fatalf` inside one case stops that case only and the remaining rows still run — which is usually exactly what you want when several mock configurations are being exercised.

  • Why does a shared comparison helper call `t.Helper()` as its first line?
    Without it, every failure is reported at the line inside the helper, so all failures look identical and you cannot tell which caller failed. `t.Helper()` marks the function as a helper, and the testing package then attributes the failure to the calling test line instead. It must be called before any reporting for the attribution to apply.
  • A mock callback calls `t.Errorf` after the test function has already returned. What happens?
    The test binary panics with a message about logging in a goroutine after the test completed. That is a real defect signal rather than a testing quirk: it means the code under test left a goroutine running past the end of the test. Fix the shutdown or make the test wait for it before returning.
  • When comparing recorded call arguments, why can `reflect.DeepEqual` fail on values that look identical?
    `reflect.DeepEqual` distinguishes a nil slice or map from an empty non-nil one, and it compares unexported fields as well. A mock that records into a slice which was never appended to holds nil, while the expected value written as `[]Spec{}` is non-nil, so the comparison fails despite both having length zero.

saying these in an interview costs you the question

  • Uses t.Fatalf inside a mock callback on another goroutine
  • Prints want before got, or omits the call being described
  • Uses t.Logf for a failed check, leaving the test green
  • Thinks t.Fatalf aborts the whole test binary
  • Compares structs containing slices with ==, which does not compile
  • Forgets t.Helper, so every failure points at the helper
open as a page

What does a `//go:generate` line above an interface do, and when does that command actually run?

level: juniorimportance: should knowfreq 48%

basics

~20 s

It is an ordinary comment that the compiler ignores. Only an explicit go generate run scans source files for lines starting with //go:generate and executes the rest as a command in that package's directory. go build and go test never trigger it.

open as a page

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%

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.

open as a page

Your team must decide whether to generate mocks from interfaces or hand-write doubles. How do you make that call?

level: principalimportance: should knowfreq 33%

basics

~20 s

Decide on scale and churn, not taste. Generation pays when many wide interfaces change often; it costs a pinned tool dependency every contributor and CI must have, plus a CI check that regenerated output matches what is committed. Few narrow interfaces do not repay that.

open as a page