Go Testing Guide: Writing Tests with the testing Package
Go ships with a testing package in the standard library that covers most of what you need. No external dependencies, no configuration files, no ceremony. You write a function that starts with Test, run go test, and you get results. This guide walks through the core patterns — from basic test functions to subtests, helpers, and cleanup — with real examples you can use immediately.
The Basics: Test Functions
A test function must be in a file ending with _test.go, live in the same package as the code it tests, and follow this signature:
func TestFunctionName(t *testing.T) {
// test body
}Here's a minimal example. Given this function:
// math.go
package math
func Add(a, b int) int {
return a + b
}A test looks like:
// math_test.go
package math
import "testing"
func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d; want 5", result)
}
}Run it:
go test ./...
go test -v ./... # verbose — shows each test name and PASS/FAIL
go test -run TestAdd ./... # run only tests matching the patternt.Errorf marks the test as failed but continues execution. Use t.Fatalf when you want to stop immediately — useful when subsequent steps depend on the first assertion being true.
Subtests with t.Run
t.Run creates a named subtest inside a parent test. This gives you:
- Individual pass/fail reporting per subtest
- The ability to run specific subtests via
-run - Parallel subtests (covered below)
func TestAdd(t *testing.T) {
t.Run("positive numbers", func(t *testing.T) {
if Add(2, 3) != 5 {
t.Errorf("got %d; want 5", Add(2, 3))
}
})
t.Run("negative numbers", func(t *testing.T) {
if Add(-1, -2) != -3 {
t.Errorf("got %d; want -3", Add(-1, -2))
}
})
t.Run("zero", func(t *testing.T) {
if Add(0, 0) != 0 {
t.Errorf("got %d; want 0", Add(0, 0))
}
})
}Run a specific subtest:
go test -run TestAdd/positive_numbers ./...Note: spaces in subtest names are replaced with underscores in the -run flag.
Test Helpers
Helper functions reduce boilerplate. Mark them with t.Helper() so that when they fail, the error points to the call site — not the helper itself.
func assertEqual(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Errorf("got %d; want %d", got, want)
}
}
func TestAdd(t *testing.T) {
assertEqual(t, Add(2, 3), 5)
assertEqual(t, Add(-1, 1), 0)
assertEqual(t, Add(0, 0), 0)
}Without t.Helper(), a failure inside assertEqual would report the line number inside the helper function. With it, you get the line number in TestAdd where assertEqual was called — which is where you actually want to look.
For more complex assertions, you can build a small assertion library:
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func assertError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected an error, got nil")
}
}t.Cleanup: Teardown Without Defer Stacking
t.Cleanup registers a function to be called when the test (or subtest) ends, regardless of pass or fail. It's cleaner than stacking defer calls and works properly with subtests.
func TestDatabase(t *testing.T) {
db := setupTestDB(t)
t.Cleanup(func() {
db.Close()
})
// test code here — db.Close() called automatically when test ends
}Multiple t.Cleanup calls are executed in LIFO order (last registered, first called):
func TestSetup(t *testing.T) {
t.Cleanup(func() { fmt.Println("cleanup 1") })
t.Cleanup(func() { fmt.Println("cleanup 2") })
// prints: cleanup 2, then cleanup 1
}A common pattern is to have your setup function register its own cleanup:
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("postgres", testDSN)
if err != nil {
t.Fatalf("failed to open DB: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestSomething(t *testing.T) {
db := setupTestDB(t) // cleanup registered inside
// use db
}Parallel Tests
Mark a test as parallel with t.Parallel(). The test runner holds it until all non-parallel tests in the package complete, then runs all parallel tests concurrently.
func TestAdd(t *testing.T) {
t.Parallel()
// runs concurrently with other parallel tests
if Add(2, 3) != 5 {
t.Error("failed")
}
}For parallel subtests, there's a well-known gotcha with loop variables:
func TestParallelSubtests(t *testing.T) {
cases := []struct {
name string
a, b, want int
}{
{"positive", 2, 3, 5},
{"negative", -1, -2, -3},
}
for _, tc := range cases {
tc := tc // capture loop variable — critical before Go 1.22
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if Add(tc.a, tc.b) != tc.want {
t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, Add(tc.a, tc.b), tc.want)
}
})
}
}As of Go 1.22, loop variables are scoped per iteration, so the tc := tc capture is no longer required — but it doesn't hurt to keep it for older codebases.
Useful go test Flags
# Run tests matching a pattern (regexp)
go test -run TestAdd ./...
# Run with verbose output
go test -v ./...
# Set a timeout
go test -timeout 30s ./...
# Count: disable test result caching
go test -count=1 ./...
# Run tests in parallel with N workers
go test -parallel 4 ./...
# Short mode — tests can check t.Short() and skip long operations
go test -short ./...Inside tests, check for short mode:
func TestHeavyOperation(t *testing.T) {
if testing.Short() {
t.Skip("skipping in short mode")
}
// expensive test
}TestMain: Package-Level Setup and Teardown
When you need to run setup/teardown once for an entire package — not per test — use TestMain:
func TestMain(m *testing.M) {
// setup
db := startTestDatabase()
code := m.Run() // run all tests
// teardown
db.Stop()
os.Exit(code)
}TestMain takes full control of the test binary's lifecycle. You must call m.Run() and pass its return code to os.Exit — otherwise tests won't actually run.
Test Organization: _test Package vs Same Package
Go supports two approaches:
White-box testing (same package): access unexported identifiers.
// file: parser_test.go
package parser
func TestInternalHelper(t *testing.T) {
result := internalParse("input") // can access unexported
}Black-box testing (external test package): tests only the public API.
// file: parser_test.go
package parser_test
import "mymodule/parser"
func TestParse(t *testing.T) {
result := parser.Parse("input") // only exported
}A single _test.go file can mix both — Go allows both package foo and package foo_test in the same directory, but not in the same file.
The convention: use black-box testing by default. Drop into white-box when you need to test complex internal logic that shouldn't be exported just for testability.
Checking Test Coverage
go test -cover ./...
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out # opens browser with highlighted source
go tool cover -func=coverage.out # per-function coverage summaryCoverage tells you which lines were executed during tests. High coverage doesn't mean good tests — you can execute every line with tests that assert nothing. Use it as a signal, not a target.
Quick Reference
| What you want | How to do it |
|---|---|
| Fail and continue | t.Errorf(...) |
| Fail and stop | t.Fatalf(...) |
| Skip a test | t.Skip(...) |
| Named subtest | t.Run("name", func(t *testing.T) {...}) |
| Mark as helper | t.Helper() |
| Register cleanup | t.Cleanup(func() {...}) |
| Run in parallel | t.Parallel() |
| Check short mode | testing.Short() |
The testing package covers the majority of Go testing needs without any third-party libraries. Add testify or other packages when they genuinely reduce friction — not by default.
HelpMeTest complements your Go unit tests by running end-to-end checks against your deployed service, catching regressions that unit tests can't reach.