Skip to content
gohacks.dev
Testing

Test Panics in Go and Assert on the Recovered Value

Test that Go code panics: a small recoverValue helper catches the value, then assert on a sentinel error, a runtime.Error for index and divide faults, or nil.

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

Most Go code returns errors, but panics still turn up: a generic helper that rejects an empty slice, a constructor that refuses a bad argument at start-up, a Must function. When the panic is part of the contract, a test should pin it down. The standard testing package has no assert.Panics, but the helper is four lines.

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

safe.goGo
package main

import (
	"errors"
	"fmt"
)

var ErrEmpty = errors.New("empty slice")

func First[T any](xs []T) T {
	if len(xs) == 0 {
		panic(ErrEmpty)
	}
	return xs[0]
}

func Index(xs []int, i int) int { return xs[i] }

func Div(a, b int) int { return a / b }

func Wrap(f func()) (err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("recovered: %v", r)
		}
	}()
	f()
	return nil
}

func main() {}

A helper that returns what was recovered

recover only works inside a deferred function in the goroutine that is panicking, so the helper wraps the call:

safe_test.goGo
package main

import (
	"errors"
	"runtime"
	"testing"
)

// recoverValue runs f and returns what it panicked with, or nil.
func recoverValue(f func()) (v any) {
	defer func() { v = recover() }()
	f()
	return nil
}

func TestFirstPanics(t *testing.T) {
	v := recoverValue(func() { First([]string{}) })
	err, ok := v.(error)
	if !ok || !errors.Is(err, ErrEmpty) {
		t.Fatalf("recovered %#v, want ErrEmpty", v)
	}
}

func TestRuntimePanicsAreErrors(t *testing.T) {
	v := recoverValue(func() { Index([]int{1, 2}, 5) })
	re, ok := v.(runtime.Error)
	if !ok {
		t.Fatalf("recovered %T, want runtime.Error", v)
	}
	t.Logf("%T: %v", re, re)

	v = recoverValue(func() { Div(1, 0) })
	t.Logf("%T: %v", v, v)
}

func TestNoPanic(t *testing.T) {
	if v := recoverValue(func() { First([]int{9}) }); v != nil {
		t.Fatalf("unexpected panic: %v", v)
	}
	t.Logf("Wrap: %v", Wrap(func() { panic("boom") }))
}
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
--- PASS: TestFirstPanics
    runtime.boundsError: runtime error: index out of range [5] with length 2
    runtime.errorString: runtime error: integer divide by zero
--- PASS: TestRuntimePanicsAreErrors
    Wrap: recovered: boom
--- PASS: TestNoPanic

recoverValue runs f and returns whatever recover gave back. It returns nil if f returned normally, because recover returns nil when there is no panic. One subtlety: since Go 1.21, panic(nil) makes the runtime panic with a *runtime.PanicNilError instead of recovering nil, so a nil result really does mean no panic.

Assert on the value, not just on the fact

Checking that something panicked is a weak test. A panic for the wrong reason also passes. So assert on what it carried.

First panics with ErrEmpty, an error value. The test type-asserts the recovered value to error and uses errors.Is, which survives wrapping, as covered in testing errors with Is and As. If the code panicked with a string, compare the string, and decide whether that wording is part of your contract.

Runtime faults are different. An out-of-range index and an integer division by zero are not values you panicked with; the runtime raised them, and they implement runtime.Error. The recorded run shows the types: runtime.boundsError with the message runtime error: index out of range [5] with length 2, and runtime.errorString with runtime error: integer divide by zero. Those concrete types are unexported, so assert on the runtime.Error interface and, if you need more, on the message. A fault inside the code under test that you did not intend is a bug, and seeing the type tells you which one.

And test the quiet case

TestNoPanic checks that First([]int{9}) returns without panicking, then logs Wrap, which uses the same recover idea in production code to convert a panic into an error: recovered: boom. Servers use this shape in middleware so one bad request does not take the process down. A table of cases for such a wrapper should check that the error carries the panic value and that the process kept running, and HTTP handler tests show the request side.

Notes

Do not use this to test code that panics inside a goroutine it started; the panic is not recoverable from the test's goroutine and will crash the binary. For that, recover inside the goroutine and report over a channel. Do not call t.FailNow inside the helper's deferred function after a recover; return the value and let the test decide. And prefer returning errors in new code, saving panics for programmer mistakes. The language rules are in the Go specification, and the runtime error types are listed at runtime.Error.

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.