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.

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)
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:
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") }))
}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]+: # #'--- 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: TestNoPanicrecoverValue 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.


