Skip to content

Testing: what go vet catches

Level: 201 · anyone who assumed go test runs go vet

One line: go test runs a high-confidence subset of go vet before building the test binary — atomic, bool, buildtags, directive, errorsas, ifaceassert, nilfunc, printf, stringintconv and tests — so a malformed test name or a wrong t.Errorf verb fails the build; testinggoroutine, waitgroup and copylocks are not in that subset, and the mistakes they catch pass go test until someone runs go vet.

The subset is documented in go help test ↗: as part of building a test binary, go test runs go vet on the package and its test files, and only a high-confidence subset of the default checks is used; -vet=off disables it and -vet=all runs every analyzer. Each entry below runs a bad test file through go vet, and through go test where the difference matters.

tests: malformed names, and examples of things that do not exist

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

$ go mod init example
$ go vet
# example
# [example]
./stock_test.go:9:6: TestonHand has malformed name: first letter after 'Test' must not be lowercase
./stock_test.go:15:6: BenchmarkonHand has malformed name: first letter after 'Benchmark' must not be lowercase
./stock_test.go:22:1: ExampleOnHand_Bad refers to unknown field or method: OnHand.Bad
exit status 1
$ go test
# example
# [example]
./stock_test.go:9:6: TestonHand has malformed name: first letter after 'Test' must not be lowercase
./stock_test.go:15:6: BenchmarkonHand has malformed name: first letter after 'Benchmark' must not be lowercase
./stock_test.go:22:1: ExampleOnHand_Bad refers to unknown field or method: OnHand.Bad
FAIL    example [build failed]
exit status 1
$ go test -vet=off -v
=== RUN   ExampleOnHand_Bad
--- PASS: ExampleOnHand_Bad
PASS
ok      example
exit status 0

The warning means that TestonHand and BenchmarkonHand will never run: the letter after Test or Benchmark must be uppercase for go test to treat the function as a test, so a lowercase one is an ordinary function that happens to be in a _test.go file. ExampleOnHand_Bad names a method Bad on a function, which cannot exist; An Example test runs its // Output: lists the naming rules. The analyzer is in go test's subset, so go test reports the same three lines and [build failed].

The good version — TestOnHand, BenchmarkOnHand, and ExampleOnHand_bad if a second example of OnHand is wanted:

