Introduction
Table-driven tests are the idiomatic way to write tests in Go. Instead of writing a separate test function for every scenario, you define a slice of test cases and loop over them with t.Run. This lesson shows you how to structure table-driven tests that are easy to read, extend, and debug.
Key Concepts
- testing.T: The type passed to every test function. It provides methods like
Error,Fatal, andRunfor reporting failures and organizing subtests. - Table-driven test: A pattern where test cases are defined as a slice of structs, each containing inputs and expected outputs, then iterated with a loop.
- Subtest: A named test created with
t.Runthat can be filtered and run independently viago test -run TestName/subtest.
Real World Context
Imagine you maintain a URL-parsing library. Every time a bug report arrives, you add one line to your test table instead of writing an entirely new function. Six months later your table has 40 rows covering every edge case anyone has ever hit, all in a single readable block. Table-driven tests also integrate naturally with CI dashboards because each t.Run subtest appears as its own pass/fail line.
Deep Dive
Go has a built-in testing framework with zero external dependencies. Test files must end in _test.go and test functions must start with Test followed by a capitalized name, accepting a single *testing.T parameter.
Here is a complete table-driven test that exercises an Add function with several input combinations.
gofunc TestAdd(t *testing.T) { tests := []struct { name string a, b, want int }{ {"positive", 1, 2, 3}, {"zero", 0, 0, 0}, {"negative", -1, 1, 0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := Add(tt.a, tt.b) if got != tt.want { t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.want) } }) } }
Each struct in the slice represents one test scenario. The t.Run call wraps each case in a named subtest, so the output clearly identifies which case failed. You can run a single subtest by name with go test -run TestAdd/negative.
The key reporting methods on testing.T let you control how failures are handled.
got.Error(args...) // Log failure, continue test t.Errorf(format, args...) // Formatted failure, continue test t.Fatal(args...) // Log failure, stop immediately t.Skip(args...) // Skip this test with a reason
Use t.Error when you want the rest of the test to keep running so you can see all failures at once. Use t.Fatal when a failure makes subsequent checks meaningless, for example when setup fails.
As of Go 1.26, testing.T also provides t.ArtifactDir(), which returns a unique per-test directory for writing output files such as golden snapshots or debug logs. The directory is automatically created and scoped to the running test, so parallel tests never collide. The testing/synctest package, which graduated from experimental in Go 1.26, provides utilities for deterministically testing concurrent code by controlling fake clocks and goroutine scheduling.
Common Pitfalls
- Forgetting t.Run -- Looping without
t.Runmeans all cases share one test name and you cannot filter or parallelize individual cases. - Capturing the loop variable in a closure -- In Go versions before 1.22, the loop variable
ttwas shared across iterations. If you launch parallel subtests, capturettwithtt := ttinside the loop body, or upgrade to Go 1.22+ where the variable is scoped per iteration. - Using t.Fatal inside a goroutine --
t.Fatalcallsruntime.Goexit, which only terminates the current goroutine. Calling it from a spawned goroutine will not stop the test. Uset.Errorplus a return instead.
Best Practices
- Name every test case -- A descriptive
namefield makes failure output self-explanatory and lets you isolate a single case withgo test -run. - Keep the struct anonymous -- Declaring the struct inline next to the test cases keeps everything in one place and avoids polluting the package namespace.
- Group related assertions -- Within a single subtest, check all related properties before returning so you see the full picture on failure.
Summary
- Go tests live in
_test.gofiles; functions start withTestand accept*testing.T. - Table-driven tests define cases as a slice of structs and iterate with
t.Runfor named subtests. - Use
t.Errorto report and continue,t.Fatalto report and stop. - Go 1.26 adds
t.ArtifactDir()for per-test output directories. - The
testing/synctestpackage (stable since Go 1.25) helps test concurrent code deterministically.
Code Examples
func TestSplit(t *testing.T) {
// ... define tests
t.Run("subtest", func(t *testing.T) {
// Subtests allow granular reporting
})
}