Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Testing and Benchmarking in Go

Go has a built-in testing framework provided by the testing package and the go test command. This makes writing tests a natural part of the development workflow, not an external dependency. Think of Go’s testing philosophy like a workshop where every tool comes in the same toolbox. There is no JUnit, no pytest, no Jest equivalent needed. The standard testing package and go test command handle test discovery, execution, parallelism, coverage, benchmarking, and fuzzing out of the box. Libraries like testify add assertion helpers, but the core machinery is built in. While other languages treat testing as an afterthought bolted on through third-party frameworks, Go treats it as a first-class citizen baked into the toolchain — the same way go fmt enforces formatting, go test enforces that testing is not optional.

Writing Tests

Test files reside in the same package as the code they test, but with a _test.go suffix. For example, math.go would be tested by math_test.go.

Test Functions

A test function must start with Test and take a single argument t *testing.T.
package math

import "testing"

func TestAdd(t *testing.T) {
    got := Add(1, 2)
    want := 3
    if got != want {
        t.Errorf("Add(1, 2) = %d; want %d", got, want)
    }
}

Table-Driven Tests

Go developers prefer “table-driven tests” to easily cover multiple test cases without repeating code. This is one of Go’s most distinctive testing idioms — you will see it in the standard library, in open-source projects, and in virtually every production codebase. The pattern is: define a slice of test cases, each with inputs and expected outputs, then loop through them.
func TestAddTable(t *testing.T) {
    tests := []struct {
        name string // Descriptive name shown in test output
        a, b int    // Inputs
        want int    // Expected output
    }{
        {"positive numbers", 1, 2, 3},
        {"negative numbers", -1, -1, -2},
        {"mixed signs", -1, 1, 0},
        {"zero values", 0, 0, 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)
            }
        })
    }
}
The beauty of this pattern is that adding a new test case is just adding a line to the table — no new function, no new boilerplate. If a bug is reported, you add the reproducing case to the table and it is automatically tested forever.

Subtests (t.Run)

The t.Run method allows you to define subtests. This is useful for hierarchical test reporting and running specific subtests via the -run flag.

Benchmarking

Go also has built-in support for benchmarking code to measure performance. Benchmark functions start with Benchmark and take b *testing.B. The runtime automatically adjusts b.N to run the function enough times for a statistically meaningful measurement — you do not choose the iteration count yourself.
func BenchmarkAdd(b *testing.B) {
    // b.N is automatically adjusted by the runtime for statistical significance
    for i := 0; i < b.N; i++ {
        Add(1, 2)
    }
}

// Always include -benchmem to track allocations alongside time
func BenchmarkStringConcat(b *testing.B) {
    b.ReportAllocs() // Report allocations per operation
    for i := 0; i < b.N; i++ {
        _ = fmt.Sprintf("hello %d", i)
    }
}
Run benchmarks with:
go test -bench=. -benchmem
Pitfall — Benchmarking Setup Included in Timing: If your benchmark includes setup code (allocating data, reading files), that time is included in the measurement. Use b.ResetTimer() after setup, and b.StopTimer() / b.StartTimer() for per-iteration setup:
func BenchmarkSort(b *testing.B) {
    data := generateLargeSlice(10000) // Setup
    b.ResetTimer()                    // Do not count setup time
    for i := 0; i < b.N; i++ {
        b.StopTimer()
        input := make([]int, len(data))
        copy(input, data)             // Per-iteration setup
        b.StartTimer()
        sort.Ints(input)              // Only this is measured
    }
}

Examples

Example functions serve two purposes: they act as documentation (appearing in Godoc) and as verified tests. They start with Example and use // Output: comments to verify results.
func ExampleAdd() {
    sum := Add(1, 5)
    fmt.Println(sum)
    // Output: 6
}

Fuzzing

Go 1.18 introduced native support for fuzzing, which generates random inputs to find edge cases and bugs. Think of fuzzing as hiring a chaos monkey for your code: instead of you carefully crafting test inputs, the fuzzer throws random, unexpected, and malformed data at your functions to see what breaks. It is particularly effective at finding panics, out-of-bounds errors, and edge cases that human testers would never think to write.
func FuzzAdd(f *testing.F) {
    // Seed corpus: known inputs that guide the fuzzer's mutations
    f.Add(1, 2)
    f.Add(0, 0)
    f.Add(-1, 1)
    f.Add(math.MaxInt64, 1) // Edge case: overflow

    f.Fuzz(func(t *testing.T, a, b int) {
        result := Add(a, b)
        // Property-based assertion: addition is commutative
        if result != Add(b, a) {
            t.Errorf("Add(%d, %d) != Add(%d, %d)", a, b, b, a)
        }
    })
}
Run fuzzing with:
go test -fuzz=FuzzAdd -fuzztime=30s
Pitfall — Fuzzing Without Properties: A common mistake is fuzzing only for panics without asserting properties. Merely calling Add(a, b) catches crashes, but it will not catch logic bugs. Always assert invariants: commutativity, idempotency, round-trip encoding/decoding, or bounds. The fuzz test above checks commutativity, which would catch a surprisingly large class of bugs.