func TestOnHand(t *testing.T) {
func BenchmarkOnHand(b *testing.B) {
func ExampleOnHand_bad() {

When the tool is right, and when it is noise. Always right about a name: a test with a malformed name is a test that does not run, and the third command shows what -vet=off buys — the example runs, the two mis-named functions are silently ignored, and the package is ok. The one reason to reach for -vet=off is a vet finding in a dependency's test files that block a go test ./...; fix the name instead.

printf: a wrong verb in t.Errorf, arguments to t.Log, a verb in t.Error

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

$ go mod init example
$ go test
# example
# [example]
./stock_test.go:8:26: (*testing.common).Errorf format %s has arg got of wrong type int
./stock_test.go:10:20: (*testing.common).Logf call has arguments but no formatting directives
./stock_test.go:11:2: (*testing.common).Error call has possible Printf formatting directive %q
FAIL    example [build failed]
exit status 1

The warning means that %s was given an int, that t.Logf was given arguments and no verbs to place them, and that t.Error — no f — was given a format string it will print literally, %q and all. The analyzer knows the testing methods by name: Errorf, Fatalf, Logf, Skipf take a format, Error, Fatal, Log, Skip do not. In the subset, so go test refuses to build.

The good version:

t.Errorf("OnHand(%q) = %d, want 4", "WH-1", got)
t.Log("checked", got)
t.Errorf("OnHand(%q) = %d", "WH-1", got)

When the tool is right, and when it is noise. Right every time in a test: the message is the only thing a reader of a failing test gets, and %!s(int=4) in it wastes their first minute. It is noise only for a function of your own that takes a format string, which vet does not know about unless its name ends in f and it is called with a constant format — the analyzer infers printf-like functions by their wrappers.

testinggoroutine: t.Fatal from a goroutine the test started

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

$ go mod init example
$ go vet
# example
# [example]
./stock_test.go:15:5: call to (*testing.T).Fatalf from a non-test goroutine
exit status 1
$ go test
PASS
ok      example
exit status 0

The warning means that t.Fatalf inside the go func() calls FailNow, which ends the calling goroutine with runtime.Goexit — the worker, not the test. The test goroutine goes on, and if the worker was meant to call wg.Done after the failing line, wg.Wait hangs until the test times out. The analyzer is not in go test's subset: the second command passes, on a run where OnHand is never zero.

What it did not report. TestOnHandWithWgGo has the same t.Fatalf inside a function passed to wg.Go, Go 1.25's WaitGroup.Go ↗, and vet said nothing: the analyzer looks for go statements and the goroutine starters it knows, and wg.Go is not among them in this Go. The mistake is the same.

The good version — report from the goroutine with t.Error, or send the result back and decide on the test goroutine:

go func() {
    defer wg.Done()
    if OnHand(sku) == 0 {
        t.Errorf("OnHand(%q) = 0", sku) // Error, not Fatal: marks the test failed and returns
    }
}()

When the tool is right, and when it is noise. Right whenever it fires; the only false sense of safety is the silence around wg.Go and around t.Error after the test has returned, which is the last entry of the errors page. A -race run — The race detector — catches the second when it happens.

waitgroup: wg.Add inside the goroutine it counts

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

$ go mod init example
$ go vet
# example
# [example]
./stock_test.go:14:10: WaitGroup.Add called from inside new goroutine
exit status 1

The warning means that wg.Add(1) runs on the new goroutine, and wg.Wait() on the test goroutine may run first, see a counter of zero and return — the test then checks a total the workers have not finished adding to. New in Go 1.25 (release notes, vet ↗), not in go test's subset, and the test passes on a run where the Add happens to come first. A WaitGroup counts goroutines shows the lost increment; -race reports the racing total.

The good version — Add before the go statement, or wg.Go, which does the Add for you:

for _, sku := range []string{"WH-1", "WH-22"} {
    wg.Go(func() {
        mu.Lock()
        total += OnHand(sku)
        mu.Unlock()
    })
}
wg.Wait()

When the tool is right, and when it is noise. Right: there is no correct program in which the Add for a goroutine happens inside it, because nothing then orders the Add before the Wait.

copylocks: a testing.T passed by value

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

$ go mod init example
$ go vet
# example
# [example]
./stock_test.go:6:14: check passes lock by value: testing.T contains testing.common contains sync.RWMutex
./stock_test.go:13:8: call of check copies lock value: testing.T contains testing.common contains sync.RWMutex
exit status 1
$ go test -v
=== RUN   TestOnHand
    stock_test.go:8: OnHand("WH-1") = 4, want 5
--- PASS: TestOnHand
FAIL
exit status 1
FAIL    example
exit status 1

The warning means that check(t testing.T, ...) receives a copy of the T, which contains a sync.RWMutex, and *t at the call site makes that copy. The consequence is the transcript's second half: the copy's t.Errorf marks the copy failed and, through the parent the copy shares with the real T, the package — so go test prints FAIL and exits 1 — but the real TestOnHand is reported --- PASS, and without -v the message is not printed at all (measured, not shown). Not in go test's subset.

The good version — a pointer, and t.Helper() so that the failure is reported at the caller's line:

func check(t *testing.T, sku string, want int) {
    t.Helper()
    if got := OnHand(sku); got != want {

When the tool is right, and when it is noise. Right for testing.T, sync.Mutex, sync.WaitGroup and anything that embeds one; the report names the chain — testing.T contains testing.common contains sync.RWMutex. It is noise only for a value that is copied before it is ever locked, which vet cannot tell from one that is not.

What no tool catches

  • A test that asserts nothing. A test that calls the function and returns passes, reaches 100% coverage, and checks nothing; Coverage and the flags that choose tests shows why the percentage cannot tell. The check is to make the function wrong on purpose once and watch the test fail.
  • t.Parallel on tests that share a global. Two parallel tests that read and write one package-level variable compile, vet clean, and pass most of the time; Parallel subtests wait, Cleanup runs last sets out the rule, and only -race sees the sharing.
  • A -run pattern that matches nothing. go test -run TestInvoice when there is no such test prints testing: warning: no tests to run and exits 0 — the transcript is in Coverage and the flags that choose tests — so a CI job with a misspelt pattern stays green. -skip 'empty' without a slash is the same silence.
  • testing.Short guarding the only real test. A package whose one substantive test skips itself under -short reports ok for a run of nothing; the SKIP line is visible only with -v.

staticcheck ↗ adds checks such as a t.Run whose subtest never uses its t, and golangci-lint ↗ bundles thelper, tparallel and paralleltest for the parallel rules above; neither runs in this library.

Sources