Skip to content

Testing: the errors go test stops on

Level: 201 · anyone whose go test printed [setup failed], [build failed] or exit status 2 and who wants to know which tool said so

One line: go test refuses a test package at three stages — while it loads the package (a TestX with the wrong signature), while go vet's subset runs (a fuzz target with an argument the engine cannot generate), and while the compiler builds it (an unexported name reached from the external test package) — and two more failures happen at run time: a panic ends the whole test binary with exit status 2, and a goroutine that fails a test after it has completed panics the binary with a message naming the test.

Each entry is a transcript from a bash driver that writes the broken test into a throwaway module and runs go test, keeping the exact wording of Go 1.25 so that a later Go that rewords it shows up as a diff. The absolute path go test prints for the first one is shortened to ./; the goroutine traces under the panics are dropped.

wrong signature for TestOnHand, must be: func TestOnHand(t *testing.T)

A test written with t testing.T instead of t *testing.T:

Verified output of testing_err_signature_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go mod init example
$ go test
# example
./stock_test.go:5:1: wrong signature for TestOnHand, must be: func TestOnHand(t *testing.T)
FAIL    example [setup failed]
exit status 1
$ go vet
./stock_test.go:5:1: wrong signature for TestOnHand, must be: func TestOnHand(t *testing.T)
exit status 1

The mistake. go test scans the _test.go files for functions named Test*, Benchmark*, Fuzz* and Example* before it builds anything, and a Test* whose signature is not func(*testing.T) stops it there — [setup failed], with the file's absolute path, and before the # [example] header that marks a vet finding. go vet's tests analyzer reports the same line with the same words, so the two agree; go test simply gets there first.

The fix.

func TestOnHand(t *testing.T) {

name onHand not exported by package stock and undefined: stock.Onhand (but have OnHand)

A test in the external package stock_test reaches for an unexported function, and misspells an exported one:

Verified output of testing_err_not_exported_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go mod init example
$ go test
# example_test [example.test]
./stock_test.go:10:11: name onHand not exported by package stock
./stock_test.go:13:11: undefined: stock.Onhand (but have OnHand)
FAIL    example [build failed]
exit status 1

The mistake. package stock_test is a separate package that imports stock like any client, so stock.onHand is not visible to it; the compiler's message says not exported rather than undefined because it can see the name exists. Onhand does not exist at all, and Go 1.25's compiler adds (but have OnHand) when a name differs only in case. The header # example_test [example.test] names the package being compiled: the external test package, inside the test binary.

The fix. Test unexported functions from a package stock test file, in the same directory — the two kinds of _test.go file can sit side by side — and keep the external package for tests of the exported API:

package stock // not stock_test: this file sees onHand

func TestOnHandUnexported(t *testing.T) {
    if onHand("WH-1") != 4 {

fuzzing arguments can only have the following types: … and the first parameter of a fuzz target must be *testing.T

Two fuzz targets: one takes a struct, one forgets the *testing.T:

Verified output of testing_err_fuzz_types_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go mod init example
$ go test
# example
# [example]
./stock_test.go:9:30: fuzzing arguments can only have the following types: string, bool, float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, []byte
./stock_test.go:13:18: the first parameter of a fuzz target must be *testing.T
FAIL    example [build failed]
exit status 1

The mistake. The fuzzing engine generates and mutates values of the listed types only — string, bool, the floats, the integers and []byte — and the target's first parameter is the *testing.T it reports through. Both checks are made by go vet's tests analyzer, which is in the subset go test runs, hence the # [example] header and [build failed]. Fuzzing finds the input you did not write shows what the engine does with the types it accepts.

The fix. Take the fields as separate arguments and build the struct inside the target:

func FuzzBin(f *testing.F) {
    f.Fuzz(func(t *testing.T, row, slot int) {
        b := Bin{Row: row, Slot: slot}

? example [no test files]

A package with no _test.go file:

Verified output of testing_err_no_test_files_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go mod init example
$ go test
?       example [no test files]
exit status 0
$ go test ./...
?       example [no test files]
exit status 0

The mistake. Not an error: go test reports the package with a ? and exits 0, for go test and for go test ./... alike. A CI step that watches only the exit status cannot tell an untested package from a tested one, and a _test.go file left out of a commit goes unnoticed the same way.

The fix. There is nothing to fix in the package; the check belongs in CI. go test ./... 2>&1 | grep '\[no test files\]' lists the packages without tests, and a coverage report (Coverage and the flags that choose tests) shows them at 0%.

panic: assignment to entry in nil map — and exit status 2

A test writes into a map it never made, and a second test follows it in the file:

Verified output of testing_err_panic_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go mod init example
$ go test -v
=== RUN   TestBins
--- FAIL: TestBins
panic: assignment to entry in nil map [recovered, repanicked]
exit status 2
FAIL    example
exit status 1

The mistake. The nil map is an ordinary Go bug; the testing lesson is what it does to the run. The testing package recovers the panic far enough to print --- FAIL: TestBins and the panic: line, then re-panics — [recovered, repanicked] — and the test binary exits 2. TestPallets never ran: there is no === RUN TestPallets. go test prints the binary's exit status 2 as a line of output and exits 1 itself. One panicking test hides every test after it, so the first thing to fix in a failing suite is any panic: line, before reading the assertion failures.

The fix. For the map, bins := map[string]int{}. For the suite, recover works only in the deferred call itself — a test that expects a panic recovers it in a deferred function of its own, and reports with t.Errorf rather than letting it reach the runner.

panic: Fail in goroutine after TestBins has completed

A goroutine started by TestBins calls t.Error after TestBins has returned — here made certain by waiting for the next test to start:

Verified output of testing_err_fail_after_complete_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go mod init example
$ go test -v
=== RUN   TestBins
--- PASS: TestBins
=== RUN   TestPallets
panic: Fail in goroutine after TestBins has completed
exit status 2
FAIL    example
exit status 1

The mistake. TestBins passed: it returned without failing, and the goroutine was still waiting. When the goroutine finally called t.Error, its *testing.T belonged to a test that had already been reported, and the testing package panics with the test's name (testing.go ↗) rather than fail a test that is over. The binary exits 2, and every test after it is lost — the === RUN TestPallets here is the only trace of the second test. The usual way to write this bug is a test that starts a worker and returns without waiting for it; the failure then appears, or not, depending on scheduling.

The fix. Wait for every goroutine the test starts before the test function returns — a sync.WaitGroup, or the worker's own done channel — and report from the test goroutine:

func TestBins(t *testing.T) {
    result := make(chan int)
    go func() { result <- countBins() }()
    if got := <-result; got != 12 {
        t.Errorf("bins = %d, want 12", got)
    }
}

go vet's testinggoroutine analyzer catches t.Fatal in a goroutine, and not t.Error — the vet page measures which.

Sources