Skip to content
gohacks.dev
Testing

Test Errors in Go With errors Is and errors As Properly

Assert on Go errors the right way: errors.Is for sentinels, errors.As for typed errors with data, errors.Join for several, and never compare error strings.

7 min read
Summarize in AI
Flat illustration of a test report with one failing test.

A test that checks an error by comparing its message breaks the first time someone improves the wording. A test that compares with == breaks the first time someone wraps the error with context. Go has had the right tools since version 1.13, and most error tests in the wild still do not use them.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article builds a small payments example and tests it three ways. It follows the habits from table driven tests and it applies equally to the panic values in testing panics. The runs are on Go 1.25.6.

Two kinds of error

pay.goGo
package main

import (
	"errors"
	"fmt"
)

var ErrDeclined = errors.New("card declined")

// LimitError carries data a caller can act on.
type LimitError struct {
	Limit, Asked int
}

func (e *LimitError) Error() string {
	return fmt.Sprintf("amount %d is over the limit of %d", e.Asked, e.Limit)
}

func Charge(amount int) error {
	switch {
	case amount > 500:
		return &LimitError{Limit: 500, Asked: amount}
	case amount == 13:
		return ErrDeclined
	}
	return nil
}

func Checkout(user string, amount int) error {
	if err := Charge(amount); err != nil {
		return fmt.Errorf("checkout for %s: %w", user, err)
	}
	return nil
}

func main() {}

ErrDeclined is a sentinel: a package-level variable that stands for one condition. Callers check for it by identity. LimitError is a typed error: a struct that implements Error() and carries data. Callers check for the type and read the fields, here to tell the user what the limit was.

Checkout wraps whatever Charge returned with fmt.Errorf("checkout for %s: %w", ...). The %w verb keeps the original error reachable. That one line is why == is wrong.

What the tests assert

pay_test.goGo
package main

import (
	"errors"
	"testing"
)

func TestCheckout(t *testing.T) {
	t.Run("sentinel through a wrap", func(t *testing.T) {
		err := Checkout("ana", 13)
		if err == ErrDeclined {
			t.Error("== matched a wrapped error, which it should not")
		}
		if !errors.Is(err, ErrDeclined) {
			t.Fatalf("errors.Is failed for %v", err)
		}
		t.Logf("message: %v", err)
	})

	t.Run("typed error carries data", func(t *testing.T) {
		err := Checkout("ana", 900)
		var le *LimitError
		if !errors.As(err, &le) {
			t.Fatalf("no *LimitError in %v", err)
		}
		if le.Limit != 500 || le.Asked != 900 {
			t.Errorf("got %+v", le)
		}
	})

	t.Run("no error", func(t *testing.T) {
		if err := Checkout("ana", 5); err != nil {
			t.Fatal(err)
		}
	})

	t.Run("joined errors", func(t *testing.T) {
		err := errors.Join(Checkout("a", 13), Checkout("b", 900))
		var le *LimitError
		if !errors.Is(err, ErrDeclined) || !errors.As(err, &le) {
			t.Fatalf("join lost a branch: %v", err)
		}
		t.Logf("joined:\n%v", err)
	})
}
Shell
go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|=== PAUSE|=== CONT|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: #    #'
Text
    message: checkout for ana: card declined
    joined:
        checkout for a: card declined
        checkout for b: amount 900 is over the limit of 500
--- PASS: TestCheckout
    --- PASS: TestCheckout/sentinel_through_a_wrap
    --- PASS: TestCheckout/typed_error_carries_data
    --- PASS: TestCheckout/no_error
    --- PASS: TestCheckout/joined_errors

Sentinels: errors.Is. The first subtest checks both comparisons on purpose. err == ErrDeclined is false, because err is the *fmt.wrapError that wraps it, and the test would report an error if it were true. errors.Is(err, ErrDeclined) walks the chain of wrapped errors, comparing each with the target, and finds it. The logged message, checkout for ana: card declined, is for humans. The test never depends on it.

