How to Write Table-Driven Tests Idiomatically in Go

Write Go table-driven tests by defining a slice of test cases with inputs and expected outputs, then looping over them in a test function to verify behavior.

The problem with copy-paste tests

You write a function that trims whitespace and validates a username. It works for "alice". You add a test. It passes. Then you realize it should reject empty strings, handle leading spaces, and cap length at thirty characters. You copy-paste the test block four times. You change one assertion. Now you have to update five blocks. The test suite grows into a wall of nearly identical code. Changing the validation logic later means hunting through repetitive boilerplate. Go solves this with a pattern that separates test data from test logic. You define a table of cases, loop over it, and let the standard library handle the execution.

What table-driven tests actually are

Table-driven tests treat test cases as data. Instead of writing a new function call for every scenario, you pack inputs, expected outputs, and metadata into a slice of structs. The test function reads that slice and runs the same verification logic against each row. Think of it like a laboratory notebook. The procedure stays on page one. The variables change on page two. You run the procedure once per row. The pattern keeps your test code DRY, makes adding edge cases a matter of appending a struct literal, and gives the test runner structured output. Each row becomes a named subtest that can pass or fail independently.

The standard library provides everything you need. No external assertion frameworks, no macro generators, no test runners beyond go test. The pattern relies on testing.T, struct slices, and range loops. It is the idiomatic way to verify deterministic functions in Go.

The minimal pattern

Here is the simplest working pattern. The function under test clamps a number between a minimum and maximum value. The test defines a slice of structs, ranges over it, and calls t.Run for each case.

func TestClamp(t *testing.T) {
    // Define the test table: each struct holds inputs and expected output
    tests := []struct {
        name     string
        val      int
        min      int
        max      int
        expected int
    }{
        {"below min", 0, 10, 20, 10},
        {"within range", 15, 10, 20, 15},
        {"above max", 25, 10, 20, 20},
    }

    // Iterate over the table and run a subtest for each row
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := Clamp(tt.val, tt.min, tt.max)
            if got != tt.expected {
                t.Errorf("Clamp(%d, %d, %d) = %d, want %d", tt.val, tt.min, tt.max, got, tt.expected)
            }
        })
    }
}

The struct fields map directly to the function signature plus an expected result. The name field feeds t.Run, which creates an isolated subtest. The closure captures tt for that specific iteration. The assertion compares the actual output to the expected value and reports a formatted message on failure.

How the test runner executes it

When go test executes this, it calls TestClamp once. The range loop yields three iterations. Each iteration calls t.Run with a unique name. The test runner spawns a subtest context, passes a fresh *testing.T to the closure, and executes the body. If the assertion fails, only that subtest fails. The other two continue running. The output lists each case by name, making it trivial to spot which scenario broke.

The closure receives its own t parameter. This is a Go convention: the testing parameter is always named t, never test or ctx. The subtest t inherits the parent logger but tracks its own pass/fail state and timing. You can call t.Parallel() inside the closure to run multiple table rows concurrently. The test runner waits for all subtests to finish before reporting the parent result.

Go 1.22 changed how loop variables work. Older versions reused the same memory address for tt across iterations, which caused closures to capture the final value instead of the current one. The compiler now creates a new variable per iteration, so the closure safely captures the correct row. If you run older toolchains, you would see a warning or incorrect test results. Modern Go handles it automatically.

Table-driven tests are the standard. Write the data once, run the logic many times.

A realistic production example

Production code rarely returns a single integer. It returns structs, errors, or multiple values. Here is a configuration parser that reads a string and returns a settings object or an error. The test table handles both success and failure paths.

func TestParseConfig(t *testing.T) {
    // Struct defines inputs, expected output, and whether an error is expected
    tests := []struct {
        name        string
        input       string
        wantErr     bool
        wantTimeout int
    }{
        {"valid config", "timeout=30", false, 30},
        {"missing value", "timeout=", true, 0},
        {"invalid int", "timeout=abc", true, 0},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            cfg, err := ParseConfig(tt.input)

            // Check error presence first to avoid nil pointer dereference
            if (err != nil) != tt.wantErr {
                t.Errorf("ParseConfig(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
                return
            }

            // Only check struct fields if no error was expected
            if !tt.wantErr && cfg.Timeout != tt.wantTimeout {
                t.Errorf("ParseConfig(%q).Timeout = %d, want %d", tt.input, cfg.Timeout, tt.wantTimeout)
            }
        })
    }
}

The wantErr boolean controls the verification flow. If an error is expected, the test verifies that err != nil and returns early. If no error is expected, it checks the struct fields. This pattern prevents panics from dereferencing a nil struct when an error occurs. The return statement after the error check stops further assertions from running on a failed case.

You can extend the table with skip conditions, environment flags, or golden file paths. The struct grows, but the loop stays identical. The test runner prints each subtest name, so you see exactly which configuration string triggered the failure. You can also add t.Cleanup inside the closure to tear down temporary files or database connections created during that specific row. The cleanup runs after the subtest finishes, regardless of pass or fail.

Keep the table flat. Nesting tables inside tables defeats the purpose.

Common traps and compiler complaints

Table-driven tests look simple until you hit edge cases. The most common mistake is mutating shared state inside the loop. If your function under test modifies a global variable or a slice passed by reference, subtests will interfere with each other. Always pass fresh copies or initialize state inside the closure.

Another trap is forgetting t.Run. If you call t.Errorf directly inside the range loop, all failures batch into a single test result. You lose the ability to run a specific case with go test -run TestClamp/below\ min. The test runner also cannot parallelize individual rows. Always wrap the body in t.Run.

The compiler will catch structural mistakes early. If you miss a field in the struct literal, you get missing field in struct literal or cannot use type X as type Y in field value. If you accidentally shadow the t parameter inside the closure, the compiler rejects it with t redeclared in this block. If you pass the wrong number of arguments to t.Errorf, you see too many arguments in call to t.Errorf. These errors are straightforward. Fix the signature or the struct definition and the build succeeds.

One convention worth noting: error checking in Go tests follows the same pattern as production code. if err != nil { return err } is verbose by design. Tests should verify the unhappy path explicitly. Do not swallow errors with _ unless you are testing a function that intentionally ignores them. The underscore discards a value intentionally. Use it sparingly.

Subtests isolate failures. Never share mutable state across rows.

When to use table-driven tests versus alternatives

Use table-driven tests when you need to verify the same function against multiple inputs and expected outputs. Use a single linear test when the scenario requires complex setup, external dependencies, or stateful interactions that cannot be expressed as static data. Use property-based testing with a library like go-quick when you want to generate thousands of random inputs and verify invariants rather than checking fixed cases. Use integration tests when you need to validate the interaction between multiple packages, a database, or an HTTP server. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Where to go next