Go: Under the Hood Under the Hood
中文

16.4 Testing Code

In many languages the test framework comes from the community, and different projects may use different frameworks, assertion libraries and runners; before running the tests of an unfamiliar project, one often has to work out which set it uses. Go puts testing in the toolchain: go test is a subcommand of the go command, testing is a package in the standard library, and only a few naming conventions connect the two. This section first explains how go test organizes tests into an executable program according to those conventions. It then discusses the design choices of the testing package: why the reporting primitives are so plain, how subtests solve parameterization and parallelism together, and how fuzzing brings coverage-guided input generation under the same conventions. Finally it outlines the additions made to the testing package in recent releases.

16.4.1 From Conventions to an Executable Program

go test discovers tests from file names and function signatures: a file ending in _test.go is compiled only when testing, and within it a function of the form func TestXxx(*testing.T) is a test, func BenchmarkXxx(*testing.B) a benchmark, func FuzzXxx(*testing.F) a fuzz test, and func ExampleXxx() an example; if an example function ends with a comment beginning with Output:, the function’s standard output is compared against it at run time [Go Authors, n.d.a]. There is no configuration file and no annotation [Go Authors, n.d.d]. Below is a function that reverses a string byte by byte, together with its tests; the examples in this section all use it as the subject. A byte-wise Reverse is also the example used by the official fuzzing tutorial [Go Authors, n.d.e]:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package strutil

// Reverse reverses a string byte by byte.
func Reverse(s string) string {
	b := []byte(s)
	for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
		b[i], b[j] = b[j], b[i]
	}
	return string(b)
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package strutil

import (
	"testing"
	"unicode/utf8"
)

func TestReverse(t *testing.T) {
	tests := []struct {
		name, in, want string
	}{
		{"empty", "", ""},
		{"single", "a", "a"},
		{"ascii", "abc", "cba"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			if got := Reverse(tt.in); got != tt.want {
				t.Errorf("Reverse(%q) = %q, want %q", tt.in, got, tt.want)
			}
		})
	}
}

func FuzzReverse(f *testing.F) {
	f.Add("hello")
	f.Fuzz(func(t *testing.T, s string) {
		rev := Reverse(s)
		if utf8.ValidString(s) && !utf8.ValidString(rev) {
			t.Errorf("Reverse(%q) = %q, not valid UTF-8", s, rev)
		}
	})
}

How do the conventions become a runnable program? go test generates a main package for the package under test, registers the test functions it found in tables, and links it together with the package under test and the test files into a test binary, which it then runs. The template for the generated code is in cmd/go/internal/load/test.go [Go Authors, 2026a]; running go test -work with Go 1.26.10 shows the files generated for the package above in the work directory, here with the blank lines left by the template removed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Code generated by 'go test'. DO NOT EDIT.

package main

import (
	"os"
	"testing"
	"testing/internal/testdeps"
	_test "example.com/strutil"
)

var tests = []testing.InternalTest{
	{"TestReverse", _test.TestReverse},
}

var benchmarks = []testing.InternalBenchmark{
}

var fuzzTargets = []testing.InternalFuzzTarget{
	{"FuzzReverse", _test.FuzzReverse},
}

var examples = []testing.InternalExample{
}

func init() {
	testdeps.ModulePath = "example.com/strutil"
	testdeps.ImportPath = "example.com/strutil"
}

func main() {
	m := testing.MainStart(testdeps.TestDeps{}, tests, benchmarks, fuzzTargets, examples)
	os.Exit(m.Run())
}

If the test package defines func TestMain(m *testing.M), the generated main calls it instead, and it performs global setup and teardown before and after m.Run() [Go Authors, n.d.a]. When building the test binary, go test also runs a subset of the higher-confidence go vet checks on the package under test and does not run the tests if they report a problem. In package-list mode it caches successful test results: when the test binary, the command line, and the files and environment variables read by the test are all unchanged, it prints the previous result directly and marks it (cached). Both behaviors date from Go 1.10 [Go Authors, 2018; Go Authors, 2026b].

16.4.2 Plain Reporting Primitives

The testing package has no assertion functions, only a few reporting primitives: t.Error and t.Errorf record a failure and let the test continue; t.Fatal and t.Fatalf record a failure and then call FailNow, which ends the current goroutine through runtime.Goexit, runs all its deferred calls, and moves on to the next test. For this reason FailNow must be called in the goroutine that runs the test function, not in other goroutines the test creates, and it does not stop those goroutines [Go Authors, 2026c].

Omitting assertions is a deliberate choice. The reasoning in the Go FAQ is that good error handling means that when one check fails the others keep running, so the person debugging sees the full picture of the failure, for example that isPrime gives wrong answers for 2, 3, 5 and 7 rather than only that it fails on 2; and test frameworks tend to grow into small languages with their own conditionals, control flow and printing, which Go already has [Go Authors, n.d.f]. The Go test review comments advise against assertion libraries more bluntly: assertions either abort the test early or discard the information about what the test did right, and they force the assertion package to invent a sub-language instead of reusing Go itself [Go Authors, n.d.c]. The same document recommends that failure messages give the actual value before the expected one, usually in the format YourFunc(%v) = %v, want %v; the pair of names got and want has thus become an idiom of Go tests. The failure message in TestReverse above uses this format. The price is more boilerplate than a one-line assertion, which is why third-party assertion libraries such as testify have many users.