Typed errors: errors.As. The second subtest declares var le *LimitError and passes &le. errors.As walks the chain until it finds an error assignable to the target's type, assigns it and returns true. The test then asserts on Limit and Asked. This is the strongest kind of error assertion because it checks the data a caller would act on.

The target must be a non-nil pointer to a type that implements error, or to an interface type. I tried passing &le where le was a LimitError value, while the function returns a *LimitError, and go test stopped before running anything: go vet, which go test runs by default, printed second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type. That was the build output of the first draft of the lab, and it is the useful kind of failure: found before the test ran. Without the vet check, the call would panic at run time.

Match the receiver in the type. Error() is defined on *LimitError, so the value that flows through the program is a pointer, and the target is a pointer to a pointer. That **LimitError shape confuses people at first; read var le *LimitError; errors.As(err, &le) as "find me a *LimitError and put it in le".

Several errors: errors.Join. The last subtest joins two checkout failures. The joined error answers errors.Is(err, ErrDeclined) and errors.As(err, &le) for different branches, and its message is the two messages on separate lines, as the log shows. If your code collects validation errors with errors.Join or returns several from a goroutine group, the same assertions work.

The regression these tests catch

The most common error bug in review is a verb change. Someone edits %w to %v while tidying a format string, and the message looks identical. The text checkout for ana: card declined is the same, but the error no longer wraps anything: errors.Is and errors.As now return false, and a handler three layers up that used to answer "payment declined" with a 402 starts answering 500. Nothing in the diff looks dangerous. A string comparison test would still pass, which is the strongest argument against asserting on text. A test written with errors.Is fails on that commit, in the right place, and names the sentinel that went missing.

The same is true one level down. If a function that opens a file returns the error from os.Open through your own wrapper, a test with errors.Is(err, fs.ErrNotExist) documents that callers may rely on that condition. The in-memory file system tests use exactly that check, with a wrapper that injects fs.ErrPermission. Standard library errors work this way throughout: context.DeadlineExceeded, io.EOF, sql.ErrNoRows and os.ErrNotExist are all designed to be matched with errors.Is, and network errors expose Timeout() through errors.As on net.Error.

One more habit helps. When a function documents that it returns a particular error, add a test whose only purpose is to hold that promise, named for the promise, such as TestChargeReturnsErrDeclinedWhenAmountIs13. Months later, when someone changes the implementation, that name is what they read in the failure output, and it tells them which behaviour callers depend on.

When the message is the test

Sometimes the text is the contract: a CLI error printed to a user, an HTTP error body, a log line. Then compare it, and say so. For a package-level sentinel the cleanest assertion is still errors.Is, plus one check on err.Error() where the wording matters. Use strings.Contains for the part that matters rather than freezing a whole sentence with a path in it.

Table tests with a want error

In a table, use a field named wantErr of type error and compare with errors.Is(err, tt.wantErr). When wantErr is nil, check err == nil explicitly, since errors.Is(nil, nil) is true but errors.Is(err, nil) is false for a real error, which makes the logic less obvious than two clear branches:

Go
if tt.wantErr == nil {
	if err != nil { t.Fatalf("unexpected error: %v", err) }
	return
}
if !errors.Is(err, tt.wantErr) {
	t.Fatalf("err = %v, want %v", err, tt.wantErr)
}

For typed errors use a second field, a function or a target type per row, rather than trying to fit both into one. Keep each assertion in the shape that reads easiest.

Design for testable errors

If a caller must branch on a failure, export the sentinel or the type, and document it. If the failure is an implementation detail, do not export it, and tests in the same package can still check it. Avoid errors.New("...") inline at the return site for errors a test must recognise; there is nothing to compare against except the string. And write Is(target error) bool or Unwrap() error methods on your own types only when you have a reason, for instance to make a custom type match a sentinel.

The package documentation at pkg.go.dev/errors defines the chain rules, and the Go blog post Working with Errors in Go 1.13 explains the design behind Is, As and %w.

Also inBasics
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.