Testing

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18. Go ships its testing story in the standard library and the go command: there is no test framework to install, no annotations, and no assertion DSL. A test is an ordinary function in a file whose name ends in _test.go, go test finds it, builds a test binary, runs it and reports. Everything else on this page — subtests, helpers, fixtures, HTTP servers, coverage, integration binaries — is a layer of conventions and small helpers on top of that one mechanism. Every example below was compiled, vetted and run for real against the shelf module (example.com/shelf) used throughout this Go section; the module declares go 1.27.

Benchmarks (BenchmarkXxx, b.Loop), fuzzing (FuzzXxx) and testing/synctest have their own page, Benchmarks, Fuzzing and synctest; this page covers tests, examples, test support packages and coverage. For the same topic in another language see the Rust Reference’s Testing & Benchmarking, and for end-to-end tests of a finished command-line program see Testing CLIs.

The shape of a Go test

A package under test is an ordinary directory of .go files. Next to the production files you add files named *_test.go. The go command treats them specially:

  • they are excluded from go build and go install, so test code never reaches a production binary;

  • they are compiled into a separate test binary together with the package, by go test;

  • they may declare TestXxx(t *testing.T) functions, BenchmarkXxx(b *testing.B), FuzzXxx(f *testing.F) and ExampleXxx() functions, plus an optional TestMain(m *testing.M);

  • the part after Test must not start with a lowercase letter, so TestParse and Test_parse are tests while Testparse is not. go vet (its tests analyzer) reports malformed names and signatures.

The testing package defines the types every test uses: testing.T for tests, testing.B for benchmarks, testing.F for fuzz targets and testing.M for TestMain. testing.TB is the interface their common methods satisfy; helpers that must work in tests, benchmarks and fuzz targets accept a testing.TB.

Same package or _test package

A test file declares one of two package names, and a single directory can hold both kinds at once:

  • Same package (package catalog): a white-box test. It sees unexported identifiers, which is convenient for testing internals and for tests of helpers.

  • External test package (package catalog_test): a black-box test. It must import the package like any client, so it can only use the exported API. It may import packages that themselves import the package under test, which a same-package test cannot do without creating an import cycle. Testable examples normally live here, because they double as documentation for users.

Three kinds of files in one directory: production files, same-package test files and export_test.go in package catalog, and external test files in package catalog_test that import catalog; testdata holds fixtures

When a black-box test needs one unexported name, the idiom is a file called export_test.go in the same package that re-exports it. Because the file ends in _test.go, the bridge exists only while testing and adds nothing to the public API. The following scratch module (example.com/bridge) has one unexported checksum function:

check.go
package bridge

// Valid reports whether the digits carry a correct checksum.
func Valid(digits string) bool { return checksum(digits)%10 == 0 }

func checksum(digits string) int {
	sum := 0
	for i, r := range digits {
		w := 1
		if i%2 == 1 {
			w = 3
		}
		sum += int(r-'0') * w
	}
	return sum
}
export_test.go
package bridge

// Checksum exposes the unexported checksum to the external test package.
var Checksum = checksum
check_test.go
package bridge_test

import (
	"testing"

	"example.com/bridge"
)

func TestChecksum(t *testing.T) {
	if got := bridge.Checksum("9780134190440"); got != 80 {
		t.Errorf("Checksum = %d, want 80", got)
	}
	if !bridge.Valid("9780134190440") {
		t.Error("Valid = false")
	}
}
$ go test -v .
=== RUN   TestChecksum
--- PASS: TestChecksum (0.00s)
PASS
ok  	example.com/bridge	0.170s
$ go list -f '{{.GoFiles}} {{.TestGoFiles}} {{.XTestGoFiles}}' .
[check.go] [export_test.go] [check_test.go]

go list shows the three groups: production files, same-package test files (export_test.go among them) and external test files. testdata directories, and directories whose names begin with . or _, are ignored when the go command expands patterns such as ./..., so they are the right place for fixtures (see testdata and golden files).

A first test

Here is the first test of the shelf module’s ISBN parser, using only t.Fatalf and t.Errorf:

func TestParseISBN(t *testing.T) {
	got, err := ParseISBN("978-0-13-419044-0")
	if err != nil {
		t.Fatalf("ParseISBN(valid) error = %v", err)
	}
	if want := ISBN("9780134190440"); got != want {
		t.Errorf("ParseISBN(valid) = %q, want %q", got, want)
	}
}

Run it verbosely, restricting to this test:

$ go test -v -run 'TestParseISBN$' ./catalog
=== RUN   TestParseISBN
--- PASS: TestParseISBN (0.00s)
PASS
ok  	example.com/shelf/catalog	0.173s

Source: testing: Main

Reporting: Error, Fatal, Log, Skip

Go has no assertions. A test reports problems with methods on *testing.T:

Method Effect

t.Log, t.Logf

Record a line, shown for failing tests and always with -v. Line numbers are those of the call site.

t.Error, t.Errorf

Log followed by Fail: mark the test failed and keep running, so one run reports every wrong value.

t.Fatal, t.Fatalf

Log followed by FailNow: mark failed and stop the test goroutine immediately (via runtime.Goexit, so deferred calls and cleanups still run).

t.Fail, t.FailNow

Mark failed without logging; FailNow stops the test.

t.Failed

Reports whether the test has failed so far — useful in a cleanup that dumps diagnostics only on failure.

t.Skip, t.Skipf, t.SkipNow

Log and stop, marking the test skipped (not failed). Use it for missing prerequisites and for -short.

t.Name

The full test name including parent names, for example TestParseISBNTable/plain.

The rule of thumb is to use Fatal where continuing would be meaningless or would panic (the parse failed, so there is no value to compare) and Error everywhere else, so a failing run shows as much as possible. Fatal, FailNow and Skip must be called from the goroutine running the test; Error and Log may be called from any goroutine. go vet reports Fatal called from another goroutine (the testinggoroutine analyzer, shown below).

The scratch module example.com/fail shows all outcomes at once:

package fail

import "testing"

func assertDouble(t *testing.T, in, want int) {
	t.Helper()
	if got := Double(in); got != want {
		t.Errorf("Double(%d) = %d, want %d", in, got, want)
	}
}

func TestErrorContinues(t *testing.T) {
	assertDouble(t, 2, 5)
	assertDouble(t, 3, 7)
	t.Log("still running after Errorf")
}

func TestFatalStops(t *testing.T) {
	if got := Double(2); got != 5 {
		t.Fatalf("Double(2) = %d, want 5", got)
	}
	t.Log("never reached")
}

func TestSkipped(t *testing.T) {
	t.Skip("needs a database")
}

func TestPanics(t *testing.T) {
	var m map[string]int
	m["x"] = 1
}
--- FAIL: TestErrorContinues (0.00s)
    fail_test.go:13: Double(2) = 4, want 5
    fail_test.go:14: Double(3) = 6, want 7
    fail_test.go:15: still running after Errorf
--- FAIL: TestFatalStops (0.00s)
    fail_test.go:20: Double(2) = 4, want 5
--- FAIL: TestPanics (0.00s)
panic: assignment to entry in nil map [recovered, repanicked]

goroutine 38 [running]:
testing.tRunner.func1.2({0x101132840, 0x10116cf30})
	.../src/testing/testing.go:2125 +0x1a0
...
example.com/fail.TestPanics(0x4814c2fd8908?)
	.../fail/fail_test.go:31 +0x34
...
FAIL	example.com/fail	0.188s
FAIL

The stack trace is shortened here. Note four facts the output shows. TestErrorContinues kept going after the first Errorf and logged both mismatches. TestFatalStops never printed "never reached". The skipped test is silent without -v. And a panic in a test aborts the whole test binary (the remaining tests do not run), after printing the stack and the test that panicked; go test exits with status 1.

Source: testing: type T

Useful failure messages

The Go code review guidance asks that a failure message state what was called, what came back and what was expected — conventionally in the form Fn(input) = got, want want — without needing the test source to interpret it. Prefer %q for strings (it exposes stray spaces and newlines) and %v or %+v for values, and say which input failed when a loop or table is involved.

// Fragment. Good: self-contained
t.Errorf("Double(%d) = %d, want %d", in, got, want)

// Poor: the reader must open the test to know what was compared
t.Errorf("wrong result: %d", got)

Report got before want (the official guidance), name the function under test, and compare whole values with one check where possible so the message prints both complete values.

Helper functions and t.Helper

A helper that calls t.Fatalf or t.Errorf would report the helper’s own line, not the line of the test that called it. Calling t.Helper() as the first statement marks the function as a helper, and the failure is attributed to the caller. The shelf test below builds a populated store; if the fixture is wrong, the report points at the test that asked for it:

package catalog

import "testing"

// newStore returns a MemStore holding one book per ISBN text.
func newStore(t *testing.T, isbns ...string) *MemStore {
	t.Helper()
	s := NewMemStore()
	for _, text := range isbns {
		isbn, err := ParseISBN(text)
		if err != nil {
			t.Fatalf("newStore: %v", err)
		}
		s.Put(Book{ISBN: isbn, Title: "Book " + text})
	}
	return s
}

The earlier example.com/fail listing used the same idea: assertDouble calls t.Helper(), and the failures above are reported at lines 13 and 14, where the test called it, not at the t.Errorf inside the helper. Helpers accept a testing.TB when they must also work in benchmarks. A helper taking *testing.T that registers cleanups with t.Cleanup lets the caller forget about teardown altogether.

Table-driven tests and subtests

