Chapter 10: Testing Strategies
Go has excellent built-in testing support. Let’s explore patterns that make your tests maintainable and effective.
Testing is built into Go from the ground up. No external frameworks required - the testing package in the standard library provides everything you need. This simplicity encourages testing and makes it a natural part of Go development.
Go’s testing philosophy emphasizes clarity over cleverness. Tests are just Go code. No magic assertions, no complex DSLs, just functions that call code and report failures. This directness makes tests easy to read, write, and debug.
This chapter covers table-driven tests (the Go idiom for multiple test cases), subtests for organization, mocking strategies, HTTP testing, and test coverage. You’ll learn patterns that scale from simple functions to complex systems.
Basic Test Structure
Section titled “Basic Test Structure”How Go Tests Work
Section titled “How Go Tests Work”Tests live in _test.go files alongside the code they test. Test functions start with Test, take a single *testing.T parameter, and return nothing. You report failures by calling methods on t. Run them with go test. That’s it - no configuration, no test runners, no plugins, no assertion library.
The signature matters: go test only recognises func TestXxx(t *testing.T). A function called TestDivide() with no parameter is just an ordinary function that will never be run by the test tool, no matter what it prints.
Two ways to report a failure:
t.Errorf(...)marks the test as failed and keeps going. Use it when the remaining checks are still meaningful.t.Fatalf(...)marks the test as failed and stops this test immediately (it callsruntime.Goexit). Use it when continuing would panic - a nil result, a failed setup step.
Notice what is not there: no main, no fmt.Println("PASS"), no hand-rolled result counting. The testing package does all of that, and it is the only thing CI will believe.
Table-Driven Tests
Section titled “Table-Driven Tests”The Go Testing Idiom
Section titled “The Go Testing Idiom”Table-driven tests are Go’s standard pattern for testing multiple cases. Instead of writing separate test functions for each case, you define a slice of test cases and loop over them, running each one as a subtest with t.Run.
Why table-driven tests:
- Less duplication: Write test logic once, apply to many cases
- Easy to extend: Adding a new case is adding a line to the slice
- Clear intent: Test data is separate from test logic
- Better failures:
go testnames each subtest, so a failure points at exactly one row
The pattern: define an anonymous struct with a name field plus inputs and expected outputs, loop over the cases, and wrap each iteration in t.Run(tt.name, func(t *testing.T) { ... }).
Subtests
Section titled “Subtests”t.Run(name, f) is the whole feature. It buys you four things a bare loop does not:
- Named results. Failures are reported as
--- FAIL: TestDivide/division_by_zero, so you know which row broke without printing anything yourself. (Spaces in names become underscores.) - Selective execution.
go test -run 'TestDivide/negative'runs just the matching subtests - invaluable when one case in a table of 200 is failing. - Isolation. A
t.Fatalfinside a subtest aborts that subtest only; the remaining rows still run. Withoutt.Run, oneFatalfkills the entire table. - Per-case setup and cleanup. Each subtest gets its own
t, sot.Cleanupregistered inside it runs when that subtest ends.
You can nest t.Run arbitrarily to group related cases, and t.Parallel() inside a subtest makes siblings run concurrently.
Testing with Interfaces (Mocking)
Section titled “Testing with Interfaces (Mocking)”Go needs no mocking framework. Define a small interface, have production code depend on it, and write a struct in your test package that implements it. The mock can also record what it was called with, which is how you assert on behaviour rather than just return values.
Testing HTTP Handlers
Section titled “Testing HTTP Handlers”net/http/httptest gives you two tools. httptest.NewRecorder calls a handler directly and captures what it wrote - fast, no sockets, no ports. httptest.NewServer starts a real server on a real loopback port, which is what you want when you are testing a client or middleware that needs a genuine round trip.
Test Helpers and t.Helper()
Section titled “Test Helpers and t.Helper()”A helper is any function a test calls to do assertion or setup work. Helpers must take *testing.T as their first parameter, and their first line must be t.Helper().
t.Helper() marks the function as a helper so that when it calls t.Errorf, the failure is reported at the caller’s line number rather than inside the helper. Without it, every failure in your suite points at the same line of assertEqual, and the message is useless:
helpers_test.go:12: got 7, want 5 <- without t.Helper(): always line 12 math_test.go:41: got 7, want 5 <- with t.Helper(): the line that actually failedRelated tools worth knowing:
t.Cleanup(fn)registers teardown that runs when the test (or subtest) finishes, in LIFO order, even on failure. Prefer it todeferin helpers, because a helper’sdeferfires when the helper returns, not when the test ends.t.TempDir()creates a directory that is removed automatically.t.Setenv(k, v)sets an environment variable and restores it afterwards.
Test Coverage
Section titled “Test Coverage”Coverage tells you which lines your tests executed. It is a map of what you have not tested, not a quality score - 100% coverage of code with no assertions proves nothing. Use it to find the branches you forgot.
# Percentage per packagego test -cover ./...# ok example.com/app/user 0.004s coverage: 78.3% of statements
# Write a profile, then read it two waysgo test -coverprofile=cover.out ./...
# 1. Per-function breakdown in the terminal - fastest way to spot a gapgo tool cover -func=cover.out# example.com/app/user/service.go:14: GetUserName 100.0%# example.com/app/user/service.go:31: DeleteUser 0.0% <- never tested# total: (statements) 78.3%
# 2. An annotated HTML report: green = covered, red = notgo tool cover -html=cover.outgo tool cover -html=cover.out -o coverage.html # write it to a file for CITwo flags worth knowing:
-covermode=atomicis required if your tests run anything in parallel or with-race; the defaultsetmode is not goroutine-safe. Usecountwhen you want execution counts rather than a boolean.-coverpkg=./...measures coverage of all packages from every test binary. Without it, an integration test inpackage apirecords no coverage for thepackage storecode it exercises - a very common reason coverage looks lower than it should.
In CI, enforce a floor rather than a target:
go test -coverprofile=cover.out -covermode=atomic ./...go tool cover -func=cover.out | awk '/^total:/ {if (+$3 < 70.0) exit 1}'Key Takeaways
Section titled “Key Takeaways”func TestXxx(t *testing.T)- the signature is the contract. ATestXxx()with no parameter is not a test andgo testwill never run itt.Errorfcontinues,t.Fatalfstops - pick based on whether the remaining checks still mean anything- Table-driven tests - the Go way for multiple test cases
- Subtests with
t.Run()- named failures,-runfiltering, and per-case isolation - Interface-based mocking - inject dependencies; no framework needed
httptest-NewRecorderfor handlers,NewServerfor real round trips- Helpers take
*testing.Tfirst and callt.Helper()first - otherwise every failure points at the helper instead of the test t.Cleanup,t.TempDir,t.Setenv- teardown that runs even when the test fails- Coverage finds gaps, it is not a score -
-coverprofileplusgo tool cover -func/-html; use-covermode=atomicwith-race
Exercise
Section titled “Exercise”Test a REST API Handler
Write a real table-driven *testing.T test for a user creation handler. The playground runs this file with `go test -v` (there is no main), so you get genuine PASS/FAIL output. Each case must be its own t.Run subtest.
Practice
Section titled “Practice”The exercise battery has a dedicated testing track:
- Table-Driven Tests - the pattern from this chapter, from scratch
- Mocking with Interfaces - test doubles without a framework
- HTTP Handler Testing -
httptestin anger - All testing exercises - or browse the full exercise battery
Next up: Chapter 11: Benchmarking & Profiling