Go's `testing` package has no assertion API — how do you check a mock's recorded calls, and when is `t.Fatalf` wrong?
answer
- the standard library gives you if
- two words, in a fixed order
- one keeps going, one stops
- stopping only works on one goroutine
- a helper should confess it is one
basics
~20 sYou 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 sGo'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 linesgot, 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
Know the shape by heart: an if, then t.Errorf with got printed before want. Know that t.Logf alone leaves the test passing.
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.
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.
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