Most Go tests are table-driven: a slice of anonymous structs holds the cases (inputs, expected values, an expected error) and one loop runs them. Adding a case is a one-line change, and the test logic is written once. Since Go 1.7 each case runs as a subtest through t.Run(name, func(t *testing.T)), which gives every case its own name, its own failure status and the ability to select it from the command line.

package catalog

import (
	"errors"
	"testing"
)

func TestParseISBN(t *testing.T) {
	got, err := ParseISBN("978-0-13-419044-0")
	if err != nil {
		t.Fatalf("ParseISBN(valid) error = %v", err)
	}
	if want := ISBN("9780134190440"); got != want {
		t.Errorf("ParseISBN(valid) = %q, want %q", got, want)
	}
}

func TestParseISBNTable(t *testing.T) {
	tests := []struct {
		name    string
		in      string
		want    ISBN
		wantErr error
	}{
		{name: "hyphenated", in: "978-0-13-419044-0", want: "9780134190440"},
		{name: "plain", in: "9781492077213", want: "9781492077213"},
		{name: "bad checksum", in: "9780134190441", wantErr: ErrInvalidISBN},
		{name: "too short", in: "978013", wantErr: ErrInvalidISBN},
		{name: "letters", in: "97801341904X0", wantErr: ErrInvalidISBN},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := ParseISBN(tt.in)
			if !errors.Is(err, tt.wantErr) {
				t.Fatalf("ParseISBN(%q) error = %v, want %v", tt.in, err, tt.wantErr)
			}
			if got != tt.want {
				t.Errorf("ParseISBN(%q) = %q, want %q", tt.in, got, tt.want)
			}
		})
	}
}
$ go test -v -run 'TestParseISBNTable/(plain|too)' ./catalog
=== RUN   TestParseISBNTable
=== RUN   TestParseISBNTable/plain
=== RUN   TestParseISBNTable/too_short
--- PASS: TestParseISBNTable (0.00s)
    --- PASS: TestParseISBNTable/plain (0.00s)
    --- PASS: TestParseISBNTable/too_short (0.00s)
PASS
ok  	example.com/shelf/catalog	0.067s

Points worth knowing about the loop:

  • The check errors.Is(err, tt.wantErr) handles both success (wantErr is nil, so only nil matches) and the failure kinds; see Error Handling for errors.Is and wrapping.

  • Since Go 1.22 each iteration of the for loop has its own tt variable (for modules that declare go 1.22 or later), so a closure or a parallel subtest captures the case it was created for. Older code that you may meet writes tt := tt as the first line of the loop body to get the same effect; that copy is no longer needed (the copyloopvar analyzer reports it). See Control Flow for the language change.

  • A table can be a map keyed by name instead of a slice with a name field. Map iteration order is random, which also shakes out tests that accidentally depend on order.

  • Keep tables readable: use named fields (want, wantErr), not positional struct literals, and a short descriptive name per case — the name is the handle you use with -run.

Selecting subtests: -run, -skip and name rewriting

-run takes a regular expression that is split on unbracketed slashes: -run 'A/B' matches top-level tests that match A and, inside them, subtests that match B. Each part is an unanchored search, so use ^ and $ to anchor (-run '^TestLend$'). -skip (Go 1.20) is the inverse: run everything that does not match. Both apply to tests, examples and fuzz targets. -list regexp prints the matching top-level names without running them.

$ go test -list '.' ./catalog
TestParseISBN
TestParseISBNTable
TestLend
TestLendNotifyFails
TestLoadBooksGolden
TestLoadBooksMapFS
TestLoadBooksInvalidISBN
ExampleParseISBN
ExampleLoanState_String
ExampleMemStore_All
ok  	example.com/shelf/catalog	0.063s
$ go test -v -skip 'Table|Lend|Load' ./catalog
=== RUN   TestParseISBN
--- PASS: TestParseISBN (0.00s)
=== RUN   ExampleParseISBN
--- PASS: ExampleParseISBN (0.00s)
=== RUN   ExampleLoanState_String
--- PASS: ExampleLoanState_String (0.00s)
=== RUN   ExampleMemStore_All
--- PASS: ExampleMemStore_All (0.00s)
PASS
ok  	example.com/shelf/catalog	0.059s

Subtest names are rewritten before matching and printing: spaces become underscores, non-printable characters are escaped, duplicate names get a #01, #02 suffix and an empty name becomes #00. A slash in a name stays and so looks like another nesting level to -run, which is a reason to avoid slashes in case names.

package par

import "testing"

func TestNames(t *testing.T) {
	for _, name := range []string{"two words", "two words", "a/b", ""} {
		t.Run(name, func(t *testing.T) { t.Log(t.Name()) })
	}
}
=== RUN   TestNames
=== RUN   TestNames/two_words
    name_test.go:7: TestNames/two_words
=== RUN   TestNames/two_words#01
    name_test.go:7: TestNames/two_words#01
=== RUN   TestNames/a/b
    name_test.go:7: TestNames/a/b
=== RUN   TestNames/#00
    name_test.go:7: TestNames/#00
--- PASS: TestNames (0.00s)
    --- PASS: TestNames/two_words (0.00s)
    --- PASS: TestNames/two_words#01 (0.00s)
    --- PASS: TestNames/a/b (0.00s)
    --- PASS: TestNames/#00 (0.00s)
PASS
ok  	example.com/par	0.186s

When -run matches nothing, go test still succeeds but says so, which catches a typo in a CI filter:

testing: warning: no tests to run
PASS
ok  	example.com/fail	0.076s [no tests to run]

t.Parallel

t.Parallel() signals that a test (or subtest) may run concurrently with other parallel tests. The call pauses the test until its sequential siblings — and, for a subtest, the parent’s function body — have finished, then releases it. The number of tests running at once is capped by -parallel n (default GOMAXPROCS). A parent test does not complete until all its parallel subtests have, so a t.Cleanup on the parent runs after them.

package par

import (
	"fmt"
	"testing"
)

func TestParallelSubtests(t *testing.T) {
	for _, n := range []int{1, 2, 3} {
		t.Run(fmt.Sprintf("n=%d", n), func(t *testing.T) {
			t.Parallel()
			fmt.Println("running subtest with n =", n)
		})
	}
	fmt.Println("parent body finished (parallel subtests have not run yet)")
}
$ go test -v -run Parallel -parallel 1 .
=== RUN   TestParallelSubtests
=== RUN   TestParallelSubtests/n=1
=== PAUSE TestParallelSubtests/n=1
=== RUN   TestParallelSubtests/n=2
=== PAUSE TestParallelSubtests/n=2
=== RUN   TestParallelSubtests/n=3
=== PAUSE TestParallelSubtests/n=3
parent body finished (parallel subtests have not run yet)
=== CONT  TestParallelSubtests/n=1
running subtest with n = 1
=== CONT  TestParallelSubtests/n=3
running subtest with n = 3
=== CONT  TestParallelSubtests/n=2
running subtest with n = 2
--- PASS: TestParallelSubtests (0.00s)
    --- PASS: TestParallelSubtests/n=1 (0.00s)
    --- PASS: TestParallelSubtests/n=3 (0.00s)
    --- PASS: TestParallelSubtests/n=2 (0.00s)
PASS
ok  	example.com/par	0.167s

The order of the CONT lines is not defined and changes between runs; that is exactly the point. Consequences:

  • A parallel subtest that reads the loop variable captures the right value only because of the Go 1.22 per-iteration variable (see above); under go 1.21 and older semantics all subtests would see the last case.

  • Parallel tests must not share mutable state without synchronisation. Run suites with -race (see the race detector) so a violation fails loudly.

  • t.Setenv and t.Chdir change process-wide state and therefore panic if the test or an ancestor is parallel. Tests that need them stay sequential.

  • Parallelism is per test binary (per package); go test also builds and runs different packages in parallel, controlled by the build flag -p.

Fixtures and cleanup

Setup that every test needs is best written as a helper that returns the fixture and registers its own teardown, so a test body shows only what is specific to it. testing.T provides the building blocks.

t.Cleanup

t.Cleanup(f) registers a function to run when the test and all its subtests complete, in last-in-first-out order — like defer, but scoped to the test rather than to one function, so a helper can register teardown for its caller (Go 1.14). Cleanups run after t.Fatal, after t.Skip and after a panic in the test.

t.TempDir, t.Setenv, t.Chdir, t.Context

Method Behaviour Since

t.TempDir()

A fresh directory, unique per call, removed (with its contents) after the test.

1.15

t.Setenv(key, value)

os.Setenv for the duration of the test; the previous value (or absence) is restored by a cleanup. Panics in a parallel test.

1.17

t.Context()

A context.Context cancelled just before cleanup functions run, so cleanups can wait for goroutines that stop on ctx.Done().

1.24

t.Chdir(dir)

os.Chdir for the duration of the test, restored by a cleanup (and PWD set on Unix). Panics in a parallel test.

1.24

t.Attr(key, value)

Emit a test attribute (=== ATTR line, or an attr action in JSON output) for CI systems; key without whitespace, value without newlines.

1.25

t.Output()

An io.Writer that writes into the same stream as t.Log, indented but without file and line.

1.25

t.ArtifactDir()

A directory for output files; kept under -outputdir when -artifacts is given, otherwise temporary and removed.

1.26

The same methods exist on testing.B and testing.F (and on testing.TB). The lifecycle package of the shelf module exercises each of them:

// Package lifecycle holds a tiny config reader used by the testing page.
package lifecycle

import "os"

// DataDir returns SHELF_DATA, or "data" when unset.
func DataDir() string {
	if v := os.Getenv("SHELF_DATA"); v != "" {
		return v
	}
	return "data"
}
package lifecycle

