Skip to content
C Codeloom
Go

Go Testing Package Tutorial

Write effective tests in Go with the standard testing package: table-driven tests, subtests, fixtures, parallelism, benchmarks, and fuzzing.

·4 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • Writing and running tests with go test
  • Structuring tests as table-driven cases
  • Using subtests and t.Run for clarity
  • Benchmarks and how to interpret allocations
  • Property-style testing with Go fuzzing

Prerequisites

  • Comfort with Go syntax and packages

What and Why

Go ships with a testing framework in the standard library: the testing package and the go test command. There is no separate runner, no XML config, no fixtures DSL. You write functions named TestXxx(t *testing.T), run go test ./..., and you are done.

This minimalism is the point. With one obvious way to write tests, every Go codebase tests the same way, and tooling (coverage, race detection, fuzzing, benchmarks) all live in the same command.

Mental Model

Tests are normal Go code in _test.go files alongside the code they exercise.

func TestXxx(t *testing.T)         unit test
func BenchmarkXxx(b *testing.B)    benchmark; -bench flag
func FuzzXxx(f *testing.F)         fuzz target; go test -fuzz
func TestMain(m *testing.M)        custom setup/teardown
ExampleXxx() / Example(t)          doc + check
t.Run("name", func(t *testing.T))  subtests
Test taxonomy in Go

Hands-on Example

A common pattern: table-driven tests with subtests.

package strings_test

import (
	"strings"
	"testing"
)

func TestTrimSpace(t *testing.T) {
	cases := []struct {
		name string
		in   string
		want string
	}{
		{"empty", "", ""},
		{"no spaces", "abc", "abc"},
		{"leading", "  abc", "abc"},
		{"trailing", "abc  ", "abc"},
		{"both", "  abc  ", "abc"},
		{"only spaces", "   ", ""},
	}
	for _, tc := range cases {
		tc := tc // capture range var for parallel
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			got := strings.TrimSpace(tc.in)
			if got != tc.want {
				t.Errorf("TrimSpace(%q) = %q; want %q", tc.in, got, tc.want)
			}
		})
	}
}

Notes worth absorbing. t.Run creates a subtest with its own name, so failures point at the specific case. t.Parallel() runs subtests concurrently within the parent test. Capturing the loop variable into tc was historically required; in Go 1.22+ the loop variable is per-iteration, but the explicit form still reads clearly.

A benchmark looks similar but uses b.N:

func BenchmarkTrimSpace(b *testing.B) {
	s := "   hello world   "
	for i := 0; i < b.N; i++ {
		_ = strings.TrimSpace(s)
	}
}

Run with go test -bench=. -benchmem to see allocations per op.

A fuzz target generates inputs automatically:

func FuzzReverse(f *testing.F) {
	f.Add("hello")
	f.Fuzz(func(t *testing.T, s string) {
		r := reverse(s)
		if reverse(r) != s {
			t.Errorf("reverse not involutive for %q", s)
		}
	})
}

go test -fuzz=FuzzReverse runs the fuzzer, mutating seed inputs to find failing cases.

Common Pitfalls

  • Shared state across tests: package-level variables modified by tests cause order-dependent failures. Use t.Cleanup to undo per-test mutations.
  • Hidden flakiness: tests that hit the network, the clock, or random sources fail intermittently. Inject dependencies (a fake clock, a deterministic RNG) instead.
  • t.Fatal from a goroutine: only call t.Fatal from the test goroutine. From other goroutines, send the error back on a channel and fail in the main goroutine.
  • Skipping when you meant to fail: t.Skip removes the test from the run. Use it for environment-dependent tests, not as a workaround for known-broken cases.
  • Benchmark setup inside the loop: any setup work between for i := 0 and the action under test is included in the timing. Move it out, or use b.ResetTimer().

Practical Tips

Run with -race regularly. Go’s race detector catches data races at runtime and is one of the most valuable features in the toolchain. CI should run tests with go test -race ./....

Use coverage incrementally: go test -cover ./... for a quick number, go test -coverprofile=cov.out for HTML with go tool cover -html=cov.out. Aim for coverage in the layers that change frequently rather than chasing a number.

For integration tests that need external resources, gate them with build tags: //go:build integration and run with go test -tags=integration ./....

The testing/quick package is older but still useful for quick property tests when fuzzing is overkill. The testify library is popular for assertions; the standard library is enough for most needs.

For test helpers, call t.Helper() at the top. Failures then point at the calling test, not the helper.

Wrap-up

Go testing rewards convention over configuration. Write _test.go files next to your code, use table-driven tests with subtests for readability, and add -race to your CI. When you need benchmarks or fuzzing, the same go test command takes you there with a flag. With these defaults, your tests stay close to the code they cover, run fast, and surface bugs the moment you change something.