Helper functions raise a concrete problem: when a helper calls t.Error, the line number in the failure report points inside the helper rather than at the test that called it, which is of almost no help in locating the failure. t.Helper, added in Go 1.9, marks the caller as a helper function, and the reporting of positions skips these frames and gives the position in the first non-helper function [Spare, 2016].

16.4.3 Table-Driven Tests and Subtests

When failure messages have to be written by hand one at a time, writing the inputs and expectations as a table and checking row by row in a loop spreads the cost of writing a good message over all the cases, which is what the Go FAQ recommends [Go Authors, n.d.f]. This style is known in the Go community as table-driven testing [Cheney, 2019]. Early table-driven tests had two inconveniences: a single row of the table could not be run on its own, and the failure message had to carry the name of the case itself. Subtests, proposed by van Lohuizen in 2015, solved these problems; the motivations listed include selecting a single table case from the command line, using methods such as Fail within a subtest, controlling the scope of setup and teardown at a finer granularity than TestMain, and better control over parallelism [van Lohuizen, 2015]. Subtests shipped with Go 1.7 [Go Authors, 2016]. t.Run runs the subtest in a new goroutine and returns only when the subtest returns or calls t.Parallel; the name of a subtest is appended to its parent’s after a slash, and the patterns of -run are likewise matched slash-separated segment by segment [Go Authors, 2026c]. Run with Go 1.26.10, with the durations removed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ go test -v -run TestReverse
=== RUN   TestReverse
=== RUN   TestReverse/empty
=== PAUSE TestReverse/empty
=== RUN   TestReverse/single
=== PAUSE TestReverse/single
=== RUN   TestReverse/ascii
=== PAUSE TestReverse/ascii
=== CONT  TestReverse/empty
=== CONT  TestReverse/ascii
=== CONT  TestReverse/single
--- PASS: TestReverse
    --- PASS: TestReverse/empty
    --- PASS: TestReverse/ascii
    --- PASS: TestReverse/single
PASS

The output shows how parallel subtests are scheduled: each subtest pauses (PAUSE) after calling t.Parallel, t.Run returns at once, and the loop goes on to create the next subtest; only after the body of the parent test finishes do the paused subtests resume together (CONT). The parent test is not complete until all its subtests finish, so a group of parallel subtests runs in parallel only with one another [Go Authors, n.d.a]. To run just one row, use go test -run TestReverse/ascii.

This scheduling, together with the semantics of the for loop, once formed a common trap. Before Go 1.22, a variable declared by a for statement was created once and updated on each iteration [Go Authors, 2024]; the parallel subtests ran only after the loop ended, the tt captured by the closure was already the last row, and all the subtests actually checked the same case, which passed anyway. The idiom of the time was to add the line tt := tt before the closure. Go 1.22 creates a new variable for each iteration. The change takes effect per file by language version: the new semantics apply only when the version determined by the go directive in go.mod or by build constraints is Go 1.22 or later [Go Authors, 2026d]. The module above declares go 1.26, so that line is not needed.

Beyond subtests, *testing.T has gradually gained methods for managing test resources: t.Cleanup registers a cleanup function that runs after the test and its subtests finish (Go 1.14); t.TempDir returns a temporary directory that is deleted automatically after the test (Go 1.15); t.Setenv sets an environment variable for the duration of the test (Go 1.17); t.Context returns a context that is canceled before the cleanup functions run, and t.Chdir changes the working directory for the duration of the test (Go 1.24) [Go Authors, 2026c; Go Authors, 2025a]. What they have in common is that they bind the lifetime of a resource to the test. t.Setenv and t.Chdir, which modify process-wide state, cannot be used together with t.Parallel [Go Authors, 2026c].

16.4.4 Fuzzing

Table-driven tests can only check inputs the author thought of. All of TestReverse above passes, but its three cases are ASCII strings, for which byte-wise reversal happens to be correct. Fuzzing takes a different approach: the author writes only a property that should hold for all inputs, and the tool generates large numbers of inputs to look for a counterexample. The property checked by FuzzReverse is that reversing a valid UTF-8 string should yield a valid UTF-8 string. Without -fuzz, go test just runs the seeds supplied by f.Add and the existing corpus under the testdata/fuzz/FuzzReverse directory as ordinary cases; only with -fuzz does it enter fuzzing mode [Go Authors, n.d.b]. One run with Go 1.26.10:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
$ go test -fuzz=FuzzReverse
fuzz: elapsed: 0s, gathering baseline coverage: 0/1 completed
fuzz: elapsed: 0s, gathering baseline coverage: 1/1 completed, now fuzzing with 18 workers
fuzz: elapsed: 0s, execs: 643 (6499/sec), new interesting: 6 (total: 7)
--- FAIL: FuzzReverse (0.11s)
    --- FAIL: FuzzReverse (0.00s)
        reverse_test.go:31: Reverse("Δ0") = "0\x94\xce", not valid UTF-8
    
    Failing input written to testdata/fuzz/FuzzReverse/022c99056fac7c47
    To re-run:
    go test -run=FuzzReverse/022c99056fac7c47