import (
	"fmt"
	"os"
	"path/filepath"
	"testing"
)

func TestCleanupOrder(t *testing.T) {
	t.Cleanup(func() { fmt.Println("cleanup 1 (registered first, runs last)") })
	t.Cleanup(func() { fmt.Println("cleanup 2 (registered last, runs first)") })
	fmt.Println("test body")
}

func TestSetenv(t *testing.T) {
	t.Setenv("SHELF_DATA", "/srv/shelf")
	if got := DataDir(); got != "/srv/shelf" {
		t.Errorf("DataDir() = %q", got)
	}
}

func TestDataDirDefault(t *testing.T) {
	if got := DataDir(); got != "data" {
		t.Errorf("DataDir() = %q, want %q", got, "data")
	}
}

func TestTempDirAndChdir(t *testing.T) {
	dir := t.TempDir()
	t.Chdir(dir)
	if err := os.WriteFile("loan.json", []byte("{}"), 0o644); err != nil {
		t.Fatal(err)
	}
	cwd, _ := os.Getwd()
	entries, _ := os.ReadDir(dir)
	t.Logf("cwd is the temp dir: %v, entries: %d", filepath.Base(cwd) == filepath.Base(dir), len(entries))
	t.Cleanup(func() {
		_, err := os.Stat(filepath.Join(dir, "loan.json"))
		fmt.Println("file still exists inside Cleanup:", err == nil)
	})
}

func TestContext(t *testing.T) {
	ctx := t.Context()
	done := make(chan struct{})
	go func() {
		<-ctx.Done()
		fmt.Println("worker saw cancellation:", ctx.Err())
		close(done)
	}()
	t.Cleanup(func() { <-done })
}

func TestAttrAndOutput(t *testing.T) {
	t.Attr("owner", "catalog-team")
	fmt.Fprintln(t.Output(), "written through t.Output")
	t.Log("written through t.Log")
}

