Menu

Golang Testing: Unit Tests, Table Tests and Benchmarks

How to test Go code with the standard testing package and go test: _test.go files, TestXxx functions, t.Errorf versus t.Fatalf, table-driven tests with t.Run, helpers and temp dirs, coverage, benchmarks with b.Loop, and example tests.

This page includes runnable editors - edit, run, and see output instantly.

The code under test

Go's testing tools are part of the standard toolchain: the testing package and the go test command. No framework to install, no assertion library required.

The examples on this page test one small function, Slugify, which turns a title into a URL slug. Here it is, runnable, with a hand-rolled check in main:

The browser editor runs main; it cannot run go test. Everything below is written as it lives in a real project, with the terminal output shown after each command. All of it was run with Go 1.24.

The first test

In a project the function lives in a package, and its tests live next to it in a file whose name ends in _test.go:

textutil/
├── go.mod          module example.com/textutil
├── slug.go         package textutil, func Slugify
└── slug_test.go    package textutil, the tests
// slug_test.go
package textutil

import "testing"

func TestSlugify(t *testing.T) {
	got := Slugify("Hello, World!")
	want := "hello-world"
	if got != want {
		t.Errorf("Slugify(%q) = %q, want %q", "Hello, World!", got, want)
	}
}

The rules go test uses:

  • Only files ending in _test.go are test files. go build ignores them, so test code never ends up in your binary.
  • A test is a function named TestXxx (the part after Test must not start with a lowercase letter) that takes one *testing.T.
  • A test passes unless it calls one of the failure methods or panics.
go test          # the package in the current directory
go test ./...    # every package in the module
$ go test
PASS
ok  	example.com/textutil	0.318s

The failure message format Func(input) = got, want expected is the Go convention. Put the input in the message: a failure reading only got "a", want "b" sends you back to the code to find out which input it was.

t.Errorf versus t.Fatalf

MethodMarks failedStops the test
t.Error, t.Errorfyesno, keeps running
t.Fatal, t.Fatalfyesyes, immediately
t.Log, t.Logfnono, prints only with -v or on failure
t.Skip, t.Skipfnoyes, reported as skipped

Use Errorf by default, so one run reports every wrong field. Use Fatalf when the rest of the test cannot work, typically after an unexpected error:

func TestCountWords(t *testing.T) {
	path := writeFile(t, "doc.txt", "the quick brown\nfox  jumps\n")

	got, err := CountWords(path)
	if err != nil {
		t.Fatalf("CountWords: unexpected error: %v", err) // stop: got is meaningless
	}
	if got != 5 {
		t.Errorf("CountWords = %d, want 5", got)
	}
}

Fatal stops the test by calling runtime.Goexit, so it must be called from the test's own goroutine. From a goroutine you started, report with t.Error and return.

Table-driven tests with t.Run

Most Go tests are tables: a slice of cases, one loop, and t.Run to give each case its own name.

func TestSlugifyTable(t *testing.T) {
	tests := []struct {
		name, in, want string
	}{
		{"simple", "Go Testing", "go-testing"},
		{"punctuation", "What's new in Go 1.24?", "what-s-new-in-go-1-24"},
		{"extra spaces", "  lots   of   space  ", "lots-of-space"},
		{"non-ascii dropped", "Café au lait", "caf-au-lait"},
		{"empty", "", ""},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Slugify(tt.in); got != tt.want {
				t.Errorf("Slugify(%q) = %q, want %q", tt.in, got, tt.want)
			}
		})
	}
}

Adding a case is one line. Each subtest is reported separately, a Fatal inside one ends only that subtest, and you can run a single one by name. Run everything with -v:

$ go test -v
=== RUN   TestSlugify
--- PASS: TestSlugify (0.00s)
=== RUN   TestSlugifyTable
=== RUN   TestSlugifyTable/simple
=== RUN   TestSlugifyTable/punctuation
=== RUN   TestSlugifyTable/extra_spaces
=== RUN   TestSlugifyTable/non-ascii_dropped
=== RUN   TestSlugifyTable/empty
--- PASS: TestSlugifyTable (0.00s)
    --- PASS: TestSlugifyTable/simple (0.00s)
    --- PASS: TestSlugifyTable/punctuation (0.00s)
    --- PASS: TestSlugifyTable/extra_spaces (0.00s)
    --- PASS: TestSlugifyTable/non-ascii_dropped (0.00s)
    --- PASS: TestSlugifyTable/empty (0.00s)