FAIL
exit status 1
FAIL	example.com/strutil	0.665s
$ cat testdata/fuzz/FuzzReverse/022c99056fac7c47
go test fuzz v1
string("Δ0")

After less than a second and a little over six hundred executions, the engine found a counterexample: Δ takes two bytes in UTF-8, and byte-wise reversal splits it. The exact input found differs from run to run. The engine first measures baseline coverage with the seed corpus, then mutates inputs in several worker processes and keeps those that increase coverage (new interesting in the output). This generated corpus is stored in $GOCACHE/fuzz; the input that caused the failure is written to testdata/fuzz/FuzzReverse, and once it is committed with the code, every later ordinary go test runs it, so the bug becomes a permanent regression case [Go Authors, n.d.b; Go Authors, 2022]. Coverage guidance depends on compiler instrumentation, and the documentation recommends running fuzz tests on platforms that support instrumentation, currently amd64 and arm64 [Go Authors, n.d.b].

Go’s fuzzing combines two streams. One is coverage-guided fuzzing: Hockman’s design draft takes AFL and libFuzzer as references and notes that tools already in the Go community, such as go-fuzz, had successfully found a large number of bugs but lacked native integration with the toolchain [Hockman, 2020]. The other is property-based random testing, which originates with Claessen and Hughes’s QuickCheck: instead of writing concrete cases, one writes properties that should always hold, and the tool generates random inputs to try to refute them [Claessen and Hughes, 2000]. The standard library’s testing/quick belongs to this line, and its documentation states that the package is frozen and accepts no new features [Go Authors, 2026e]. FuzzReverse combines both: it asserts a property, and coverage feedback drives the generation of inputs. The draft was finalized as proposal #44551, and fuzzing shipped with Go 1.18 [Go Authors, 2022].

16.4.5 Benchmarks and Concurrent Tests

Benchmarks use the same conventions, with functions of the form func BenchmarkXxx(b *testing.B). b.Loop, introduced in Go 1.24, replaces the traditional b.N loop: the benchmark function runs only once per -count, and costly setup and teardown also run only once; the arguments and results of function calls are kept alive, so the compiler cannot optimize the whole loop body away [Go Authors, 2025a]. How b.N converges, how b.Loop is implemented, and memory measurement are covered in 16.5.

Testing concurrent code has two dedicated tools. When tests are run with -race, the race detector reports the data races the tests actually exercise (16.2). testing/synctest, which became a regular feature in Go 1.25, runs a test in an isolated “bubble”; time inside the bubble is virtual, and the clock advances immediately when all goroutines are durably blocked, so tests that depend on timeouts and timers are both fast and deterministic [Go Authors, 2025b]. Its detection of deadlocks inside a bubble is covered in 16.1.4.

16.4.6 Recent Additions

The interfaces added to the testing package in the last few releases mostly serve to connect tests with external systems [Go Authors, 2025a; Go Authors, 2025b; Go Authors, 2026f]:

VersionAddedPurpose
Go 1.24T.Context, T.Chdir, B.LoopA context and working directory bound to the test lifecycle; the new benchmark loop
Go 1.25T.Attr, T.Output, testing/synctestEmit key-value attributes to the test log, with their meaning left to continuous integration systems and test frameworks; an io.Writer on the same output stream as t.Log; virtual time for concurrent tests
Go 1.26T.ArtifactDir, testing/cryptotest.SetGlobalRandomA directory for saving test artifacts; a deterministic random source for cryptographic functions

When go test -artifacts is given, the directory returned by ArtifactDir is under the output directory and can be inspected afterwards; otherwise it is a temporary directory deleted after the test [Go Authors, 2026f]. SetGlobalRandom appeared because of another Go 1.26 change: functions in the crypto packages, such as key generation, no longer use a random source passed in by the caller but always use secure random bytes, so tests that need determinism use this function instead [Go Authors, 2026f]. The versions in which the interfaces in the table were introduced agree with the records in api/go1.24.txt through api/go1.26.txt.

16.4.7 Summary

Go’s testing rests on a few naming conventions: go test discovers tests by the conventions and generates a main package that organizes them into a test binary. The testing package is deliberately plain. It provides only reporting primitives, leaves the responsibility of expressing failures to the test author, and spreads that cost with table-driven tests; subtests let each row of the table be run on its own and in parallel; fuzzing brings coverage-guided input generation under the same conventions, and the counterexamples it finds become regression cases in the form of corpus files.

This design also gives something up. Conveniences such as assertions and mock objects are left to third-party libraries or boilerplate, and the rules for discovering tests cannot be redefined; in return, all Go projects organize and run tests the same way, and continuous integration, coverage tools and editors need to face only one set of conventions. This is consistent with Go’s trade-offs elsewhere (1.1): a simple convention that everyone understands and tools can recognize buys low friction in collaboration across the whole ecosystem.

Further Reading