Test Error Paths in Go Properly Not Just the Happy Path
Test the failure branches of Go code: inject failing readers and stores, assert with errors.Is and As, check wrapping and cleanup, and cover partial failures.

Look at the coverage report for a typical Go package and find the lines that are red. Most of them are return err inside an if err != nil. The happy path is exercised by every test that touches the function, and the error branches by none, because producing a failure takes effort and the effort is easy to skip. Those branches are also the ones that run during an outage, when everything else is going wrong, and a bug there turns an incident into a longer one: an error that is swallowed, a mutex that stays locked, a file left half-written.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article shows how to make the failures happen on demand and how to check the results. It belongs to the error handling guide, and it uses the tools from Is and As and custom error types. Everything ran on Go 1.25.6.
Make failure injectable
You cannot test a branch that you cannot reach. A function that calls os.ReadFile directly can only fail if the file system does, which is not something a test should arrange. The fix is the same as for any dependency: take it as a parameter, usually an interface, and pass a fake that fails when told to.
For readers and writers the standard library has done the work. The testing/iotest package includes ErrReader, which returns a reader that fails every read with an error you choose:
package main
import (
"bufio"
"fmt"
"io"
"strings"
)
type Settings map[string]string
// Parse reads key=value lines from r.
func Parse(r io.Reader) (Settings, error) {
s := Settings{}
sc := bufio.NewScanner(r)
for n := 1; sc.Scan(); n++ {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
return nil, fmt.Errorf("line %d: missing '='", n)
}
s[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("read settings: %w", err)
}
return s, nil
}Parse has two kinds of failure: bad content, which a string reader can produce, and a failing read, which needs iotest.ErrReader. Both get a test.
package main
import (
"errors"
"strings"
"testing"
"testing/iotest"
)
func TestParse(t *testing.T) {
boom := errors.New("disk on fire")
tests := []struct {
name string
input func() *strings.Reader
wantErr string
}{
{"ok", func() *strings.Reader { return strings.NewReader("a=1\nb = 2\n") }, ""},
{"missing equals", func() *strings.Reader { return strings.NewReader("a=1\nbroken\n") }, "line 2"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := Parse(tc.input())
if tc.wantErr == "" {
if err != nil || got["b"] != "2" {
t.Fatalf("got %v, %v", got, err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("err = %v, want it to mention %q", err, tc.wantErr)
}
})
}
t.Run("read failure is wrapped and reachable", func(t *testing.T) {
_, err := Parse(iotest.ErrReader(boom))
if !errors.Is(err, boom) {
t.Fatalf("errors.Is(err, boom) = false; err = %v", err)
}
if !strings.HasPrefix(err.Error(), "read settings:") {
t.Errorf("missing context: %v", err)
}
})
}=== RUN TestParse
=== RUN TestParse/ok
=== RUN TestParse/missing_equals
=== RUN TestParse/read_failure_is_wrapped_and_reachable
--- PASS: TestParse (0.00s)
--- PASS: TestParse/ok (0.00s)
--- PASS: TestParse/missing_equals (0.00s)
--- PASS: TestParse/read_failure_is_wrapped_and_reachable (0.00s)
PASS
ok example.test/lab 1.249sThe last subtest is the one that matters. It proves that when the underlying read fails, Parse returns an error (not a half-filled map), adds context, and keeps the cause reachable through errors.Is. If someone later changes %w to %v, this fails, which is exactly the regression that is otherwise invisible until a caller's errors.Is stops working in production.
Fake the dependencies that fail
For your own interfaces, write a small fake whose behavior the test controls. A pattern that scales: a struct with a field for the error to return and, where useful, a record of calls.
package main
import (
"context"
"fmt"
)
type Store interface {
Save(ctx context.Context, key, value string) error
}
type Notifier interface {
Notify(ctx context.Context, msg string) error
}
// Publish saves the value, then notifies. A failed save must not notify.
func Publish(ctx context.Context, s Store, n Notifier, key, value string) error {
if err := s.Save(ctx, key, value); err != nil {
return fmt.Errorf("publish %s: save: %w", key, err)
}
if err := n.Notify(ctx, "saved "+key); err != nil {
return fmt.Errorf("publish %s: notify: %w", key, err)
}
return nil
}package main
import (
"context"
"errors"
"testing"
)
type fakeStore struct{ err error }
func (f fakeStore) Save(context.Context, string, string) error { return f.err }
type fakeNotifier struct {
err error
calls int
}
func (f *fakeNotifier) Notify(context.Context, string) error {
f.calls++
return f.err
}
func TestPublishFailures(t *testing.T) {
errSave := errors.New("save failed")
errNotify := errors.New("notify failed")
tests := []struct {
name string
store fakeStore
notifier *fakeNotifier
wantErr error
wantNotify int
}{
{"success", fakeStore{}, &fakeNotifier{}, nil, 1},
{"save fails, nobody is notified", fakeStore{errSave}, &fakeNotifier{}, errSave, 0},
{"notify fails after save", fakeStore{}, &fakeNotifier{err: errNotify}, errNotify, 1},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := Publish(context.Background(), tc.store, tc.notifier, "k", "v")
if !errors.Is(err, tc.wantErr) && tc.wantErr != nil {
t.Errorf("err = %v, want %v", err, tc.wantErr)
}
if tc.wantErr == nil && err != nil {
t.Errorf("unexpected error: %v", err)
}
if tc.notifier.calls != tc.wantNotify {
t.Errorf("notify calls = %d, want %d", tc.notifier.calls, tc.wantNotify)
}
})
}
}One row per step that can fail, and each row asserts two things: the cause comes back through errors.Is, and the side effects are what they should be. The second row is the valuable one. It proves that a failed save does not trigger a notification, a rule that matters to the business and one that nobody would notice breaking until a user got an email about data that was never stored. Counting calls on the fake is how you check it.
Assert on identity, not text
Whenever a test compares err.Error() to a string, it breaks the day someone adds a wrapper or fixes a typo. Compare through errors.Is for sentinels and errors.As for types, as above, and reserve substring checks for the cases where the text itself is the behavior, such as a message shown to a user. When you need the type's data, extract it:
var ve *ValidationError
if !errors.As(err, &ve) || ve.Field != "email" {
t.Fatalf("want a validation error for email, got %v", err)
}That test survives any change to wording or wrapping, and fails when the field is wrong, which is the point.
Fail on the Nth call
Some bugs only show when the failure comes after some success: the third write of five, the second retry, the last page of results. A fake that fails on a chosen call number reaches them. It is a counter and a comparison:
package main
import (
"context"
"errors"
"fmt"
"testing"
)
type failNth struct {
n, calls int
err error
}
func (f *failNth) Save(context.Context, string, string) error {
f.calls++
if f.calls == f.n {
return f.err
}
return nil
}
func TestBatchStopsAtFirstFailure(t *testing.T) {
errDisk := errors.New("disk full")
store := &failNth{n: 3, err: errDisk}
notifier := &fakeNotifier{}
var done int
var err error
for i := range 5 {
if err = Publish(context.Background(), store, notifier, fmt.Sprint("k", i), "v"); err != nil {
break
}
done++
}
if !errors.Is(err, errDisk) {
t.Fatalf("err = %v, want disk full", err)
}
if done != 2 || notifier.calls != 2 {
t.Errorf("done = %d, notifications = %d, want 2 and 2", done, notifier.calls)
}
}=== RUN TestBatchStopsAtFirstFailure
--- PASS: TestBatchStopsAtFirstFailure (0.00s)
=== RUN TestPublishFailures
=== RUN TestPublishFailures/success
=== RUN TestPublishFailures/save_fails,_nobody_is_notified
=== RUN TestPublishFailures/notify_fails_after_save
--- PASS: TestPublishFailures (0.00s)
--- PASS: TestPublishFailures/success (0.00s)
--- PASS: TestPublishFailures/save_fails,_nobody_is_notified (0.00s)
--- PASS: TestPublishFailures/notify_fails_after_save (0.00s)
PASS
ok example.test/lab 1.228sTwo items succeeded, the third failed, the loop stopped, and exactly two notifications went out. Without the counting fake, the only way to reach that state would be a real disk filling up. The same trick covers retries (fail twice, then succeed), pagination (fail on the last page) and anything where the order of events matters.
Name rows after the failing step
A table with rows called "case 1", "case 2" tells you nothing when one fails. Name each row after the step that breaks or the behavior it proves: "save fails, nobody is notified", "read failure is wrapped and reachable". The failure output then reads like a sentence, and a reviewer can scan the names to see which failures have a test and which are missing. If you cannot name a row without saying "and", it is probably two tests.
Check what a failure leaves behind
A function that fails halfway can leave damage: a partly written file, a held lock, an open connection, a goroutine still running. After the failing call, check the world. For files, look in t.TempDir() for stray output. For locks, try to take the lock again with a short timeout. For goroutines, compare counts or use a leak detector (goleak). The test that a failure left things clean is the one that finds the defer someone forgot to write.
Cover partial failures in batches
Batch operations have a space of outcomes larger than pass or fail. A test for a function that processes ten items should include none failing, all failing, and one in the middle failing, and assert on both what succeeded and what was reported (collecting multiple errors). The middle case catches an early return where a continue was meant.
Panics and cancellation
Two more failure shapes deserve rows in the table. A function documented to panic on misuse gets a test using a recover helper (recover has one). A function that takes a context gets a test with an already-cancelled context, which should return promptly with context.Canceled and not start the work. Both are cheap to write and both catch real bugs.
Use coverage as a map
Run go test -coverprofile=c.out and go tool cover -html=c.out, and look at the error branches. Red lines are untested failures. The aim is not 100% but that each if err != nil in code you care about has a test that reaches it, and the report shows you which do not. For the testing tools used here, see the testing/iotest package and the testing package.


