Skip to content
C Codeloom
Go

Go Testing Basics with the testing Package

Get productive with Go tests fast: writing _test.go files, table-driven tests with t.Run, running with go test flags, coverage, and an intro to benchmarks.

·6 min read · By Yash Kesharwani
Intermediate 10 min read

What you'll learn

  • How to write tests in _test.go files using the testing package
  • How to use t.Run for subtests and table-driven cases
  • How to run tests with -run, -v, and -cover
  • How to read coverage output and find untested paths
  • How to write a basic benchmark with b.N

Prerequisites

  • Familiar with [Go functions](/blog/go-functions)
  • A working Go module from [Go install and first program](/blog/go-install-and-first-program)

Go ships with a complete testing toolkit in the standard library and the go test command. There is no separate framework to pick, no runner to configure, and the conventions are uniform across every Go project. That uniformity is one of the language’s most underrated productivity wins.

File and function conventions

Tests live next to the code they exercise. For a file math.go, tests go in math_test.go in the same package. The Go tool only compiles _test.go files when running tests, so they never affect production builds.

// file: math.go
package math

func Add(a, b int) int { return a + b }
// file: math_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
	got := Add(2, 3)
	if got != 5 {
		t.Errorf("Add(2,3) = %d, want 5", got)
	}
}

A test function must start with Test, take a single *testing.T, and live in a _test.go file. Run with:

go test

go test prints PASS or FAIL, plus details on any failures.

Reporting failures

testing.T offers two main families of failure methods:

  • t.Error / t.Errorf mark the test as failed but continue execution.
  • t.Fatal / t.Fatalf mark it as failed and stop the test function immediately.

Use Fatal for invariants that make later checks meaningless (a nil pointer you are about to dereference). Use Error when you want to gather multiple failures in one run.

func TestUser(t *testing.T) {
	u, err := newUser("ada")
	if err != nil {
		t.Fatalf("newUser: %v", err)
	}
	if u.Name != "ada" {
		t.Errorf("Name = %q, want %q", u.Name, "ada")
	}
}

Subtests with t.Run

t.Run creates a named subtest. You can target individual subtests, run them in parallel, and produce nicely grouped output.

func TestAdd(t *testing.T) {
	t.Run("positive", func(t *testing.T) {
		if Add(1, 2) != 3 {
			t.Fail()
		}
	})
	t.Run("negative", func(t *testing.T) {
		if Add(-1, -2) != -3 {
			t.Fail()
		}
	})
}

Run a specific subtest with the -run flag and a slash:

go test -run TestAdd/positive

Table-driven tests

Table-driven tests are the idiomatic way to cover many cases without duplication. Build a slice of structs, then loop with t.Run.

func TestAddTable(t *testing.T) {
	cases := []struct {
		name string
		a, b int
		want int
	}{
		{"zero", 0, 0, 0},
		{"positive", 2, 3, 5},
		{"negative", -1, -1, -2},
		{"mixed", -5, 10, 5},
	}

	for _, tc := range cases {
		tc := tc // capture for parallel safety
		t.Run(tc.name, func(t *testing.T) {
			got := Add(tc.a, tc.b)
			if got != tc.want {
				t.Errorf("Add(%d,%d) = %d, want %d", tc.a, tc.b, got, tc.want)
			}
		})
	}
}

When a single case fails, the output names the offending row, which makes the cause obvious. The patterns from Go slices and maps come up constantly when building these tables.

Useful go test flags

A few flags you will use every day:

  • go test -v prints each test name and result.
  • go test -run Pattern runs only tests whose names match the regex.
  • go test ./... runs every test in the module.
  • go test -race enables the data race detector; use it in CI.
  • go test -count=1 disables result caching to force a re-run.
  • go test -cover prints overall coverage; -coverprofile=cover.out writes a file for go tool cover.
go test -v -run TestAdd -cover ./...

Coverage is reported per package. After writing a profile, go tool cover -html=cover.out opens an HTML view that highlights uncovered lines, which is the fastest way to find a branch your tests missed.

Helpers and setup

Mark helper functions with t.Helper() so that failure messages report the caller’s line, not the helper’s:

func mustParse(t *testing.T, s string) int {
	t.Helper()
	n, err := strconv.Atoi(s)
	if err != nil {
		t.Fatalf("parse %q: %v", s, err)
	}
	return n
}

For per-test cleanup, use t.Cleanup:

func TestWithTemp(t *testing.T) {
	dir := t.TempDir() // auto-removed after the test
	t.Cleanup(func() { /* extra teardown */ })
	_ = dir
}

t.TempDir is especially nice: a fresh temp directory per test, removed automatically.

Parallel tests

Calling t.Parallel() lets a test run alongside other parallel tests, which can shorten suites significantly:

func TestSlow(t *testing.T) {
	t.Parallel()
	// ...
}

Inside a subtest loop, remember to capture the loop variable as shown earlier, or all parallel subtests will see the last value.

Benchmarks

Benchmarks live in the same file and start with Benchmark. The runner adjusts b.N until the result is statistically stable.

func BenchmarkAdd(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = Add(2, 3)
	}
}

Run with:

go test -bench=. -benchmem

-benchmem adds allocation counts. To compare versions, save the output of two runs and diff with benchstat. Use benchmarks to guide optimization; do not micro-optimize without numbers, and avoid letting the compiler eliminate your work by assigning results to a package-level var sink.

Where to test what

Practical guidance that holds up over time:

  • Test exported functions thoroughly; treat them as your API contract.
  • Use table-driven tests for pure functions and edge cases.
  • For functions that touch I/O, accept interfaces (see the idea in Go functions) so tests can supply fakes.
  • Keep tests deterministic; avoid time and random sources, or inject them.
  • Run go test -race ./... in CI to catch concurrent misuse early.

Wrap up

Go’s testing model is small, sharp, and consistent. Put tests in _test.go, group cases with t.Run, drive coverage with tables, and lean on -run, -v, -cover, and -race from the command line. When performance matters, add a benchmark and measure. Stick to these basics and your test suite will scale with the codebase rather than against it.