func TestArtifactDir(t *testing.T) {
	dir := t.ArtifactDir()
	if err := os.WriteFile(filepath.Join(dir, "report.txt"), []byte("ok\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	t.Logf("artifact dir has a path: %v", dir != "")
}
=== RUN   TestCleanupOrder
test body
cleanup 2 (registered last, runs first)
cleanup 1 (registered first, runs last)
--- PASS: TestCleanupOrder (0.00s)
=== RUN   TestSetenv
--- PASS: TestSetenv (0.00s)
=== RUN   TestDataDirDefault
--- PASS: TestDataDirDefault (0.00s)
=== RUN   TestTempDirAndChdir
    lifecycle_test.go:37: cwd is the temp dir: true, entries: 1
file still exists inside Cleanup: true
--- PASS: TestTempDirAndChdir (0.00s)
=== RUN   TestContext
worker saw cancellation: context canceled
--- PASS: TestContext (0.00s)
=== RUN   TestAttrAndOutput
=== ATTR  TestAttrAndOutput owner catalog-team
    written through t.Output
    lifecycle_test.go:58: written through t.Log
--- PASS: TestAttrAndOutput (0.00s)
=== RUN   TestArtifactDir
    lifecycle_test.go:66: artifact dir has a path: true
--- PASS: TestArtifactDir (0.00s)
PASS
ok  	example.com/shelf/internal/lifecycle	0.176s

Observations from the output: the cleanup order is reversed; TestDataDirDefault passes because t.Setenv in the previous test restored the variable; the temporary directory still exists while cleanups run (it is removed after them, because TempDir registered its own cleanup first); and t.Context is cancelled before the cleanup that waits for the worker, which is what makes that "wait for the goroutine" cleanup safe. t.Output and t.Log appear in the same stream, in call order.

t.ArtifactDir is meant for files a human or CI wants after the run — screenshots, a captured response, a database dump. Without -artifacts the directory is temporary. With the flag the files land under the directory named by -outputdir, which must already exist, in a path derived from the package and the test name, and the location is printed in the log:

$ mkdir -p out
$ go test -artifacts -outputdir=$PWD/out -run TestArtifactDir -v ./internal/lifecycle
=== RUN   TestArtifactDir
=== ARTIFACTS TestArtifactDir /.../out/_artifacts/internal/lifecycle/TestArtifactDir/2385243606
    lifecycle_test.go:66: artifact dir has a path: true
--- PASS: TestArtifactDir (0.00s)
PASS
ok  	example.com/shelf/internal/lifecycle	0.062s
$ find out -type f
out/_artifacts/internal/lifecycle/TestArtifactDir/2385243606/report.txt

Running with -outputdir pointing at a directory that does not exist fails with testing: mkdir ...: no such file or directory and the package reports FAIL.

TestMain

When something must happen once per package — start a database container, build a binary, seed a fixture, parse a custom flag — declare func TestMain(m *testing.M) in any _test.go file of the package. go test then calls it instead of running the tests directly; it runs on the main goroutine, and m.Run() returns the exit code. Since Go 1.15 a TestMain that simply returns also exits with m.Run's result, but the explicit os.Exit(code) form is still the clearest because it lets setup and teardown wrap m.Run. flag.Parse has not run when TestMain starts; call it if TestMain reads flags, including the testing flags such as testing.Short().

The shelf api package uses TestMain to seed a store shared by the whole package. The same file contains the HTTP tests shown later; here is the part that matters:

var seeded *catalog.MemStore

// TestMain runs once per package: expensive setup goes here, and the exit
// code of m.Run must be handed to os.Exit.
func TestMain(m *testing.M) {
	seeded = catalog.NewMemStore()
	seeded.Put(catalog.Book{ISBN: "9780134190440", Title: "The Go Programming Language"})
	fmt.Fprintln(os.Stderr, "TestMain: store seeded")

	code := m.Run()

	fmt.Fprintln(os.Stderr, "TestMain: done")
	os.Exit(code)
}
TestMain: store seeded
=== RUN   TestHandlerRecorder
...
PASS
TestMain: done
ok  	example.com/shelf/api	0.209s

TestMain is a low-level hook with a cost: only one per package, no access to a *testing.T (so no t.Cleanup, t.TempDir or t.Log), and anything it sets up is shared global state. Prefer a helper function that builds the fixture per test and registers cleanup. Use TestMain for work that is truly per-process: testscript.Main (see below), building a binary once, or goleak-style leak checks after m.Run.

Source: testing: Main

testdata and golden files

The go tool ignores directories named testdata when matching packages, and a test binary runs with the package directory as its working directory, so tests open fixtures with a plain relative path such as testdata/books.txt. (Because the module cache may be read-only, a test must not write into its source tree except when the user asks, as with -update below; use t.TempDir for scratch files.)

A golden file stores the expected output of a function whose result is too large to write inline. The test compares and, when run with a custom -update flag, rewrites the golden file so a deliberate change is reviewed as a diff in version control.

package catalog_test

import (
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"testing/fstest"

	"example.com/shelf/catalog"
)

var update = flag.Bool("update", false, "rewrite golden files")

func render(books []catalog.Book) string {
	var sb strings.Builder
	for _, b := range books {
		fmt.Fprintf(&sb, "%s %s\n", b.ISBN, b.Title)
	}
	return sb.String()
}

func TestLoadBooksGolden(t *testing.T) {
	books, err := catalog.LoadBooks(os.DirFS("testdata"))
	if err != nil {
		t.Fatal(err)
	}
	got := render(books)

	golden := filepath.Join("testdata", "books.golden")
	if *update {
		if err := os.WriteFile(golden, []byte(got), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	want, err := os.ReadFile(golden)
	if err != nil {
		t.Fatal(err)
	}
	if got != string(want) {
		t.Errorf("LoadBooks output differs from %s (run with -update)\ngot:\n%swant:\n%s", golden, got, want)
	}
}

func TestLoadBooksMapFS(t *testing.T) {
	fsys := fstest.MapFS{
		"a.txt":     {Data: []byte("9780134190440;The Go Programming Language\n")},
		"notes.md":  {Data: []byte("ignored")},
		"bad/b.txt": {Data: []byte("not read: only top-level files match")},
	}
	books, err := catalog.LoadBooks(fsys)
	if err != nil {
		t.Fatal(err)
	}
	if len(books) != 1 || books[0].Title != "The Go Programming Language" {
		t.Errorf("books = %+v", books)
	}
}

func TestLoadBooksInvalidISBN(t *testing.T) {
	fsys := fstest.MapFS{"x.txt": {Data: []byte("123;Broken\n")}}
	_, err := catalog.LoadBooks(fsys)
	if err == nil {
		t.Fatal("LoadBooks succeeded, want an ISBN error")
	}
	t.Log(err)
}

The catalog.LoadBooks function reads isbn;title lines from every *.txt file in an fs.FS; the fixture directory contains one file:

9780134190440;The Go Programming Language
9781492077213;Learning Go

The first run, before the golden file exists, fails; -update writes it; later runs compare. Test flags that go test does not know (here -update) are passed through to the test binary, but they must come after the package list:

$ go test ./catalog -run TestLoadBooksGolden
--- FAIL: TestLoadBooksGolden (0.00s)
    load_test.go:40: open testdata/books.golden: no such file or directory
FAIL
FAIL	example.com/shelf/catalog	0.189s
FAIL
$ go test ./catalog -run TestLoadBooksGolden -update
ok  	example.com/shelf/catalog	0.055s
$ cat catalog/testdata/books.golden
9780134190440 The Go Programming Language
9781492077213 Learning Go

After the fixture gains a third line, the comparison fails with both sides printed:

--- FAIL: TestLoadBooksGolden (0.00s)
    load_test.go:43: LoadBooks output differs from testdata/books.golden (run with -update)
        got:
        9780134190440 The Go Programming Language
        9781492077213 Learning Go
        9781788838467 Mastering Go
        want:
        9780134190440 The Go Programming Language
        9781492077213 Learning Go
FAIL
FAIL	example.com/shelf/catalog

(The last line is shortened; the failure message and the got/want blocks are verbatim.) Review a regenerated golden file like any other source change. The same -update technique is the basis of snapshot tests for command-line output; see Testing CLIs.

Test doubles with small interfaces

Go tests rarely need a mocking framework, because interfaces are satisfied implicitly: the consumer declares the small interface it needs and the test supplies a hand-written fake. The shelf Lender depends on a one-method Notifier and takes the clock as a function field (Now func() time.Time), so a test controls both the side effect and the time:

package catalog

import (
	"errors"
	"fmt"
	"time"
)

// Errors returned by Lender.
var (
	ErrNotFound = errors.New("catalog: book not found")
	ErrOnLoan   = errors.New("catalog: book already on loan")
)

// Notifier tells a member about a loan.
type Notifier interface {
	Notify(member, message string) error
}

// Lender lends books from a Store.
type Lender struct {
	Store    Store
	Notifier Notifier
	Now      func() time.Time
	loans    map[ISBN]Loan
	due      map[ISBN]time.Time
}

// Lend lends the book to member and notifies them.
func (l *Lender) Lend(isbn ISBN, member string) (Loan, error) {
	if _, ok := l.Store.Get(isbn); !ok {
		return Loan{}, fmt.Errorf("lend %s: %w", isbn, ErrNotFound)
	}
	if l.loans == nil {
		l.loans, l.due = map[ISBN]Loan{}, map[ISBN]time.Time{}
	}
	if _, ok := l.loans[isbn]; ok {
		return Loan{}, fmt.Errorf("lend %s: %w", isbn, ErrOnLoan)
	}
	loan := Loan{Book: isbn, Member: member, State: OnLoan}
	l.loans[isbn] = loan
	l.due[isbn] = l.Now().AddDate(0, 0, 14)
	msg := fmt.Sprintf("%s is due on %s", isbn, l.due[isbn].Format("2006-01-02"))
	if err := l.Notifier.Notify(member, msg); err != nil {
		return loan, fmt.Errorf("lend %s: notify: %w", isbn, err)
	}
	return loan, nil
}
package catalog

import (
	"errors"
	"testing"
	"time"
)

// fakeNotifier records messages instead of sending them.
type fakeNotifier struct {
	sent []string
	err  error
}

func (f *fakeNotifier) Notify(member, message string) error {
	f.sent = append(f.sent, member+": "+message)
	return f.err
}

// notifyFunc adapts a function to the Notifier interface.
type notifyFunc func(member, message string) error

func (f notifyFunc) Notify(member, message string) error { return f(member, message) }

func TestLend(t *testing.T) {
	store := newStore(t, "9780134190440")
	notes := &fakeNotifier{}
	l := &Lender{
		Store:    store,
		Notifier: notes,
		Now:      func() time.Time { return time.Date(2026, 10, 18, 9, 0, 0, 0, time.UTC) },
	}

	loan, err := l.Lend("9780134190440", "ada")
	if err != nil {
		t.Fatal(err)
	}
	if loan.State != OnLoan {
		t.Errorf("State = %v, want %v", loan.State, OnLoan)
	}
	want := []string{"ada: 9780134190440 is due on 2026-11-01"}
	if len(notes.sent) != 1 || notes.sent[0] != want[0] {
		t.Errorf("notifications = %q, want %q", notes.sent, want)
	}

	if _, err := l.Lend("9780134190440", "bob"); !errors.Is(err, ErrOnLoan) {
		t.Errorf("second Lend error = %v, want ErrOnLoan", err)
	}
	if _, err := l.Lend("9781492077213", "bob"); !errors.Is(err, ErrNotFound) {
		t.Errorf("unknown book error = %v, want ErrNotFound", err)
	}
}

func TestLendNotifyFails(t *testing.T) {
	boom := errors.New("smtp down")
	l := &Lender{
		Store:    newStore(t, "9780134190440"),
		Notifier: notifyFunc(func(string, string) error { return boom }),
		Now:      time.Now,
	}
	_, err := l.Lend("9780134190440", "ada")
	if !errors.Is(err, boom) {
		t.Fatalf("Lend error = %v, want it to wrap %v", err, boom)
	}
}

Two idioms are on display. fakeNotifier is a spy: it records what it was asked to do and can be told to fail (err). notifyFunc is the function-type adapter: a named function type with a method, the same trick as http.HandlerFunc, which turns a closure into an interface value in one line. Injecting Now as a field makes the due date deterministic (2026-11-01) instead of depending on the wall clock. The catalog.Store interface in the same module is already tiny; a test that needs "a store that fails on Put`" embeds `*MemStore in a struct and overrides the one method. Interface design for testability is covered in Interfaces and Composition and Object-Oriented Design.

$ go test -count=1 -v -run 'TestLend' ./catalog
=== RUN   TestLend
--- PASS: TestLend (0.00s)
=== RUN   TestLendNotifyFails
--- PASS: TestLendNotifyFails (0.00s)
PASS
ok  	example.com/shelf/catalog	0.120s

Comparing values: DeepEqual, slices.Equal and go-cmp

The standard library gives three ways to compare composite values in a test:

  • == for comparable types (including structs of comparable fields and arrays);

  • the typed helpers slices.Equal, maps.Equal and slices.EqualFunc;

  • reflect.DeepEqual for everything else, which compares deeply but treats a nil slice and an empty slice as different, cannot ignore fields, and gives no description of where two values differ.

package cmpd

import (
	"maps"
	"reflect"
	"slices"
	"testing"
)

func TestEquality(t *testing.T) {
	var nilSlice []string
	empty := []string{}
	t.Logf("DeepEqual(nil, empty) = %v", reflect.DeepEqual(nilSlice, empty))
	t.Logf("slices.Equal(nil, empty) = %v", slices.Equal(nilSlice, empty))

	a := map[string]int{"go": 1}
	b := map[string]int{"go": 1}
	t.Logf("maps.Equal = %v, DeepEqual = %v", maps.Equal(a, b), reflect.DeepEqual(a, b))
}
=== RUN   TestEquality
    d_test.go:13: DeepEqual(nil, empty) = false
    d_test.go:14: slices.Equal(nil, empty) = true
    d_test.go:18: maps.Equal = true, DeepEqual = true
--- PASS: TestEquality (0.00s)
PASS
ok  	example.com/cmpd	0.186s

For large structures the community package github.com/google/go-cmp/cmp is the usual choice: cmp.Diff(want, got) returns a readable, indented diff, understands Equal methods, and takes options such as cmpopts.IgnoreFields, cmpopts.EquateEmpty and cmpopts.SortSlices. It is a third-party module and is not part of the standard library, so the fragment below was not compiled in this offline environment; it shows the shape of the call only.

// Fragment (needs: go get github.com/google/go-cmp): not compiled here.
if diff := cmp.Diff(want, got, cmpopts.EquateEmpty()); diff != "" {
	t.Errorf("LoadBooks() mismatch (-want +got):\n%s", diff)
}

Testable examples

A function named ExampleXxx in a _test.go file is both documentation and a test. go test runs it, captures standard output and compares it with the trailing // Output: comment. godoc and pkg.go.dev display the function body next to the documented identifier. The name selects what the example documents:

Name Attached to

Example

the package

ExampleParseISBN

the function ParseISBN

ExampleMemStore

the type MemStore

ExampleMemStore_All

the method All of MemStore

ExampleParseISBN_invalid

a second example for ParseISBN; the suffix after _ must start with a lowercase letter

package catalog_test

import (
	"fmt"

	"example.com/shelf/catalog"
)

func ExampleParseISBN() {
	isbn, err := catalog.ParseISBN("978-0-13-419044-0")
	fmt.Println(isbn, err)

	_, err = catalog.ParseISBN("978-0-13-419044-1")
	fmt.Println(err)
	// Output:
	// 9780134190440 <nil>
	// catalog: invalid ISBN: "978-0-13-419044-1"
}

func ExampleLoanState_String() {
	for _, s := range []catalog.LoanState{catalog.Available, catalog.OnLoan, catalog.Overdue} {
		fmt.Println(s)
	}
	// Output:
	// available
	// on loan
	// overdue
}

func ExampleMemStore_All() {
	s := catalog.NewMemStore()
	for _, isbn := range []catalog.ISBN{"b", "a", "c"} {
		s.Put(catalog.Book{ISBN: isbn})
	}
	for _, b := range s.All() {
		fmt.Println(b.ISBN)
	}
	// Unordered output:
	// a
	// b
	// c
}
$ go test -v -run 'Example' ./catalog
=== RUN   ExampleParseISBN
--- PASS: ExampleParseISBN (0.00s)
=== RUN   ExampleLoanState_String
--- PASS: ExampleLoanState_String (0.00s)
=== RUN   ExampleMemStore_All
--- PASS: ExampleMemStore_All (0.00s)
PASS
ok  	example.com/shelf/catalog	0.072s

Rules that trip people up:

  • Output is compared after trimming leading and trailing whitespace. // Unordered output: ignores the order of lines — the right choice when the example iterates a map, as ExampleMemStore_All does through MemStore.All.

  • An example without an output comment is compiled but not run. This is the way to document code that touches the network or the clock.

  • Examples are always run once: -count does not repeat them.

  • go vet checks that an example refers to something that exists. The tests analyzer reported this on a module whose go.mod said go 1.23, together with a version error shown later:

    ./s_test.go:14:1: ExampleMissing refers to unknown identifier: Missing

Testing HTTP with net/http/httptest

net/http/httptest has two halves: a way to call a handler directly with no network, and a way to run a real server for testing clients and end-to-end behaviour. The code under test is the small catalogue API of the shelf module (api.Handler, a ServeMux using the Go 1.22 method-and-wildcard pattern GET /books/{isbn}, and api.Client); see net/http Clients and Servers for the code itself.

Handlers: httptest.NewRequest and ResponseRecorder

httptest.NewRequest(method, target, body) builds an *http.Request suitable for a server handler (it panics on a malformed target, which is fine in tests). httptest.NewRecorder() returns a ResponseRecorder that implements http.ResponseWriter and keeps the status, headers and body. Pass them to handler.ServeHTTP and inspect rec.Result() — a normal *http.Response — or the shortcut fields rec.Code and rec.Body. Nothing listens on a port.

package api_test

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"testing"

	"example.com/shelf/api"
	"example.com/shelf/catalog"
)

var seeded *catalog.MemStore

// TestMain runs once per package: expensive setup goes here, and the exit
// code of m.Run must be handed to os.Exit.
func TestMain(m *testing.M) {
	seeded = catalog.NewMemStore()
	seeded.Put(catalog.Book{ISBN: "9780134190440", Title: "The Go Programming Language"})
	fmt.Fprintln(os.Stderr, "TestMain: store seeded")

	code := m.Run()

	fmt.Fprintln(os.Stderr, "TestMain: done")
	os.Exit(code)
}

func TestHandlerRecorder(t *testing.T) {
	tests := []struct {
		name       string
		target     string
		wantStatus int
		wantBody   string
	}{
		{"found", "/books/9780134190440", http.StatusOK, `{"isbn":"9780134190440","title":"The Go Programming Language"}` + "\n"},
		{"unknown", "/books/9781492077213", http.StatusNotFound, "404 page not found\n"},
		{"malformed", "/books/12", http.StatusBadRequest, "catalog: invalid ISBN: \"12\"\n"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, tt.target, nil)
			rec := httptest.NewRecorder()

			api.Handler(seeded).ServeHTTP(rec, req)

			res := rec.Result()
			body, _ := io.ReadAll(res.Body)
			if res.StatusCode != tt.wantStatus {
				t.Errorf("status = %d, want %d", res.StatusCode, tt.wantStatus)
			}
			if string(body) != tt.wantBody {
				t.Errorf("body = %q, want %q", body, tt.wantBody)
			}
		})
	}
}

func TestHandlerMethodNotAllowed(t *testing.T) {
	rec := httptest.NewRecorder()
	api.Handler(seeded).ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/books/9780134190440", nil))
	if rec.Code != http.StatusMethodNotAllowed {
		t.Errorf("status = %d, want 405", rec.Code)
	}
	t.Logf("Allow: %s", rec.Header().Get("Allow"))
}

func TestClientInMemory(t *testing.T) {
	srv := httptest.NewTestServer(t, api.Handler(seeded))
	c := &api.Client{BaseURL: "http://shelf.test", HTTP: srv.Client()}

	title, err := c.Title("9780134190440")
	if err != nil {
		t.Fatal(err)
	}
	if title != "The Go Programming Language" {
		t.Errorf("title = %q", title)
	}

	if _, err := c.Title("9781492077213"); err == nil {
		t.Error("unknown book: want an error")
	} else {
		t.Log(err)
	}
}

func TestClientLoopback(t *testing.T) {
	if testing.Short() {
		t.Skip("skipping loopback test in -short mode")
	}
	srv := httptest.NewTestServer(t, api.Handler(seeded))
	srv.Start()
	c := &api.Client{BaseURL: srv.URL, HTTP: srv.Client()}
	title, err := c.Title("9780134190440")
	if err != nil || title == "" {
		t.Fatalf("Title = %q, %v", title, err)
	}
	t.Logf("served from %s", srv.URL[:len("http://127.0.0.1")])
}
$ go test -v -count=1 ./api
TestMain: store seeded
=== RUN   TestHandlerRecorder
=== RUN   TestHandlerRecorder/found
=== RUN   TestHandlerRecorder/unknown
=== RUN   TestHandlerRecorder/malformed
--- PASS: TestHandlerRecorder (0.00s)
    --- PASS: TestHandlerRecorder/found (0.00s)
    --- PASS: TestHandlerRecorder/unknown (0.00s)
    --- PASS: TestHandlerRecorder/malformed (0.00s)
=== RUN   TestHandlerMethodNotAllowed
    api_test.go:66: Allow: GET, HEAD
--- PASS: TestHandlerMethodNotAllowed (0.00s)
=== RUN   TestClientInMemory
    api_test.go:84: title 9781492077213: unexpected status 404 Not Found
--- PASS: TestClientInMemory (0.00s)
=== RUN   TestClientLoopback
    api_test.go:99: served from http://127.0.0.1
--- PASS: TestClientLoopback (0.00s)
PASS
TestMain: done
ok  	example.com/shelf/api	0.209s

The ServeMux pattern gives 405 Method Not Allowed with an Allow header for free, which the third test confirms. A ResponseRecorder does not run the server’s connection logic: it does not enforce timeouts and does not exercise Server-level settings. For those, use a server.

Clients and end-to-end: httptest.NewTestServer (Go 1.27)

Before Go 1.27 the way to test an HTTP client was httptest.NewServer(handler): it starts a real listener on a loopback port, returns a *httptest.Server with a URL, and you defer srv.Close(). That still works, but its documentation now steers most tests to the new constructor.

httptest.NewTestServer(t testing.TB, handler http.Handler) *httptest.Server exists in Go 1.27 (it is listed in the Go 1.27 release notes, and go doc net/http/httptest on go1.27.2 shows it). It differs from NewServer in four ways:

  • the server uses an in-memory network by default: no port is opened, so there is no port exhaustion or flaky loopback, and the server works inside a testing/synctest bubble (see the Benchmarks, Fuzzing and synctest page);

  • srv.Client() returns an http.Client that sends every request to the server, whatever the host in the URL — the example above passes http://shelf.test as a base URL that does not resolve anywhere;

  • it registers a cleanup that shuts the server down, so there is no Close to forget;

  • it fails the test if the handler panics with anything other than http.ErrAbortHandler, and a nil handler serves 500 (it never falls back to http.DefaultServeMux).

To use a real loopback listener instead, call srv.Start() (or StartTLS()) on the NewTestServer result and use srv.URL, as TestClientLoopback does; it is then skipped under -short because it opens a socket. Configure the server (srv.Config fields, EnableHTTP2) before the first call to Client, Start or StartTLS.

The handler-panic rule is useful enough to see once. This handler panics, and the test fails with the handler’s stack instead of an opaque client error (middle of the stack shortened):

package srvpanic

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestHandlerPanics(t *testing.T) {
	h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		panic("boom")
	})
	srv := httptest.NewTestServer(t, h)
	resp, err := srv.Client().Get("http://shelf.test/")
	if err == nil {
		resp.Body.Close()
	}
	t.Logf("client saw error: %v", err != nil)
}
--- FAIL: TestHandlerPanics (0.00s)
    server.go:202: httptest: panic in server handler: boom
        goroutine 42 [running]:
        net/http/httptest.testServerHandler.ServeHTTP.func1()
        	.../src/net/http/httptest/server.go:201 +0x84
        panic({0x100dea1b8?, 0x100d80570?})
        	.../src/runtime/panic.go:859 +0x120
        example.com/srvpanic.TestHandlerPanics.func1({0x20627c7fda48?, 0x1007c0ee4?}, 0x0?)
        	srvpanic/p_test.go:11 +0x2c
        ...
    p_test.go:18: client saw error: true

What to assert in server tests: status code, Content-Type, the decoded body (not a byte-exact JSON string unless the format is the contract), and the headers the client relies on. Use http.MaxBytesReader and server timeouts in the production code and test them with large or slow requests; those belong to the handler, not to the test double.

Testing file systems: testing/fstest

Code that takes an fs.FS (see I/O, Files and the Filesystem) is easy to test because tests can supply an in-memory file system. testing/fstest.MapFS is a map[string]*fstest.MapFile that implements fs.FS, fs.ReadFileFS, fs.ReadDirFS, fs.GlobFS and fs.StatFS; parent directories are synthesised, and a MapFile can carry data, a mode and a modification time. The second half of load_test.go above tests LoadBooks this way:

func TestLoadBooksMapFS(t *testing.T) {
	fsys := fstest.MapFS{
		"a.txt":     {Data: []byte("9780134190440;The Go Programming Language\n")},
		"notes.md":  {Data: []byte("ignored")},
		"bad/b.txt": {Data: []byte("not read: only top-level files match")},
	}
	books, err := catalog.LoadBooks(fsys)
	if err != nil {
		t.Fatal(err)
	}
	if len(books) != 1 || books[0].Title != "The Go Programming Language" {
		t.Errorf("books = %+v", books)
	}
}

The golden test uses the real os.DirFS("testdata"), and MapFS covers the edge cases, with no files on disk. The map need not list parent directories (they are synthesised), and the third entry shows that the pattern *.txt matches only top-level names, so bad/b.txt is never read.

The other half of the package is a conformance checker. If you implement an fs.FS (a zip reader, an embedded tree, a remote store), fstest.TestFS(fsys, expected...) walks the whole tree and verifies that Open, ReadDir, Stat, Glob and friends agree with each other, returning an error that lists every misbehaviour. The idiom is a single test:

// Fragment: assert that an implementation behaves like a file system.
if err := fstest.TestFS(myFS, "file/that/should/be/present"); err != nil {
	t.Fatal(err)
}

TestFS also works on embed.FS and os.DirFS, which makes it a handy sanity check for a //go:embed tree (see Directives, Code Generation and Embedding). It was extended in Go 1.25 to verify fs.ReadLinkFS. Both helpers are listed in the package documentation.

Source: testing/fstest

Testing log/slog handlers: testing/slogtest

log/slog handlers have a precise contract (groups, WithAttrs, zero times, resolved values, inline groups). The package testing/slogtest (added in Go 1.21) runs a standard battery of cases against any slog.Handler so a custom handler can be checked against the contract. slogtest.Run(t, newHandler, result) creates a fresh handler per case via newHandler, runs the case as a subtest, then calls result to decode what the handler wrote into a map[string]any. The older slogtest.TestHandler(h, results) runs everything in one test and returns an error.

The example validates the standard library’s JSONHandler — the harness is the same one you point at your own handler:

package shelflog_test

import (
	"bytes"
	"encoding/json"
	"log/slog"
	"testing"
	"testing/slogtest"
)

func TestJSONHandler(t *testing.T) {
	var buf bytes.Buffer
	newHandler := func(*testing.T) slog.Handler {
		buf.Reset()
		return slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
	}
	result := func(t *testing.T) map[string]any {
		m := map[string]any{}
		if err := json.Unmarshal(buf.Bytes(), &m); err != nil {
			t.Fatal(err)
		}
		return m
	}
	slogtest.Run(t, newHandler, result)
}
$ go test -count=1 -v ./internal/shelflog
=== RUN   TestJSONHandler
=== RUN   TestJSONHandler/built-ins
=== RUN   TestJSONHandler/attrs
...
=== RUN   TestJSONHandler/empty-PC
--- PASS: TestJSONHandler (0.00s)
    --- PASS: TestJSONHandler/built-ins (0.00s)
    --- PASS: TestJSONHandler/attrs (0.00s)
...
PASS
ok  	example.com/shelf/internal/shelflog	0.062s

The 17 subtests (the middle of the listing is shortened) include groups, WithGroup, multi-With, resolve and empty-PC. For the application’s own logging, tests usually build a slog.Logger over a handler that writes to a bytes.Buffer (with the time attribute removed through HandlerOptions.ReplaceAttr) and assert on the output; see Structured Logging with slog.

Testing command-line programs: testscript

For end-to-end tests of a main package — arguments, standard input, exit status, files — the community package github.com/rogpeppe/go-internal/testscript runs small scripts in the txtar format against your program, in a clean temporary directory per script, with testscript.Main registering your main as a command inside the test binary so no separate build step is needed. It is a third-party module and is not part of the standard library; the commands (exec, stdout, cmp, !) and a complete example of the main_test.go and .txtar files are on the testscript for Go section of the Testing CLIs page, and the package is documented on pkg.go.dev. testscript needs TestMain (to register commands), which is one of the few good reasons to have one.

How go test runs a package

go test [build and test flags] [packages] [-args test-binary-flags] has two modes with very different behaviour:

  • Local directory mode — no package argument (go test, go test -v). It tests the package in the current directory and disables the test cache.

  • Package list mode — explicit arguments (go test ., go test ./..., go test example.com/shelf/catalog). It builds and tests each package, prints only a one-line ok for passing packages (the full output for failures, or with -v or -bench), and caches successful results.

flowchart TD A["go test [flags] [packages]"] --> B{"package arguments given?"} B -- "no: local directory mode" --> C["use the package in the current directory"] B -- "yes: package list mode" --> D["resolve patterns, skip testdata and _ dirs"] C --> E["compile package + _test.go files + generated test main"] D --> E E --> F{"compile or setup error?"} F -- yes --> G["print compiler error: FAIL [build failed] or [setup failed]"] F -- no --> H["run the vet subset: atomic, bools, buildtag, directive, errorsas, ifaceassert, nilfunc, printf, stdversion, stringintconv, tests"] H --> I{"vet reported problems?"} I -- yes --> G I -- no --> J{"package list mode and only cacheable flags and same binary, files and env?"} J -- "yes: cache hit" --> K["replay the saved output, print (cached)"] J -- "no" --> L["run pkg.test in the package directory with -test.* flags"] L --> M{"all tests pass?"} M -- yes --> N["print ok pkg time, save result in the test cache"] M -- no --> O["print full output, FAIL pkg, exit status 1"]

The "generated test main" in the diagram is a small main package that go test writes; it imports the package and the test packages, lists the Test, Benchmark, Fuzz and Example functions, and calls testing.MainStart (or your TestMain). go test -c stops after the compile and writes the binary to pkg.test; -o file names it and still runs it. The binary can be run by hand (with -test. prefixed flags, from the package directory) or moved to another machine:

$ go test -c -o catalog.test ./catalog
$ cd catalog && ../catalog.test -test.run 'TestLend$' -test.v
=== RUN   TestLend
--- PASS: TestLend (0.00s)
PASS

The test cache

In package list mode a passing result is cached and replayed:

$ go test ./catalog
ok  	example.com/shelf/catalog	0.051s
$ go test ./catalog
ok  	example.com/shelf/catalog	(cached)

A cached result is reused only when the test binary is identical, the command line consists of cacheable flags (-benchtime, -coverprofile, -cpu, -failfast, -fullpath, -list, -outputdir, -parallel, -run, -short, -skip, -timeout and -v), and the test has not observed anything different: files it opened inside the module and environment variables it read must be unchanged. Using any other flag disables caching; the idiomatic way is -count=1. go clean -testcache expires every cached result. Failed results are never cached.

The cache sees the environment and files that the test consulted, not those it ignored. Both effects are visible with the shelf module. Changing a variable that a test reads re-runs it, and the old result stays valid for the old value:

$ SHELF_DATA=x go test ./internal/lifecycle
test body
cleanup 2 (registered last, runs first)
cleanup 1 (registered first, runs last)
--- FAIL: TestDataDirDefault (0.00s)
    lifecycle_test.go:25: DataDir() = "x", want "data"
file still exists inside Cleanup: true
worker saw cancellation: context canceled
FAIL
FAIL	example.com/shelf/internal/lifecycle	0.071s
FAIL
$ go test ./internal/lifecycle
ok  	example.com/shelf/internal/lifecycle	(cached)

Editing a file that the test opened (testdata/books.txt) likewise invalidates the cached result, as the golden-file failure earlier showed. In local directory mode go test never says (cached):

$ cd catalog && go test && go test
PASS
ok  	example.com/shelf/catalog	0.067s
PASS
ok  	example.com/shelf/catalog	0.076s

Two cautions. Tests that depend on something the cache cannot see — a database, the network, the clock, a file outside the module — should be run with -count=1 in CI (or never be cacheable); a green (cached) does not mean the external system works today. And -timeout does not apply to a cached result.

The vet subset

Before running the binary, go test runs go vet with a high-confidence subset of checks on the package and its test files, and reports failures as a build error. In Go 1.27 the subset is atomic, bools, buildtag, directive, errorsas, ifaceassert, nilfunc, printf, stdversion, stringintconv and tests; stdversion was added to the default set in Go 1.27. The -vet flag changes it: -vet=off disables vet, -vet=all runs every check, and -vet=printf,atomic selects a list. This module has a wrongly typed Logf verb, a Fatal call from a goroutine and a copied mutex:

package vetfail

import "testing"

func TestVet(t *testing.T) {
	t.Logf("value: %d", "text")
	go func() {
		t.Fatal("from another goroutine")
	}()
}

func TestCopy(t *testing.T) {
	var c Counter
	_ = Copy(c)
}
$ go test -count=1 .
# example.com/vetfail
# [example.com/vetfail]
./v_test.go:6:17: (*testing.common).Logf format %d has arg "text" of wrong type string
FAIL	example.com/vetfail [build failed]
FAIL
$ go test -count=1 -vet=off .
ok  	example.com/vetfail	0.189s
$ go vet .
v.go:10:13: Copy passes lock by value: example.com/vetfail.Counter contains sync.Mutex
v_test.go:14:11: call of Copy copies lock value: example.com/vetfail.Counter contains sync.Mutex
v_test.go:6:17: (*testing.common).Logf format %d has arg "text" of wrong type string
v_test.go:8:3: call to (*testing.T).Fatal from a non-test goroutine

Only the printf problem is in the default subset, so go test stops there; -vet=off lets the faulty tests run, which is a bad habit. go vet finds the lock copy (copylocks) and the goroutine misuse (testinggoroutine) that the subset does not include. Run go vet ./... in CI as well as go test; see Formatting, vet, fix and Static Analysis.

The stdversion check reports standard-library symbols that are newer than the Go version of the module. With go 1.23 in go.mod, strings.SplitSeq (added in Go 1.24) and a malformed test signature produce:

# example.com/stdv
# [example.com/stdv]
./s_test.go:9:28: strings.SplitSeq requires go1.24 or later (module is go1.23)
FAIL	example.com/stdv [build failed]
FAIL

And a test function whose signature is wrong is reported at setup, before anything is built or run:

# example.com/stdv
s_test.go:16:1: wrong signature for TestBadSig, must be: func TestBadSig(t *testing.T)
FAIL	example.com/stdv [setup failed]
FAIL

(The path in the second message is shortened to the file name.) A compile error in a test file is reported the same way, with [build failed]:

# example.com/buildfail [example.com/buildfail.test]
./b_test.go:6:6: declared and not used: unused
FAIL	example.com/buildfail [build failed]
FAIL

Test flags

Flags before the package list and flags after it are both accepted for flags go test knows; flags it does not know (your own -update) must come after the package list, or after -args, which stops go test from interpreting the rest of the command line. Flags can be written with the optional test. prefix (-test.v); when running a compiled pkg.test the prefix is mandatory.

Flag Meaning

-run regexp

Run matching tests, examples and fuzz targets; split on / for subtests (see above).

-skip regexp

Run those that do not match (1.20).

-list regexp

Print matching top-level test, benchmark, fuzz and example names without running them.

-v

Verbose: === RUN, --- PASS and every t.Log line. Required to see logs of passing tests.

-count n

Run each test n times (default 1); -count=1 also bypasses the cache. Examples always run once.

-short

Sets testing.Short(); long tests call t.Skip. Off by default.

-failfast

Do not start new tests after the first failure (running parallel tests finish).

-shuffle off,on,N

Randomise the order of tests and benchmarks (1.17); the seed is printed (-test.shuffle 7) so a failure can be reproduced with -shuffle=7.

-parallel n

Maximum number of tests running t.Parallel in one binary (default GOMAXPROCS).

-cpu 1,2,4

Run the tests once per listed GOMAXPROCS value.

-timeout d

Panic if the binary runs longer than d (default 10 minutes; 0 disables).

-fullpath

Show full file names in messages (1.21).

-vet list

Choose the vet checks (see above).

-json

Machine-readable event stream (below).

-race, -msan, -asan

Build flags: enable the race detector or memory/address sanitizers.

-cover, -covermode, -coverpkg, -coverprofile

Coverage (below).

-artifacts, -outputdir dir

Keep t.ArtifactDir files and profiles in dir (1.26).

-bench, -benchtime, -benchmem, -fuzz, -fuzztime

Benchmarks and fuzzing; see the Benchmarks, Fuzzing and synctest page.

-cpuprofile, -memprofile, -blockprofile, -mutexprofile, -trace

Write profiles and traces; see Profiling, Tracing and Diagnostics.

-c, -o file, -exec xprog, -args

Compile only, name the binary, run it through a wrapper (such as an emulator), pass remaining arguments to the binary.

Two flags deserve a demonstration. -shuffle prints its seed first, so an order-dependent failure can be replayed:

$ go test -shuffle=7 -v ./catalog | head -8
-test.shuffle 7
=== RUN   TestLoadBooksGolden
--- PASS: TestLoadBooksGolden (0.00s)
=== RUN   TestParseISBN
--- PASS: TestParseISBN (0.00s)
=== RUN   TestLend
--- PASS: TestLend (0.00s)
=== RUN   TestLendNotifyFails

-failfast stops after the first failing test, which keeps a large run short while you fix things. On the example.com/fail module it ran TestErrorContinues, saw it fail and did not start TestFatalStops:

--- FAIL: TestErrorContinues (0.00s)
    fail_test.go:13: Double(2) = 4, want 5
    fail_test.go:14: Double(3) = 6, want 7
    fail_test.go:15: still running after Errorf
FAIL
FAIL	example.com/fail	0.069s
FAIL

-timeout turns a hang into a stack dump: the alarm panics the test binary and prints every goroutine, with the names of the tests still running at the top. For a test that sleeps for five seconds under -timeout 1s:

panic: test timed out after 1s
	running tests:
		TestSlow (1s)

goroutine 19 [running]:
testing.(*M).startAlarm.func1()
	.../src/testing/testing.go:2961 +0x2c4
...

(Shortened.) Package-level defaults can be put in the GOFLAGS environment variable or go env -w GOFLAGS=-count=1; flags given on the command line win.

go test -json and test2json

go test -json emits one JSON object per line — start, run, output, pass, fail, skip, and since Go 1.25 attr — and is the format IDEs, CI dashboards and tools such as gotestsum consume. Each event has Time, Action, Package, Test, Output and Elapsed fields. Go 1.27 adds an optional OutputType to output events (frame, error or error-continue) so tools can distinguish the framework’s own lines from the test’s output. The command below drops the timestamp with jq for a stable listing:

$ go test -json -count=1 -run 'TestParseISBN$|ExampleParseISBN' ./catalog | jq -c 'del(.Time)'
{"Action":"start","Package":"example.com/shelf/catalog"}
{"Action":"run","Package":"example.com/shelf/catalog","Test":"TestParseISBN"}
{"Action":"output","Package":"example.com/shelf/catalog","Test":"TestParseISBN","Output":"=== RUN   TestParseISBN\n","OutputType":"frame"}
{"Action":"output","Package":"example.com/shelf/catalog","Test":"TestParseISBN","Output":"--- PASS: TestParseISBN (0.00s)\n","OutputType":"frame"}
{"Action":"pass","Package":"example.com/shelf/catalog","Test":"TestParseISBN","Elapsed":0}
{"Action":"run","Package":"example.com/shelf/catalog","Test":"ExampleParseISBN"}
{"Action":"output","Package":"example.com/shelf/catalog","Test":"ExampleParseISBN","Output":"=== RUN   ExampleParseISBN\n","OutputType":"frame"}
{"Action":"output","Package":"example.com/shelf/catalog","Test":"ExampleParseISBN","Output":"--- PASS: ExampleParseISBN (0.00s)\n","OutputType":"frame"}
{"Action":"pass","Package":"example.com/shelf/catalog","Test":"ExampleParseISBN","Elapsed":0}
{"Action":"output","Package":"example.com/shelf/catalog","Output":"PASS\n","OutputType":"frame"}
{"Action":"output","Package":"example.com/shelf/catalog","Output":"ok  \texample.com/shelf/catalog\t0.191s\n"}
{"Action":"pass","Package":"example.com/shelf/catalog","Elapsed":0.192}

The t.Attr call of the lifecycle example shows up as a dedicated event:

{"Action":"attr","Package":"example.com/shelf/internal/lifecycle","Test":"TestAttrAndOutput","Key":"owner","Value":"catalog-team"}

When a test binary is run by hand, go tool test2json -p pkg ./pkg.test -test.v=test2json converts its output the same way; go test -json is preferred because it also handles build output (go help buildjson) and several packages.

The race detector

go test -race builds the test binary with the race detector, which instruments memory accesses and reports two goroutines touching the same variable without a happens-before edge, at least one of them writing. It slows execution several times and uses more memory, so it belongs in CI and in pre-merge runs, not every edit. It is available only on some platform combinations (it worked on the darwin/arm64 machine used for this page; go help build documents the flag). Besides racing tests, it catches races between a test and a leftover goroutine from an earlier test.

package par

import (
	"sync"
	"testing"
)

func TestRace(t *testing.T) {
	var wg sync.WaitGroup
	count := 0
	for range 2 {
		wg.Go(func() { count++ })
	}
	wg.Wait()
	t.Log("count", count)
}
==================
WARNING: DATA RACE
Read at 0x00c0000122d8 by goroutine 8:
  example.com/par.TestRace.func1()
      .../par/race_test.go:12 +0x2c

Previous write at 0x00c0000122d8 by goroutine 9:
  example.com/par.TestRace.func1()
      .../par/race_test.go:12 +0x3c

Goroutine 8 (running) created at:
  sync.(*WaitGroup).Go()
      .../src/sync/waitgroup.go:238 +0x6c
  testing.tRunner()
      .../src/testing/testing.go:2195 +0x164
...
==================
--- FAIL: TestRace (0.00s)
    race_test.go:15: count 2
    testing.go:1867: race detected during execution of test
FAIL

(Paths are shortened, and the second goroutine’s creation stack is omitted.) A race makes the test fail even when every assertion passed — here count printed 2. The fix is a mutex, atomic.Int64 or channel; see Sync and Atomics and Memory Model and Data Races. -race also changes the default coverage mode to atomic.

Short tests, build tags and integration tests

Tests differ in cost and in what they need from the environment. Go gives two switches to separate them:

  • -short: a convention, not a mechanism. A slow or resource-hungry test calls if testing.Short() { t.Skip("...") } and runs under plain go test but not go test -short. The api package does this for its loopback server, shown earlier:

    $ go test -short -v -count=1 -run Loopback ./api
    TestMain: store seeded
    === RUN   TestClientLoopback
        api_test.go:90: skipping loopback test in -short mode
    --- SKIP: TestClientLoopback (0.00s)
    PASS
    TestMain: done
    ok  	example.com/shelf/api	0.077s
  • Build constraints: a test file that needs a real database, container or built binary starts with //go:build integration (followed by a blank line). It is invisible to go test ./... and is compiled only when you ask for it with -tags integration. This keeps the default developer loop fast and dependency-free, and CI runs both. Use //go:build, not the pre-Go-1.17 // +build form.

cmd/shelf/integration_test.go
//go:build integration

package main_test

import (
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
)

func TestBinary(t *testing.T) {
	bin := filepath.Join(t.TempDir(), "shelf")
	if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
		t.Fatalf("build: %v\n%s", err, out)
	}
	out, err := exec.CommandContext(t.Context(), bin).Output()
	if err != nil {
		t.Fatal(err)
	}
	if got := strings.TrimSpace(string(out)); got != "1 on loan" {
		t.Errorf("output = %q", got)
	}
}
$ go test -count=1 ./cmd/shelf
?   	example.com/shelf/cmd/shelf	[no test files]
$ go test -count=1 -tags integration -v ./cmd/shelf
=== RUN   TestBinary
--- PASS: TestBinary (0.27s)
PASS
ok  	example.com/shelf/cmd/shelf	0.455s
$ go test -count=1 -tags integration -list . ./cmd/shelf
TestBinary
ok  	example.com/shelf/cmd/shelf	0.070s

The test builds the real binary into t.TempDir() and runs it with the test’s context (t.Context()), so the process is killed if the test is cancelled. Because the build tag is part of the build, it must also be passed to go vet -tags integration ./... or the file is never type-checked. Tests that need credentials read them from the environment and call t.Skip when absent; see Directives, Code Generation and Embedding and The go Command, Builds and Cross-Compilation for build constraints and file-name suffixes such as _linux_test.go.

Coverage

Go’s coverage tool rewrites the source before compiling, adding a counter to every basic block, so no instrumentation or agent is needed. The simplest use is the -cover flag, which prints a percentage per package:

$ go test -count=1 -cover ./...
ok  	example.com/shelf/api	0.208s	coverage: 91.3% of statements
ok  	example.com/shelf/catalog	0.175s	coverage: 92.6% of statements
	example.com/shelf/cmd/shelf		coverage: 0.0% of statements
	example.com/shelf/cmd/shelfd		coverage: 0.0% of statements
ok  	example.com/shelf/internal/lifecycle	0.178s	coverage: 100.0% of statements
	example.com/shelf/internal/textutil		coverage: 0.0% of statements

Packages without test files are listed with 0.0%. By default each test run measures only the package it tests; -coverpkg widens the measurement, for example to see how much of catalog an HTTP test exercises:

$ go test -count=1 -coverpkg=./... -cover ./api
ok  	example.com/shelf/api	0.211s	coverage: 46.2% of statements in ./...
-covermode Meaning

set

Boolean: was the statement executed? The default.

count

How many times each statement ran.

atomic

Like count, but safe with concurrent tests; the default under -race, and noticeably slower.

Profiles and reports

-coverprofile=file writes a text profile (and implies -cover). go tool cover turns it into a per-function table or an HTML page that highlights uncovered code:

$ go test -count=1 -coverprofile=cover.out ./catalog
ok  	example.com/shelf/catalog	0.074s	coverage: 92.6% of statements
$ head -3 cover.out
mode: set
example.com/shelf/catalog/catalog.go:19.2,20.18 2 1
example.com/shelf/catalog/catalog.go:21.3,22.1 1 1
$ go tool cover -func=cover.out
example.com/shelf/catalog/catalog.go:18:	ParseISBN	100.0%
example.com/shelf/catalog/catalog.go:40:	String		100.0%
example.com/shelf/catalog/catalog.go:61:	String		80.0%
example.com/shelf/catalog/catalog.go:94:	NewMemStore	100.0%
example.com/shelf/catalog/catalog.go:96:	Get		100.0%
example.com/shelf/catalog/catalog.go:103:	Put		100.0%
example.com/shelf/catalog/catalog.go:109:	All		100.0%
example.com/shelf/catalog/lend.go:30:		Lend		100.0%
example.com/shelf/catalog/load.go:10:		LoadBooks	81.0%
total:						(statements)	92.6%
$ go tool cover -html=cover.out -o cover.html

Each profile line reads file:startLine.startCol,endLine.endCol numberOfStatements count (with -covermode=count the last field is the number of executions). go tool cover -html writes a self-contained page, or opens a browser when -o is omitted. Coverage is a signal about what has not been run, not a proof of correctness: 100% statement coverage says nothing about assertions. Set thresholds for trends, not as a target.

Coverage for programs and integration tests: go build -cover and GOCOVERDIR

Unit-test coverage only covers code that tests call. Since Go 1.20 the same instrumentation can be built into a program: go build -cover produces an instrumented binary, and every run of it writes counter and metadata files into the directory named by the GOCOVERDIR environment variable when the program exits normally. Run it from scripts, a testscript suite or an integration test, then merge the data with go tool covdata.

$ go build -cover -o shelf-cov ./cmd/shelf
$ mkdir covdata
$ GOCOVERDIR=covdata ./shelf-cov
1 on loan
$ ls covdata
covcounters.f71aebfb1ec8a06ba25590d4f43ffb6e.10238.1792281806398708000
covmeta.f71aebfb1ec8a06ba25590d4f43ffb6e
$ go tool covdata percent -i=covdata
	example.com/shelf/catalog		coverage: 33.8% of statements
	example.com/shelf/cmd/shelf		coverage: 100.0% of statements
	example.com/shelf/internal/textutil		coverage: 100.0% of statements
$ go tool covdata textfmt -i=covdata -o profile.txt
$ go tool cover -func=profile.txt
example.com/shelf/catalog/catalog.go:18:		ParseISBN	78.6%
example.com/shelf/catalog/catalog.go:40:		String		0.0%
...
example.com/shelf/cmd/shelf/main.go:10:			main		100.0%
example.com/shelf/internal/textutil/textutil.go:7:	Normalize	100.0%
total:							(statements)	38.4%

If GOCOVERDIR is unset the instrumented binary prints a warning and discards the data. go tool covdata has subcommands percent, pkglist, func, textfmt, merge, subtract, intersect and debugdump; merge combines the output of many runs (different inputs, different machines) into one directory, so integration scenarios add up. The -covermode and -coverpkg build flags apply to go build -cover as well, so the instrumented binary can measure other packages of the module.

Pitfalls and how Go reports them

Mistake What happens and the fix

Test named Testparse or with the wrong signature

Not run, or wrong signature for TestBadSig, must be: func TestBadSig(t *testing.T) at setup. Capitalise after Test and use t *testing.T.

Fixture helper reports the helper’s line

Call t.Helper() first in the helper.

t.Fatal in a goroutine the test started

go vet (testinggoroutine) reports it; Fatal only stops the goroutine it runs in. Send the error back over a channel and call Fatal in the test goroutine.

Test passes with (cached) but the database changed

Cache keys do not cover external systems. Use -count=1 in CI for such packages.

t.Setenv or t.Chdir in a parallel test

Panics. Keep those tests sequential, or pass configuration explicitly.

Parallel subtests share a loop variable (module below go 1.22)

Every subtest sees the last case. Raise the go line; do not copy with tt := tt in new code.

TestMain forgets os.Exit(m.Run())

Since 1.15 harmless when it returns, but code after m.Run() that calls os.Exit(0) hides failures. Always pass the exit code.

A test writes into the source directory

Fails on read-only module cache and pollutes the work tree. Use t.TempDir().

Example output depends on map iteration order

Use // Unordered output: or sort first.

time.Sleep to wait for a goroutine

Slow and flaky. Wait on a channel, sync.WaitGroup or testing/synctest (Benchmarks, Fuzzing and synctest).

Race detected although assertions pass

The test fails with race detected during execution of test. Fix the synchronisation.

go vet not run with the integration tag

The tagged file is never type-checked. Pass -tags integration to vet too.

Test relies on test order

Run -shuffle=on regularly; the seed is printed.

Version notes

Release Testing-related change

1.7

Subtests and sub-benchmarks: t.Run, b.Run. context enters the standard library.

1.9

t.Helper.

1.10

The test cache, go test -json and go tool test2json, -failfast; go test runs a vet subset.

1.14

t.Cleanup.

1.15

t.TempDir; TestMain no longer has to call os.Exit.

1.17

t.Setenv; -shuffle; //go:build replaces // +build.

1.18

Fuzzing.

1.20

-skip; coverage for programs with go build -cover and GOCOVERDIR.

1.21

-fullpath; testing.Testing(); testing/slogtest.

1.22

Per-iteration loop variables; slogtest.Run.

1.24

t.Context, t.Chdir, b.Loop.

1.25

t.Attr, t.Output; testing/synctest generally available; AllocsPerRun panics with parallel tests; fstest.TestFS checks ReadLinkFS.

1.26

t.ArtifactDir and -artifacts.

1.27

httptest.NewTestServer; stdversion joins the go test vet subset; OutputType in go test -json.

Go 1.28 is not released at the time of writing; nothing on this page is claimed for it.

Where the books are outdated

Bodner, chapter 13, was written for Go 1.15 and presents // +build tags for integration tests (the form was replaced by //go:build in Go 1.17 and gofmt rewrites it), the original -short and -race practices (still valid), TestMain needing a manual os.Exit (optional since 1.15), and a b.N loop for benchmarks (replaced by b.Loop in Go 1.24, covered on the Benchmarks, Fuzzing and synctest page). It predates t.Setenv (1.17), t.Context and t.Chdir (1.24), t.Attr and t.Output (1.25) and t.ArtifactDir (1.26), fuzzing (1.18), testing/synctest, and the integration-binary coverage workflow of Go 1.20; it also predates the per-iteration loop variable (1.22), which removes the tt := tt idiom it teaches. Its advice to use go-cmp for deep comparison remains sound.

Vivien, chapter 12, was written for Go 1.6/1.7 and the GOPATH workflow. It shows tests, -run, -v, -cover and -coverprofile, httptest and benchmarks, all of which still work, but it runs go test inside a GOPATH/src tree where today a module and a go.mod are required; it predates subtests (1.7 shipped them), t.Helper, t.Cleanup, t.TempDir, the test cache, fuzzing and go test -json. Its httptest examples build a real server with NewServer; Go 1.27 adds NewTestServer with an in-memory network and automatic cleanup. Pages in this section use go test in a module with the current helpers.