Skip to content

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.

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 calls runtime.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 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 test names 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) { ... }).

t.Run(name, f) is the whole feature. It buys you four things a bare loop does not:

  1. 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.)
  2. Selective execution. go test -run 'TestDivide/negative' runs just the matching subtests - invaluable when one case in a table of 200 is failing.
  3. Isolation. A t.Fatalf inside a subtest aborts that subtest only; the remaining rows still run. Without t.Run, one Fatalf kills the entire table.
  4. Per-case setup and cleanup. Each subtest gets its own t, so t.Cleanup registered 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.

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.

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.

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 failed

Related tools worth knowing:

  • t.Cleanup(fn) registers teardown that runs when the test (or subtest) finishes, in LIFO order, even on failure. Prefer it to defer in helpers, because a helper’s defer fires 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.

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.

Terminal window
# Percentage per package
go test -cover ./...
# ok example.com/app/user 0.004s coverage: 78.3% of statements
# Write a profile, then read it two ways
go test -coverprofile=cover.out ./...
# 1. Per-function breakdown in the terminal - fastest way to spot a gap
go 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 = not
go tool cover -html=cover.out
go tool cover -html=cover.out -o coverage.html # write it to a file for CI

Two flags worth knowing:

  • -covermode=atomic is required if your tests run anything in parallel or with -race; the default set mode is not goroutine-safe. Use count when you want execution counts rather than a boolean.
  • -coverpkg=./... measures coverage of all packages from every test binary. Without it, an integration test in package api records no coverage for the package store code it exercises - a very common reason coverage looks lower than it should.

In CI, enforce a floor rather than a target:

Terminal window
go test -coverprofile=cover.out -covermode=atomic ./...
go tool cover -func=cover.out | awk '/^total:/ {if (+$3 < 70.0) exit 1}'
  1. func TestXxx(t *testing.T) - the signature is the contract. A TestXxx() with no parameter is not a test and go test will never run it
  2. t.Errorf continues, t.Fatalf stops - pick based on whether the remaining checks still mean anything
  3. Table-driven tests - the Go way for multiple test cases
  4. Subtests with t.Run() - named failures, -run filtering, and per-case isolation
  5. Interface-based mocking - inject dependencies; no framework needed
  6. httptest - NewRecorder for handlers, NewServer for real round trips
  7. Helpers take *testing.T first and call t.Helper() first - otherwise every failure points at the helper instead of the test
  8. t.Cleanup, t.TempDir, t.Setenv - teardown that runs even when the test fails
  9. Coverage finds gaps, it is not a score - -coverprofile plus go tool cover -func / -html; use -covermode=atomic with -race

Test a REST API Handler

medium

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.

The exercise battery has a dedicated testing track:


Chapter in progress
0 / 14 chapters completed

Next up: Chapter 11: Benchmarking & Profiling