Unit Testing Intermediate¶
Introduction¶
Go has a first-class testing framework built into the language and toolchain — no external test runner needed. The testing package, combined with the go test command, provides everything from basic assertions to subtests, benchmarks, and coverage analysis. The table-driven test pattern is the idiomatic Go way to write tests and shows up in virtually every Go codebase and interview question about testing.
Syntax & Usage¶
Basic Test Function¶
Test files end in _test.go and test functions start with Test followed by an uppercase letter.
// 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)
}
}
go test ./... # run all tests recursively
go test -v ./... # verbose output
go test -run TestAdd # run specific test by regex
Table-Driven Tests (THE Go Pattern)¶
This is the most important testing pattern in Go. It separates test data from test logic.
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -1, -2, -3},
{"zero", 0, 0, 0},
{"mixed signs", -5, 10, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.expected {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.expected)
}
})
}
}
Output with -v:
=== RUN TestAdd
=== RUN TestAdd/positive_numbers
=== RUN TestAdd/negative_numbers
=== RUN TestAdd/zero
=== RUN TestAdd/mixed_signs
--- PASS: TestAdd (0.00s)
Subtests with t.Run¶
t.Run creates named subtests that can be filtered and run independently.
func TestUser(t *testing.T) {
t.Run("creation", func(t *testing.T) {
u := NewUser("alice", "alice@example.com")
if u.Name != "alice" {
t.Errorf("expected name alice, got %s", u.Name)
}
})
t.Run("validation", func(t *testing.T) {
_, err := NewUser("", "")
if err == nil {
t.Error("expected error for empty fields")
}
})
}
t.Helper()¶
Marks a function as a test helper so that when it reports a failure, the file and line number point to the caller, not the helper itself.
func assertEqual(t *testing.T, got, want int) {
t.Helper() // without this, errors point to this line
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
func TestMath(t *testing.T) {
assertEqual(t, Add(2, 3), 5) // error would point here
assertEqual(t, Add(0, 0), 0)
}
t.Parallel()¶
Runs subtests concurrently for faster execution.
func TestParallel(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"uppercase", "hello", "HELLO"},
{"already upper", "HELLO", "HELLO"},
{"mixed", "HeLLo", "HELLO"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // runs this subtest in parallel
got := strings.ToUpper(tt.input)
if got != tt.want {
t.Errorf("ToUpper(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
Parallel Loop Variable Capture
In Go versions before 1.22, using t.Parallel() with a range loop required capturing the loop variable:
t.Cleanup¶
Registers a cleanup function that runs after the test (and its subtests) complete. Runs in LIFO order.
func TestWithDB(t *testing.T) {
db := setupTestDB(t)
t.Cleanup(func() {
db.Close() // always runs, even if test fails or panics
})
// ... test using db ...
}
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.Exec("DELETE FROM test_data") })
return db
}
testing.Short() — Skipping Slow Tests¶
func TestIntegration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
// expensive test that hits a real database or network
}
Test Fixtures and testdata Directory¶
The testdata directory is special — go build ignores it, but go test can read from it.
func TestParseConfig(t *testing.T) {
// testdata/ is relative to the test file's package directory
data, err := os.ReadFile("testdata/valid_config.json")
if err != nil {
t.Fatalf("reading fixture: %v", err)
}
cfg, err := ParseConfig(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Port != 8080 {
t.Errorf("expected port 8080, got %d", cfg.Port)
}
}
TestMain — Setup and Teardown for the Whole Package¶
func TestMain(m *testing.M) {
// Global setup
pool, err := setupDockerDB()
if err != nil {
log.Fatalf("could not start db: %v", err)
}
code := m.Run() // run all tests
// Global teardown
pool.Purge()
os.Exit(code)
}
TestMain Replaces Default
When you define TestMain, it replaces the default test runner. You must call m.Run() and os.Exit(code) yourself, or no tests will execute.
Testing Private vs Exported Functions¶
// Package: mypackage
// mypackage/handler.go
package mypackage
func PublicHandler() string { return format("hello") }
func format(s string) string { return "[" + s + "]" }
// Same package — can test unexported functions
// mypackage/handler_test.go
package mypackage
func TestFormat(t *testing.T) {
got := format("test") // can access unexported function
if got != "[test]" {
t.Errorf("got %s, want [test]", got)
}
}
// External test package — tests only the public API
// mypackage/handler_external_test.go
package mypackage_test
import "mypackage"
func TestPublicHandler(t *testing.T) {
got := mypackage.PublicHandler()
// cannot call mypackage.format() here
if got != "[hello]" {
t.Errorf("got %s, want [hello]", got)
}
}
| Package | File | Access |
|---|---|---|
package mypackage |
xxx_test.go |
All functions (exported + unexported) |
package mypackage_test |
xxx_test.go |
Only exported functions |
Code Coverage¶
go test -cover ./... # show coverage percentage
go test -coverprofile=coverage.out ./... # generate coverage file
go tool cover -html=coverage.out # open HTML coverage report
go tool cover -func=coverage.out # per-function coverage
// Use build tags or test flags for coverage-aware testing
// Example output:
// ok mypackage 0.003s coverage: 85.2% of statements
Quick Reference¶
| Concept | Syntax / Command | Notes |
|---|---|---|
| Test function | func TestXxx(t *testing.T) |
Must start with Test + uppercase |
| Run tests | go test ./... |
Recursive, all packages |
| Verbose | go test -v |
Shows each test name and status |
| Run specific | go test -run "TestUser/creation" |
Regex filter |
| Subtest | t.Run("name", func(t *testing.T) {...}) |
Named, filterable |
| Table-driven | for _, tt := range tests { t.Run(...) } |
Idiomatic Go pattern |
| Mark helper | t.Helper() |
Correct line numbers in errors |
| Parallel | t.Parallel() |
Concurrent subtests |
| Skip | t.Skip("reason") |
Conditionally skip |
| Short mode | go test -short |
testing.Short() returns true |
| Cleanup | t.Cleanup(func() {...}) |
Runs after test, LIFO |
| Fatal | t.Fatalf("msg: %v", err) |
Stops the test immediately |
| Error | t.Errorf("got %d, want %d", g, w) |
Records failure, continues |
| Coverage | go test -cover |
Statement coverage percentage |
| Test fixture | testdata/ directory |
Ignored by go build |
| Package-level setup | func TestMain(m *testing.M) |
Must call m.Run() |
Best Practices¶
- Use table-driven tests for any function with multiple input/output scenarios — it's the expected pattern in Go codebases
- Name subtests descriptively —
t.Run("empty_input_returns_error", ...)reads well in output and filters - Call
t.Helper()in every test helper function — keeps error reporting accurate - Use
t.Cleanup()overdefer** in helper functions — cleanup runs after the calling test completes, not when the helper returns - Test behavior, not implementation — test the public API (
package foo_test) unless you specifically need to test internals - Keep tests close to code —
foo.goandfoo_test.golive in the same directory - Use
testing.Short()to separate fast unit tests from slow integration tests - Don't use assertion libraries unless the team agrees — the Go community prefers plain
ifchecks witht.Errorf - Use
testdata/for fixtures — it's the conventional location and ignored by the build tool
Common Pitfalls¶
Forgetting t.Helper()
func assertOK(t *testing.T, err error) {
// without t.Helper(), error points to THIS line
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
t.Helper() as the first line in test helper functions.
Using t.Fatal in Goroutines
func TestAsync(t *testing.T) {
go func() {
t.Fatal("this will panic!") // WRONG: t.Fatal from non-test goroutine
}()
}
t.Fatal, t.Fatalf, and t.FailNow must only be called from the goroutine running the test function. Use channels or t.Error (which is safe) to report failures from goroutines.
Loop Variable with t.Parallel (Pre-Go 1.22)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// BUG (pre-1.22): tt points to last element for all goroutines
check(tt.input) // all tests use same tt value
})
}
tt := tt before t.Run. Go 1.22+ fixes loop variable scoping.
Forgetting m.Run() in TestMain
Always callcode := m.Run() and os.Exit(code).
Not Checking Error in Test Setup
func TestFoo(t *testing.T) {
f, _ := os.Open("testdata/fixture.json") // ignoring error!
// f is nil, test panics with confusing message
}
t.Fatalf for setup errors to get clear failure messages.
Performance Considerations¶
| Scenario | Recommendation |
|---|---|
| Many independent subtests | Use t.Parallel() to run concurrently |
| Expensive setup (DB, containers) | Use TestMain for one-time setup/teardown |
| Slow integration tests | Guard with testing.Short() and -short flag |
| Test binary caching | go test caches passing results; use -count=1 to force rerun |
| Large table-driven tests | Tests are data — generate edge cases programmatically |
| Coverage overhead | -cover adds ~5% overhead; -covermode=atomic is slower but goroutine-safe |
| Benchmark comparison | Use go test -bench=. -benchmem for allocation-aware benchmarks |
Interview Tips¶
Interview Tip
"What is a table-driven test?" It's Go's idiomatic pattern where test cases are defined as a slice of structs, and a single loop iterates over them calling t.Run for each. It separates test data from test logic, makes adding cases trivial, and produces clear output.
Interview Tip
"What's the difference between t.Error and t.Fatal?" t.Error (and t.Errorf) records the failure but continues the test. t.Fatal (and t.Fatalf) records the failure and stops the test immediately by calling runtime.Goexit(). Use t.Fatal for setup errors where continuing is pointless.
Interview Tip
"How do you test unexported functions?" Put the test file in the same package (package foo, not package foo_test). For black-box testing of the public API, use the external test package (package foo_test). Both approaches are valid and serve different purposes.
Interview Tip
"Does Go have a built-in assertion library?" No. Go's philosophy is that if statements with t.Errorf are clear and sufficient. The standard library itself uses this style. Third-party libraries like testify exist but are not idiomatic for the stdlib.
Interview Tip
"How do you skip slow tests?" Use testing.Short() at the top of slow tests: if testing.Short() { t.Skip("skipping in short mode") }. Run with go test -short to skip them. This is how you separate fast unit tests from slow integration tests.
Key Takeaways¶
- Test files end in
_test.go; test functions start withTestXxx(t *testing.T) - Table-driven tests are the idiomatic Go pattern — use them everywhere
t.Run("name", ...)creates subtests that are filterable and independently runnablet.Helper()is essential in helper functions for accurate error reportingt.Parallel()runs subtests concurrently; watch for loop variable capture in pre-1.22 Got.Cleanup()registers teardown functions that run after the test completes (LIFO)TestMain(m *testing.M)provides package-level setup/teardown — must callm.Run()- Same-package tests access unexported functions; external test package (
_test) tests only the public API - Use
testing.Short()and-shortflag to separate fast and slow tests go test -covershows coverage;-coverprofilegenerates detailed reports