Go Testing Best Practices: Table Tests, Mocks, and Race Detection
Key Takeaways
- →High coverage with no race detection misses the bugs that hurt most — coverage measures breadth, not concurrency safety, and the data races that double-charge customers live in branches the happy-path test never visits
- →Table-driven tests with t.Run() eliminate boilerplate and turn adding test cases into a data problem — test name appears in failure output, no subtest naming confusion
- →Interfaces for mocks injected through constructors catch integration issues that framework-based mocks hide — plain structs, no reflection, no magic
- →go test -race catches concurrent reads/writes that sequential tests cannot — it's not a performance feature, it's a correctness check
The classic high-coverage production bug. A payments service ships with green CI and very high statement coverage. Then it starts double-charging customers. The postmortem is always the same — a data race in order finalisation, two goroutines reading
pendingbefore either writescompleted. The tests never caught it because nobody rango test -race[Go Memory Model]. Coverage measured the happy path; the race lived in a branch the happy path never visited. We've debugged this exact incident multiple times.
High Coverage, Real Bug, Wrong Tool
Coverage and correctness are not the same metric. High statement coverage means most lines were exercised by the suite; it says nothing about whether your tests would catch a race condition, a bad input encoding, or a flaky downstream.
Go's standard library[Go Language Specification] handles testing without external frameworks: table-driven tests, interface-based mocks, httptest, fuzz tests, and race detection[Go Memory Model]. Know which pattern to reach for.
- Table-driven tests with
t.Run()eliminate boilerplate - Interface-based mocks injected through constructors catch integration issues
- Always run
go test -race— it catches concurrency bugs unit tests cannot
graph TD
Goal[What are you testing?] --> Branch{Type of bug?}
Branch -->|"input edge cases"| Tab[Table-driven test<br/>+ t.Run subtests]
Branch -->|"dependencies / boundaries"| Mock[Interface-based mocks<br/>injected through constructors]
Branch -->|"HTTP handler"| Http[httptest.NewRecorder<br/>or NewServer]
Branch -->|"concurrency"| Race[go test -race<br/>data-race detector]
Branch -->|"parser / validator"| Fuzz[testing.F fuzz<br/>Go 1.18+]
Branch -->|"real DB / Kafka"| Tc[testcontainers-go<br/>+ build tags]
style Race fill:#fee
style Tc fill:#eef
The diagram is the picker: choose the testing tool by the kind of failure you're worried about, not by habit. Coverage is the breadth metric; race + fuzz are the depth metrics that catch what coverage cannot.
The Quick Start
Pick the test type by what is under test, not by familiarity:
graph TD
Start[I want to test...] --> What{What is<br/>under test?}
What -->|Pure logic with<br/>multiple inputs| Table[Table-driven test<br/>+ t.Run subtest<br/>5-50 ms each]
What -->|Function that<br/>calls a dependency| Mock[Define interface at<br/>consumer + inject mock<br/>20-100 ms each]
What -->|HTTP handler| HTTP[httptest.NewRecorder<br/>or NewServer<br/>no real network]
What -->|Concurrent code<br/>with shared state| Race[go test -race<br/>+ goleak]
What -->|Parser, validator,<br/>encoder/decoder| Fuzz[testing.F + f.Add<br/>seeds + fuzz iterations]
What -->|Real DB, Kafka,<br/>Redis behaviour| TC[testcontainers-go<br/>+ build tag<br/>5-15 s each]
Table --> CI[CI: -short for unit<br/>-tags=integration for TC]
Mock --> CI
HTTP --> CI
Race --> CI
Fuzz --> CI
TC --> CI
style Table fill:#dfd
style Mock fill:#dfd
style HTTP fill:#dfd
style Race fill:#ffd
style Fuzz fill:#ffd
style TC fill:#fdd
Go's built-in testing tools map directly to testing patterns:
| Goal | Tool | Pattern |
|---|---|---|
| Test many inputs | testing.T + table | Slice of structs, t.Run() loop |
| Mock dependencies | Interfaces | Define at consumer, inject mocks |
| HTTP handlers | httptest.NewRecorder / NewServer | No network, assert status/body |
| Concurrency bugs | go test -race | Runs all tests with data race detection |
| Parser edge cases | testing.F (Go 1.18+) | Fuzz with f.Add() seed inputs |
| Database tests | testcontainers-go | Spin up real containers, guard with build tags |
Table-Driven Tests
Instead of writing separate test functions for each input, define test cases as data and loop over them. Use t.Run() to create named subtests—each case gets its own subtest output, so failures are immediately clear.
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
want bool
}{
{name: "valid email", email: "alice@example.com", want: true},
{name: "missing @", email: "aliceexample.com", want: false},
{name: "@ at start", email: "@example.com", want: false},
{name: "@ at end", email: "alice@", want: false},
{name: "empty string", email: "", want: false},
{name: "unicode local part", email: "ü@example.com", want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ValidateEmail(tt.email)
if got != tt.want {
t.Errorf("ValidateEmail(%q) = %v, want %v", tt.email, got, tt.want)
}
})
}
}Benefits: Adding cases is trivial. When a case fails, output shows TestValidateEmail/missing_@. We run t.Parallel() to execute cases concurrently and catch race conditions earlier.
Run with go test -v to see each subtest fail:
$ go test -v ./user
=== RUN TestValidateEmail/missing_@
--- FAIL: TestValidateEmail/missing_@ (0.00s)
user_test.go:25: ValidateEmail("aliceexample.com") = true, want falseInterface-Based Mocking
Define interfaces at the consumer boundary and inject mock implementations. No reflection, no code generation—just plain structs.
// domain.go - service owns the interface
type PaymentGateway interface {
Charge(ctx context.Context, cents int) error
}
type OrderService struct {
gateway PaymentGateway
}
func (s *OrderService) PlaceOrder(ctx context.Context, cents int) error {
return s.gateway.Charge(ctx, cents)
}// service_test.go
type mockGateway struct {
chargeFunc func(ctx context.Context, cents int) error
}
func (m *mockGateway) Charge(ctx context.Context, cents int) error {
return m.chargeFunc(ctx, cents)
}
func TestPlaceOrder(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{name: "success", err: nil, want: false},
{name: "fails", err: errors.New("declined"), want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := &mockGateway{chargeFunc: func(ctx context.Context, cents int) error {
return tt.err
}}
svc := &OrderService{gateway: mock}
err := svc.PlaceOrder(context.Background(), 5000)
if (err != nil) != tt.want {
t.Errorf("error = %v, want error = %v", err, tt.want)
}
})
}
}This catches integration issues: if the service doesn't propagate context or doesn't call the gateway at all, the test fails.
Testing HTTP Handlers
Use httptest.NewRecorder() to capture handler responses without a real server:
func TestCreateOrderHandler(t *testing.T) {
tests := []struct {
name string
body string
want int
}{
{name: "valid", body: `{"amount": 5000}`, want: http.StatusCreated},
{name: "invalid", body: `{}`, want: http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/orders", strings.NewReader(tt.body))
CreateOrderHandler(w, req)
if w.Code != tt.want {
t.Errorf("status = %d, want %d", w.Code, tt.want)
}
})
}
}For middleware stacks or routing, use httptest.NewServer(). See production-grade Go API design.
Race Detection
[Go Memory Model]Always run go test -race in CI. The race detector instruments memory accesses at runtime and catches concurrent reads and writes to the same variable without synchronization.
go test -race ./...Cost: 2-10x runtime overhead. Worth it. Add to CI:
- run: go test -race -count=1 ./...If two goroutines write to the same field without synchronization, -race detects it even if the interleaving never occurred in your test run. This is the only tool that catches this class of bug.
Fuzz Testing for Parsers
Go 1.18+ native fuzz tests discover edge cases by generating millions of random inputs. Useful for parsers, validators, and anything that takes untrusted input.
func FuzzValidateEmail(f *testing.F) {
f.Add("alice@example.com")
f.Add("invalid")
f.Add("")
f.Fuzz(func(t *testing.T, email string) {
result := ValidateEmail(email)
if result && !strings.Contains(email, "@") {
t.Errorf("ValidateEmail(%q) returned true but has no @", email)
}
})
}Run with go test -fuzz=FuzzValidateEmail ./... to generate inputs. When the fuzzer finds a crash, it saves the input to testdata/fuzz/ for reproducibility.
Integration Tests with Testcontainers
For database tests, use testcontainers-go to spin up real PostgreSQL or Redis in Docker:
//go:build integration
package repository_test
func TestCreateOrder(t *testing.T) {
ctx := context.Background()
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "postgres:16",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{"POSTGRES_PASSWORD": "test"},
WaitingFor: wait.ForLog("database system is ready to accept connections"),
},
Started: true,
})
if err != nil {
t.Fatalf("container failed: %v", err)
}
defer container.Terminate(ctx)
db := setupDB(t, dsn)
repo := NewOrderRepository(db)
order, err := repo.Create(ctx, &Order{UserID: "user-1", Total: 5000})
if err != nil {
t.Fatalf("Create failed: %v", err)
}
}Guard with build tags so go test ./... skips integration tests:
go test -short ./... # unit tests only
go test -tags=integration ./... # include integration tests in CITest Helpers and Goroutine Leak Detection
Two patterns separate Go tests that catch real bugs from tests that just exercise lines: t.Helper() for failure-line attribution and goleak for goroutine-leak detection.
t.Helper() marks a function as a test helper so when an assertion fails, the failure line points at the caller, not the helper. Without it, every failure points at line 47 of assertEqual and you have no idea which test case actually failed:
func assertResponse(t *testing.T, w *httptest.ResponseRecorder, wantCode int, wantBody string) {
t.Helper() // CRITICAL: failure line points at the caller
if w.Code != wantCode {
t.Errorf("status: got %d, want %d", w.Code, wantCode)
}
if got := w.Body.String(); got != wantBody {
t.Errorf("body: got %q, want %q", got, wantBody)
}
}Goroutine leaks are the silent killer of long-running services[Go Runtime GC]. Tests that pass while leaking goroutines look fine until production OOMs three weeks later. The goleak package from Uber catches them at test time:
import "go.uber.org/goleak"
func TestMain(m *testing.M) {
// Fails the test run if any goroutine outlives main
goleak.VerifyTestMain(m)
}
func TestServerShutdown(t *testing.T) {
defer goleak.VerifyNone(t) // Per-test guard
srv := newServer()
// ... if shutdown leaks a goroutine, the deferred Verify will fail
}Wire goleak.VerifyTestMain into every package that spawns goroutines (HTTP handlers, queue consumers, schedulers). Run the full test suite under -race in CI[Go Memory Model] — both detectors are nearly free at test time and catch entire classes of bugs that only surface under production traffic.
Production Checklist
- Table-driven tests for functions with multiple inputs
- Interface-based mocks injected through constructors
- httptest.NewRecorder for handler unit tests, httptest.NewServer for middleware
- go test -race in CI—catches data races unit tests miss
- Fuzz tests for parsers and validators
- testcontainers-go for database tests with build tags to keep unit tests fast
- Build tags to separate unit and integration tests
Real Postgres in CI with Testcontainers Go
The earlier integration-test snippet showed the raw container API. In production CI we layer three additional concerns on top of it: a typed module per dependency, schema migrations applied per test run, and parallel-safe schema isolation. The typed modules from the testcontainers-go contrib repository give you a real connection string back instead of forcing you to assemble it from host plus mapped port. Schema migrations run from disk so the test container matches what production will see after the next deploy. Schema isolation lets you run hundreds of test functions against a single container without cross-contamination.
The pattern below uses the official Postgres module, applies migrations from the same files the production deploy uses, and creates a fresh schema per test. The fresh-schema approach beats the alternative of resetting tables between tests because it survives partially-applied transactions and lets parallel subtests run safely. We hand each subtest its own schema and drop it on cleanup.
//go:build integration
package repository_test
import (
"context"
"database/sql"
"fmt"
"testing"
"github.com/google/uuid"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
func newPostgres(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
container, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("orders_test"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2),
),
)
if err != nil {
t.Fatalf("postgres container: %v", err)
}
t.Cleanup(func() { _ = container.Terminate(ctx) })
dsn, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("dsn: %v", err)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
schema := "test_" + uuid.NewString()[:8]
if _, err := db.ExecContext(ctx, fmt.Sprintf("CREATE SCHEMA %s", schema)); err != nil {
t.Fatalf("create schema: %v", err)
}
if _, err := db.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", schema)); err != nil {
t.Fatalf("search_path: %v", err)
}
applyMigrations(t, db, "../../migrations")
return db
}CI cost matters. A cold container start adds two to four seconds, so amortise it with a package-level TestMain that boots one container, then hand each test a fresh schema inside it. The orchestration shaves minutes off the suite as the integration set grows past about thirty tests. If your CI runner has Docker preinstalled, container reuse via Ryuk is automatic; if you run on a sandbox without Docker (like some hosted CI with restrictive network egress), gate the integration suite behind a build tag so unit tests still pass on every push and integration runs on merge to main only.
Property-Based Testing with rapid
Table-driven tests verify the inputs you imagine. Property-based tests verify properties of the function across inputs the framework imagines. The difference matters most for code with algebraic structure: encoders that should round-trip, sorts that should preserve length and contents, parsers that should reject malformed inputs without panicking, and any function with idempotency or commutativity guarantees. The pgregory.net/rapid library is the modern Go choice. It generates random inputs, shrinks failing inputs to minimal counterexamples, and integrates with testing.T.
The example below tests an order-total calculator. The property under test is straightforward: applying a discount of zero to any cart must return the cart subtotal unchanged. Rapid generates random carts and random non-discount inputs to find the smallest counterexample if the implementation drifts.
import (
"testing"
"pgregory.net/rapid"
)
func TestCartTotal_ZeroDiscountPreservesSubtotal(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
items := rapid.SliceOfN(rapid.Custom(func(t *rapid.T) LineItem {
return LineItem{
SKU: rapid.StringMatching(`[A-Z]{3}-\d{4}`).Draw(t, "sku"),
Quantity: rapid.IntRange(1, 100).Draw(t, "qty"),
CentsEach: rapid.IntRange(1, 1_000_000).Draw(t, "cents"),
}
}), 1, 50).Draw(t, "items")
cart := Cart{Items: items}
subtotal := cart.Subtotal()
total := cart.TotalWithDiscount(0)
if total != subtotal {
t.Fatalf("zero discount changed total: subtotal=%d total=%d", subtotal, total)
}
})
}When the property fails, rapid prints the shrunk minimal input alongside the random seed needed to reproduce it. We commit the seed to a regression-test slice so the same case runs on every future build. Property tests catch a different class of bug than table tests — they routinely surface integer-overflow paths, off-by-one errors at boundaries, and edge cases involving empty collections or zero-length strings that humans skip when writing example-based tests.
Flake Detection with go test -count=N
The first sign of a flaky test is a CI failure that disappears on retry. The second sign is a CI failure on a different machine in a different week. By that point the flake has cost real time. The cheap defensive measure is go test -count=N with a parallelism flag, which forces the runner to ignore the test result cache and execute each test N times. We run it weekly in a scheduled CI job and on every change touching concurrency or shared state.
go test -race -count=20 -parallel=8 ./...The -count=20 runs every test twenty times in a row inside the same process. The -parallel=8 permits eight t.Parallel() subtests to run concurrently. The -race flag stays on. If a test ever fails inside that loop, you have a flake — and now you have a reliable reproduction harness because the test ran nineteen other times in the same process and one of them broke. We pair the flake hunt with t.Cleanup discipline: every resource a test allocates registers a cleanup, so leaked state from earlier iterations cannot contaminate later ones.
When the flake reproduces locally, the next move is stress from golang.org/x/tools/cmd/stress. It runs a single test binary in a tight loop, captures stack traces on failure, and stops on the first crash. Pair stress with -race and your test fails inside a minute on most concurrency bugs.
httptest vs Real-Server Tests
The earlier section showed httptest.NewRecorder for handler unit tests. The recorder approach is fast and synchronous: it bypasses the network entirely, calls your handler with a constructed request, and captures the response in memory. That speed comes with a real cost — middleware that depends on http.Server lifecycle hooks, response timeouts, or http.Hijacker does not run. Server-shutdown semantics, request-context cancellation through the connection, and the body-write flushing behaviour you get from a real net/http.Server only fire when there is one in the picture.
The trade-off rule we use: assertions about handler logic (status codes, JSON bodies, header propagation) go through httptest.NewRecorder. Assertions about server behaviour (graceful shutdown, request timeout, slow-client backpressure, connection-close semantics) go through httptest.NewServer. The second harness boots a real listener on a random localhost port, gives you the URL to dial, and tears the server down on Close. Tests that live in the second category run an order of magnitude slower than recorder tests but catch a class of bug the recorder cannot see.
func TestServer_GracefulShutdownDrainsInflight(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(200 * time.Millisecond):
w.WriteHeader(http.StatusOK)
case <-r.Context().Done():
w.WriteHeader(http.StatusServiceUnavailable)
}
})
srv := httptest.NewServer(handler)
defer srv.Close()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
resp, err := http.Get(srv.URL + "/work")
if err != nil {
t.Errorf("client: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
}()
time.Sleep(50 * time.Millisecond) // let the request reach the handler
srv.Config.Shutdown(context.Background())
wg.Wait()
}Real-server tests also catch transport-level regressions that handler-only tests miss: a missing Content-Length, a wrongly-set Connection: close, a client that hangs because the server never flushed. We keep both kinds in the suite and reach for the recorder by default; the real server pulls duty for shutdown drains, slow-client tests, and any code path that touches the response writer's Flusher or Hijacker interfaces.
Frequently Asked Questions
What is a table-driven test?
A table-driven test defines cases as a slice of structs with inputs and expected outputs, then loops over them with t.Run(). Each case becomes a named subtest, making failures explicit and adding cases trivial.
How do I mock without a framework?
Define interfaces at the consumer boundary. Inject mock implementations that satisfy the interface. No reflection, no code generation—just plain structs with methods.
What does go test -race do?
The -race flag instruments memory accesses at runtime to detect concurrent reads and writes to the same variable without synchronization. It catches data races that sequential tests cannot.
How do I write fast unit tests and slow integration tests?
Use build tags (//go:build integration) to exclude integration tests from go test by default. Run go test -tags=integration separately in CI. Alternatively, use testing.Short() with go test -short to skip integration tests.
Keep Reading
- Go Error Handling: errors.Is, errors.As, Wrapping, and Custom Types — The error patterns your tests should exercise
- Production-Grade Go API Design — The layered architecture that these testing patterns verify
- Java Testing with JUnit 5 and Mockito: A Production Guide — How Java's testing ecosystem compares
- Go Worker Pool Concurrency — Concurrency patterns that need targeted -race coverage and goleak guards
- Go Context Cheat Sheet — context.WithCancel/Timeout in tests; t.Cleanup for deferred cancellation
Engineering Team
A multidisciplinary team of backend engineers, architects, and DevOps practitioners shipping deep dives into distributed systems and production infrastructure.
Read Next
Java Testing with JUnit 5 and Mockito: A Production Guide
Write tests that catch real bugs: JUnit 5 lifecycle, parameterized tests, Mockito, WireMock, Testcontainers, and mutation testing.
Go context.Context Cheat Sheet: Cancellation, Timeouts & Gotchas
Go context.Context: constructors, cancellation, deadlines, request values, and five goroutine leak patterns in production.
Go Dynamic JSON: Parsing Unknown Schemas in Production
Handle unpredictable JSON in Go: map[string]any, json.RawMessage, type switches, and defensive patterns for shifting schemas.