=== RUN   TestCountWords
--- PASS: TestCountWords (0.00s)
=== RUN   TestCountWordsMissingFile
--- PASS: TestCountWordsMissingFile (0.00s)
=== RUN   ExampleSlugify
--- PASS: ExampleSlugify (0.00s)
PASS
ok  	example.com/textutil	0.182s

Spaces in subtest names become underscores. When a case fails, the output names the subtest and the line:

--- FAIL: TestSlugifyTable (0.00s)
    --- FAIL: TestSlugifyTable/underscore (0.00s)
        slug_test.go:27: Slugify("snake_case_name") = "snake-case-name", want "snake_case_name"
FAIL
FAIL	example.com/textutil	0.289s

Since Go 1.22 each loop iteration has its own tt, so closures inside t.Run see the right case even when subtests run in parallel. Older code often has tt := tt at the top of the loop body for that reason; it is no longer needed.

To run subtests in parallel, call t.Parallel() at the start of the subtest function. Only do it when cases are independent and slow enough for it to matter.

go test flags you will use

CommandDoes
go test -vprint every test name, result and t.Log output
go test -run TestSlugifyrun tests whose names match the regular expression
go test -run 'TestSlugifyTable/empty'run one subtest
go test -count=1ignore cached results and run again
go test -racerun with the data race detector
go test -shorttell long tests to skip themselves (if testing.Short() { t.Skip() })
go test -failfaststop after the first failing test
go test -timeout 30sfail if the run takes longer (default 10 minutes)
go test -coverprint statement coverage
go test -bench=.also run benchmarks

When you name packages (go test ., go test ./...), go test caches results for packages whose code and inputs have not changed and prints (cached) after them. Plain go test with no arguments never caches. The cache tracks the files and environment variables a test reads through the os package, but not outside state such as a database or a network service, so a test that depends on one can pass from the cache when it would fail now; -count=1 forces a real run.

Helpers, temp dirs and cleanup

The writeFile call in TestCountWords above is a test helper:

// writeFile is a test helper: t.Helper makes failures point at the caller.
func writeFile(t *testing.T, name, content string) string {
	t.Helper()
	path := filepath.Join(t.TempDir(), name) // removed automatically after the test
	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
		t.Fatalf("writing %s: %v", name, err)
	}
	return path
}
  • t.Helper() marks the function as a helper, so a failure is reported at the line in the test that called it, not inside the helper.
  • t.TempDir() creates a fresh directory and deletes it when the test ends. Tests never need to clean up files themselves.
  • t.Cleanup(func() { ... }) registers any other teardown (close a server, drop a table). Cleanups run after the test and its subtests, last registered first.
  • t.Setenv("KEY", "value") sets an environment variable for this test only and restores it afterwards.
  • t.Context() (Go 1.24) returns a context that is cancelled just before the cleanups run.

Test fixture files go in a directory named testdata next to the tests. The go tool ignores it as a package, and tests run with the package directory as the working directory, so os.ReadFile("testdata/input.json") works.

Testing errors and HTTP handlers

Check errors with errors.Is or errors.As, the same way the calling code would:

func TestCountWordsMissingFile(t *testing.T) {
	_, err := CountWords(filepath.Join(t.TempDir(), "nope.txt"))
	if !errors.Is(err, fs.ErrNotExist) {
		t.Errorf("err = %v, want fs.ErrNotExist", err)
	}
}

Comparing error strings breaks as soon as someone rewords a message.

For HTTP handlers, net/http/httptest gives you a fake ResponseWriter so you can call the handler directly, with no network:

func TestHealth(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/health", nil)
	rec := httptest.NewRecorder()

	healthHandler(rec, req)

	if rec.Code != http.StatusOK {
		t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
	}
	if body := rec.Body.String(); body != "ok\n" {
		t.Errorf("body = %q, want %q", body, "ok\n")
	}
}

httptest.NewServer starts a real server on localhost for testing client code. The HTTP client page uses it for every example.

Coverage

$ go test -cover
PASS
coverage: 100.0% of statements
ok  	example.com/textutil	0.578s

$ go test -coverprofile=cover.out
$ go tool cover -func=cover.out
example.com/textutil/slug.go:11:	Slugify		100.0%
example.com/textutil/words.go:9:	CountWords	100.0%
total:					(statements)	100.0%

$ go tool cover -html=cover.out    # opens a browser with covered lines in green

Coverage tells you which lines never ran, which is useful for finding untested branches. A high number does not tell you the assertions are any good: a test that calls every function and checks nothing reaches 100%.

Benchmarks

A benchmark is func BenchmarkXxx(b *testing.B). Go 1.24 added b.Loop, which is now the recommended form:

func BenchmarkSlugify(b *testing.B) {
	for b.Loop() {
		Slugify("The Go Programming Language, 2nd Edition")
	}
}

b.Loop runs the body as many times as needed for a stable measurement, excludes setup code before the loop from the timing, and keeps the compiler from optimizing the call away. Code written before Go 1.24 uses for i := 0; i < b.N; i++, which still works but needs b.ResetTimer() after expensive setup and can be fooled by dead-code elimination.

Benchmarks do not run with plain go test. Ask for them, and skip the regular tests with -run='^$':

$ go test -bench=. -benchmem -run='^$'
goos: darwin
goarch: arm64
pkg: example.com/textutil
cpu: Apple M4
BenchmarkSlugify-10    	 5061945	       238.1 ns/op	     168 B/op	       5 allocs/op
PASS
ok  	example.com/textutil	1.410s

The columns are: name with GOMAXPROCS appended, iterations run, time per call, bytes allocated per call, allocations per call. The numbers depend on the machine; compare runs on the same one. To compare before and after a change reliably, run each side several times with -count=10 and feed both outputs to benchstat (golang.org/x/perf/cmd/benchstat).

Examples are tests too

An example function prints something and declares the expected output in a comment. go test runs it and fails if the output differs, and go doc and pkg.go.dev show it as documentation:

// example_test.go
package textutil_test

import (
	"fmt"

	"example.com/textutil"
)

func ExampleSlugify() {
	fmt.Println(textutil.Slugify("Hello, World!"))
	// Output: hello-world
}

The package name textutil_test makes this an external test: it can only use the exported API, as a real caller would. Such files may sit in the same directory as the package. Without an // Output: comment the example is compiled but not run. Use // Unordered output: when lines can appear in any order.

Fuzzing, briefly

Go 1.18 added fuzz tests, which generate inputs to find crashes and broken invariants:

func FuzzSlugify(f *testing.F) {
	f.Add("Hello, World!") // seed input
	f.Fuzz(func(t *testing.T, s string) {
		slug := Slugify(s)
		if strings.Contains(slug, "--") || strings.HasPrefix(slug, "-") {
			t.Errorf("Slugify(%q) = %q: bad hyphens", s, slug)
		}
	})
}

Plain go test runs only the seed inputs (the f.Add values and any saved files under testdata/fuzz). go test -fuzz=FuzzSlugify keeps generating new ones until it finds a failure or you stop it, and saves failing inputs under testdata/fuzz so they become regular test cases.

Common mistakes

  • Test file in the wrong place or wrongly named. It must end in _test.go and sit in the package directory. slug_tests.go is compiled into the package, not run as tests.
  • Lowercase after Test. Testslugify is not a test. TestSlugify and Test_slugify are.
  • t.Fatal from another goroutine. Report with t.Error there, and wait for the goroutine before the test returns.
  • Messages without the input. Include what was passed in, what came out, and what was expected.
  • Trusting a cached pass. Use -count=1 when a test depends on anything outside the package.
  • Tests that depend on each other's order or shared globals. Each test should set up its own state, which is what t.TempDir, t.Setenv and t.Cleanup are for.

Frequently Asked Questions

How do I write a unit test in Go?

Create a file ending in _test.go in the same directory as the code, import testing, and write a function func TestName(t *testing.T) that calls your code and reports mismatches with t.Errorf. Run go test in that directory, or go test ./... for the whole module. No framework or assertion library is needed.

What is the difference between t.Error and t.Fatal in Go?

t.Error and t.Errorf mark the test as failed and keep running it, so one run can report several problems. t.Fatal and t.Fatalf mark it failed and stop the test immediately. Use Fatal when continuing makes no sense, such as after an unexpected error that leaves the result unusable.

How do I run a single test in Go?

Pass a regular expression to -run: go test -run TestSlugify runs every test whose name matches, and go test -run 'TestSlugifyTable/punctuation' runs one subtest (spaces in subtest names become underscores). Add -v to see each test's name and result, and -count=1 to bypass the test cache.

How do I write a benchmark in Go?

Write func BenchmarkName(b *testing.B) in a _test.go file and put the code to measure in a for b.Loop() { ... } loop (Go 1.24; older code uses for i := 0; i < b.N; i++). Run it with go test -bench=. -benchmem, which reports nanoseconds, bytes and allocations per operation.

How do I see test coverage in Go?

go test -cover prints the percentage of statements the tests executed. For details, write a profile with go test -coverprofile=cover.out, then run go tool cover -func=cover.out for per-function numbers or go tool cover -html=cover.out to see covered and uncovered lines in a browser.

Coddy programming languages illustration

Learn to code with Coddy

GET STARTED