In a Go test, how do you assert that a call returned a particular error?
answer
- assert identity, not prose
- wrapping must not break the test
- one call matches a sentinel, another a type
- a nil error never matches a non-nil target
- err != nil alone pins nothing
basics
~20 sUse errors.Is against a sentinel error, or errors.As with a typed error when the test needs a field off it. Both see through wrapping. Comparing err.Error() text is brittle, and checking only that err is non-nil pins nothing.
solid answer
~50 sAssert on identity, not on prose. For a sentinel, `if !errors.Is(err, ErrUnknownJurisdiction)` is the whole assertion - it matches through any wrapping the library added, and because `errors.Is(nil, target)` is false for a non-nil target, one check also catches the case where no error came back at all. When the test needs to inspect the error, declare a variable of the concrete type and use `errors.As` to fill it, then assert on its fields. Two things to avoid: comparing `err.Error()` against a fixed string, because the message is prose that gets reworded and two unrelated failures can share text; and asserting only `err != nil`, which passes when the function fails for a completely different reason, including a bug you just introduced. On the happy path, check `err != nil` with `t.Fatalf` before touching the result, so later assertions do not run against a zero value.
code
go · 3 linesif _, err := Apply(order, "XX"); !errors.Is(err, ErrUnknownJurisdiction) {
t.Errorf("Apply(order, %q) error = %v, want %v", "XX", err, ErrUnknownJurisdiction)
}go deeper
Know the two calls and when each applies: errors.Is when you want a specific sentinel error, errors.As when the test needs to read a field off a typed error. Do not compare error text.
Explain why identity beats prose - wrapping, embedded detail, shared text - and point out that errors.Is against a non-nil target already fails when no error came back, so no extra nil guard is needed.
In review, treat a bare err != nil check on a case about a specific failure as unfinished, and be able to say whether the missing piece is the assertion or a sentinel the library never exported.
Decide what a shared library promises about its failures: which sentinels and error types are exported, and whether any message text is contract. Tests are where that promise is demonstrated to every team that imports you.
## What "the right error" means in an assertion A Go function returns an error value, and a test has to decide what it is really asserting about it. There are four distinct claims, and they are not interchangeable: 1. **No error occurred.** 2. **Some error occurred.** 3. **This specific sentinel error occurred**, possibly wrapped inside other context. 4. **An error of this type occurred**, and its fields say something specific. Most weak error assertions are a case of asserting (2) while intending (3). ## The happy path Check it and stop: ```go got, err := Apply(order, "DE") if err != nil { t.Fatalf("Apply(order, %q) unexpected error: %v", "DE", err) } ``` `t.Fatalf` rather than `t.Errorf` because everything after this line reads `got`, and `got` is a zero value when the call failed. Continuing produces a cascade of secondary failures that bury the real one. ## Asserting a sentinel A sentinel is a package-level error value that callers are meant to recognise. The assertion is `errors.Is`: ```go if _, err := Apply(order, "XX"); !errors.Is(err, ErrUnknownJurisdiction) { t.Errorf("Apply(order, %q) error = %v, want %v", "XX", err, ErrUnknownJurisdiction) } ``` Two properties make this the right shape. It matches even when the library added context on the way out, so the test does not break the day someone adds detail to the message. And `errors.Is(nil, target)` is false for a non-nil target, so this single check also fails when the call unexpectedly succeeded - you do not need a separate `err == nil` guard. Using `err == ErrUnknownJurisdiction` instead is the classic mistake: it works today and breaks silently the moment the error is wrapped anywhere in the call chain. ## Asserting a typed error When the test cares about *what* was wrong - which field, which line item, which amount - declare a variable of the concrete error type and let `errors.As` fill it: ```go var verr *ValidationError if !errors.As(err, &verr) { t.Fatalf("Apply(order) error = %v, want a *ValidationError", err) } if verr.Field != "Amount" { t.Errorf("ValidationError.Field = %q, want %q", verr.Field, "Amount") } ``` The two-step shape matters: `Fatalf` on the type check, because the field assertion below would dereference a nil pointer otherwise. ## Why not compare the message `if err.Error() != "unknown jurisdiction: XX"` is tempting because it is one line and reads like the failure a user would see. It is a poor assertion: - Error messages are **prose**. Someone improves the wording, twenty tests go red, and the behaviour never changed. - Messages often embed **varying detail** - an id, a file path, a duration - so the test either becomes brittle or grows a substring match that no longer pins much. - **Two unrelated failures can produce the same text**, so a green assertion is not evidence the intended path ran. - The text is **not part of the contract** unless you have said it is. There is a real exception: when the message genuinely is the product - a command-line tool's output, an error body an API returns - assert it, once, in the test that owns that output. Not in every unit test that happens to be near an error. ## The assertion that pins nothing ```go if err == nil { t.Fatal("expected an error") } ``` This is honest but weak. It passes if the function fails for any reason at all, including a nil dereference you introduced in the line above the validation you meant to test. Reviewers should treat a bare non-nil check on a case whose whole point is *which* error as an unfinished assertion: either the package has no sentinel to match, in which case the gap is in the library's API rather than the test, or the test author skipped a step. ## Putting it together For a library other teams import, the error values are part of the exported surface, and the tests are where you demonstrate that. A case that asserts `errors.Is(err, ErrUnknownJurisdiction)` is simultaneously a test and a worked example of how a consumer is supposed to branch on the failure - which is a good reason to write it that way even when a cruder check would go green.
- Why not compare err.Error() against a fixed string?Because the message is prose, not contract. Rewording it turns tests red without any behaviour change, messages often embed varying detail such as an id or a path, and two unrelated failures can produce identical text, so a green assertion proves less than it looks. The exception is output that genuinely is the product - a command's stderr, an API error body - which should be asserted once, in the test that owns it.
- A case only asserts that err is not nil. What would you say in review?That it passes for the wrong reasons. Any failure satisfies it, including a nil dereference introduced above the validation the case is about. Ask which specific failure the case is pinning and assert that with `errors.Is` or `errors.As`. If the package exposes nothing to match against, the gap is in the library's exported surface, not in the test.
- Why use t.Fatalf rather than t.Errorf when an unexpected error comes back?Because the assertions after it read a result the call never produced. Continuing on a zero-value struct generates a cascade of secondary failures that bury the one line explaining what actually went wrong, and someone reading the CI log has to work out which failure was the cause. Fatal on the guard, Error on the independent checks that follow it.
saying these in an interview costs you the question
- Asserts on err.Error() text, so rewording a message breaks the test
- Compares errors with == and misses a wrapped error
- Checks only that err is non-nil and calls the case covered
- Continues asserting on a result after an unexpected error
- Treats an error message as part of the library's contract without saying so