Test Coverage

You can check code coverage with the -cover flag.
go test -cover
For a detailed HTML report:
go test -coverprofile=coverage.out
go tool cover -html=coverage.out

Test Helpers

The t.Helper() method marks a function as a test helper. When a test fails inside a helper, the error message reports the caller’s file and line number instead of the helper’s, making failures much easier to locate:
func assertEqual(t *testing.T, got, want int) {
    t.Helper() // Without this, failures report this line, not the caller
    if got != want {
        t.Errorf("got %d; want %d", got, want)
    }
}

func TestCalculation(t *testing.T) {
    assertEqual(t, Add(2, 3), 5) // Failure message points here, not inside assertEqual
}
Practical Testing Tips:
  • Always run go test -race ./... in CI — the race detector catches concurrency bugs that are nearly impossible to find through code review alone.
  • Use t.Parallel() to run independent tests concurrently, speeding up your test suite.
  • Use t.Cleanup() (Go 1.14+) instead of defer for test teardown — it runs even if the test calls t.Fatal().
  • Use testdata/ directories for test fixtures. Go’s tooling ignores testdata/ directories during builds, making them the idiomatic place for test input files.
  • Avoid TestMain for setup unless truly necessaryt.Cleanup() and subtests usually suffice. Reserve TestMain for global resources like a shared database container.
Pitfall — Testing Goroutine-Heavy Code Without -race: Tests can pass with data races present. The race detector (go test -race) instruments memory access at runtime and catches races that would otherwise be invisible. Always run with -race in CI. Be aware that the race detector slows execution by 2-10x and increases memory usage, so do not enable it in production builds — only in tests and development.
Pitfall — Shared State Between Table-Driven Test Cases: When test cases share a slice, map, or pointer, one case can corrupt another. Always ensure each test case is independent:
// WRONG: shared slice modified across iterations
shared := []int{1, 2, 3}
tests := []struct{ input []int }{
    {shared},
    {shared}, // Same backing array -- mutations leak between cases
}

// RIGHT: each case owns its data
tests := []struct{ input []int }{
    {[]int{1, 2, 3}},
    {[]int{1, 2, 3}},
}

Summary

  • go test: The standard command to run tests.
  • TestXxx: Unit tests.
  • Table-Driven Tests: Idiomatic way to structure tests.
  • BenchmarkXxx: Performance tests.
  • ExampleXxx: Documentation that is also tested.
  • Fuzzing: Automated random testing to find edge cases.
  • t.Helper(): Makes test helper functions report correct line numbers.
  • -race flag: Always use in development and CI to catch data races.

Interview Deep-Dive

Strong Answer:
  • I use a layered testing strategy. Unit tests mock external dependencies using interfaces. Integration tests use real dependencies (a test database, a local API stub). End-to-end tests run the full stack.
  • For the database: define a UserRepository interface with methods like GetByID(ctx, id) and Create(ctx, user). In unit tests, use a mock that implements this interface. In integration tests, use a real PostgreSQL instance (via Docker in CI, or testcontainers-go) with a clean database per test. The integration tests catch SQL syntax errors, schema mismatches, and transaction behavior that mocks cannot.
  • For the external API: define a PaymentClient interface. In unit tests, mock it. In integration tests, use httptest.NewServer to create a local HTTP server that returns canned responses, testing your HTTP client’s parsing, error handling, and retry logic against realistic responses.
  • The key insight is that interfaces are your seams for testing. Every external dependency should be behind an interface defined by the consumer (not the provider). Your service takes the interface in its constructor, and tests inject mocks. This is constructor-based dependency injection without any framework.
  • I avoid mocking the database for business logic tests. I have seen mocked tests pass while real queries fail due to migration bugs, NULL handling, or index behavior. The test database catches these.
Follow-up: Explain table-driven tests. Why is this pattern so prevalent in Go, and what makes a good test table design?Table-driven tests define test cases as a slice of structs, each with a name, inputs, and expected outputs, then loop through them with t.Run. The pattern is prevalent because it makes adding new cases trivial (just add a row to the table), provides named subtests that show clearly in output (=== RUN TestAdd/negative_numbers), and allows running specific cases with -run. A good test table design includes: a descriptive name field (not “test1” — use the scenario being tested like “negative numbers” or “empty input”), all inputs and expected outputs in the struct, and optionally an expectErr bool or wantErr error field. Keep setup that is common to all cases outside the loop, and put case-specific setup inside the loop. If cases need different setup logic, it might be better as separate test functions rather than a table. The anti-pattern is a table with 50 fields per case where most are zero-valued — this means the cases are too heterogeneous for a single table.
Strong Answer:
  • When you run go test -bench=., the testing framework calls your benchmark function multiple times with increasing b.N values. It starts with b.N = 1, measures the time, then increases b.N (2, 5, 10, 20, 50, 100, …) until the total benchmark time reaches a stable measurement (default 1 second, configurable with -benchtime). The framework reports the average time per operation: total time divided by b.N.
  • Common mistake 1: including setup in the timed section. If you allocate test data inside the for i := 0; i < b.N; i++ loop, you are benchmarking allocation plus the operation. Move setup before the loop and use b.ResetTimer() to exclude it.
  • Common mistake 2: the compiler optimizing away your benchmark. If the result of the benchmarked function is not used, the compiler may eliminate the call entirely. Assign the result to a package-level variable: var result int; func BenchmarkFoo(b *testing.B) { var r int; for i := 0; i < b.N; i++ { r = Foo() }; result = r }.
  • Common mistake 3: not using b.ReportAllocs() and -benchmem. Memory allocations are often the bottleneck in Go, and you cannot see them without these flags. A function that looks fast might be making thousands of heap allocations that pressure the garbage collector.
  • Common mistake 4: running benchmarks once and drawing conclusions. Use benchstat with -count=10 runs to get statistically significant comparisons between before and after.
Follow-up: What is fuzzing in Go, and how is it different from property-based testing?Go’s built-in fuzzing (since 1.18) generates random inputs to find bugs. You provide a seed corpus (known good inputs), and the fuzzer mutates them to explore code paths, guided by coverage feedback. It looks for panics, crashes, or property violations. It is different from property-based testing (like Haskell’s QuickCheck or Go’s gopter) in that property-based testing generates inputs from a defined distribution and checks invariants you specify, while Go’s fuzzer is coverage-guided — it aims to maximize code coverage by mutating inputs intelligently. Fuzzing is particularly effective at finding edge cases in parsers, serialization code, and input validation. In practice, I write fuzz tests for any function that processes untrusted input (JSON parsing, URL handling, query parameter parsing) and run the fuzzer in CI for a fixed duration (30 seconds per fuzz target). The corpus of discovered interesting inputs is committed to the repo so regression testing is fast.
Strong Answer:
  • First, identify the slow tests. Run go test -v -count=1 ./... 2>&1 | grep -E 'PASS|FAIL' to see per-package timing, then drill into slow packages with go test -v -run . ./pkg/slow/ to see individual test timing. The -count=1 flag disables test caching so you get real timings.
  • Common culprits: tests that use time.Sleep for synchronization (replace with channels or condition variables), tests that start real servers and databases without reusing them across cases, tests that do not use t.Parallel() for independent tests, and integration tests mixed with unit tests.
  • Quick wins: add t.Parallel() to every test that does not share state with other tests. This lets Go run them concurrently within a package and can cut test time dramatically. Use testing.Short() and -short flag to skip slow integration tests during local development. Use build tags to separate integration tests: //go:build integration so they only run in CI.
  • Medium-term: share expensive setup across tests using TestMain(m *testing.M) for package-level setup (like starting a database container once), and t.Cleanup() for per-test teardown. Replace network calls with httptest.NewServer stubs that respond instantly.
  • Long-term: if the test suite is fundamentally doing too much I/O, consider splitting into fast unit tests (seconds) and slow integration tests (minutes) with different CI stages. The fast suite runs on every commit, the slow suite runs on PR merge or hourly.
Follow-up: Explain t.Helper() and t.Cleanup(). When do you use each?t.Helper() marks a function as a test helper. When a test fails inside a helper, the error message reports the caller’s file and line number, not the helper’s. This makes failure messages immediately useful instead of pointing to a generic assertEqual function. Call it as the first line in any test utility function. t.Cleanup(fn) registers a function to run after the test completes, even if the test calls t.Fatal(). It is better than defer because deferred functions do not run after t.Fatal() (which calls runtime.Goexit()), but cleanup functions always do. Use t.Cleanup for test teardown: deleting test data, stopping test servers, closing connections. Cleanup functions run in LIFO order, just like defers.