Table-driven tests

Table-driven tests use a slice of structs to define multiple test cases and iterate over them in a single function.

The problem with repeating yourself

You write a function that parses a string into an integer. It works for "42". You write a test. It passes. Then you realize it should also handle leading zeros, negative signs, and overflow. You copy the test, change the input, change the expected output, and run it again. You do this six times. Your test file looks like a copy-paste graveyard. Every time you add a new edge case, you duplicate the same assertion logic. If the assertion changes, you have to update it in six places. This is the exact problem table-driven tests solve.

What a table-driven test actually is

A table-driven test flips the script. Instead of writing the test logic once per case, you write the logic once and feed it a list of cases. Think of it like a spreadsheet. The columns are your inputs and expected outputs. The rows are your scenarios. The test function reads each row, runs the function under test, and compares the result against the expected column. If anything mismatches, it reports exactly which row failed.

This pattern is the standard in Go. You will see it in the standard library, in open-source projects, and in production codebases. It keeps test files readable and makes adding new cases a matter of appending a single line to a slice. The testing package does not enforce this pattern. It is a community convention that emerged because it aligns perfectly with Go's preference for explicit, readable code over metaprogramming or framework magic.

The minimal pattern

Here is the simplest form. You define a slice of structs, populate it with cases, and loop over it.

func TestParseNumeric(t *testing.T) {
    // Define the test cases as a slice of anonymous structs
    cases := []struct {
        name string
        in   string
        want int64
        ok   bool
    }{
        {"zero padding", "0000000", 0, true},
        {"overflow", "0123456789", 0, false},
        {"negative", "-42", -42, true},
    }

    // Iterate over each case and run the assertion
    for _, tc := range cases {
        got, ok := parseNumeric(tc.in)
        if ok != tc.ok || got != tc.want {
            t.Errorf("parseNumeric(%q): got (%d, %v), want (%d, %v)", tc.in, got, ok, tc.want, tc.ok)
        }
    }
}

The struct fields hold the inputs and the expected outputs. The loop runs the function and compares the results. If the comparison fails, t.Errorf logs the mismatch and marks the test as failed. The test runner continues to the next case instead of stopping immediately. This flat loop is perfectly valid for quick scripts or throwaway prototypes, but it lacks isolation. When one case fails, the error message only tells you the input and output. It does not tell you which logical scenario broke, and it does not let you re-run just that scenario.

How the testing package actually runs it

When you execute go test, the compiler bundles your _test.go files with your package and links them against the testing package. The runner scans for functions matching the signature func TestX(*testing.T). It creates a fresh *testing.T instance for each test function and passes it in. That instance tracks failures, buffers log output, and controls execution flow.

The slice literal creates the test data in memory. The for _, tc := range cases loop pulls one struct at a time. Inside the loop, you call the function under test, capture its return values, and compare them to the struct fields. The t.Errorf call appends a failure message to the test result. After the loop finishes, the testing package reports pass or fail based on whether t.Errorf was ever called.

Go treats tests as regular functions. There is no special test framework magic. The testing package gives you a runner and a logger. Everything else is just Go code. That simplicity is why the table pattern works so well. You are just looping over data and calling functions. The convention is to keep test files in the same package as the code they test, ending in _test.go. This placement gives them access to unexported functions and types. You can test internal logic without exposing it publicly.

Real-world shape with subtests

Production tests rarely stay flat. You usually want each case to run as a separate subtest. Subtests give you granular output, allow you to run specific cases with go test -run TestParseNumeric/overflow, and keep failures isolated. You also want to handle errors properly instead of comparing boolean flags.

func TestParseNumeric(t *testing.T) {
    // Subtests isolate each scenario and allow targeted re-runs
    cases := []struct {
        name string
        in   string
        want int64
        err  bool
    }{
        {"zero padding", "0000000", 0, false},
        {"overflow", "0123456789", 0, true},
        {"negative", "-42", -42, false},
    }

    for _, tc := range cases {
        // t.Run creates a subtest with its own *testing.T
        t.Run(tc.name, func(t *testing.T) {
            got, err := parseNumeric(tc.in)
            
            // Check error presence before comparing values
            if (err != nil) != tc.err {
                t.Fatalf("parseNumeric(%q) error = %v, wantErr %v", tc.in, err, tc.err)
            }
            
            // Skip value comparison if an error was expected
            if tc.err {
                return
            }
            
            if got != tc.want {
                t.Errorf("parseNumeric(%q) = %d, want %d", tc.in, got, tc.want)
            }
        })
    }
}

The t.Run call spawns a subtest. Each subtest gets its own *testing.T instance. If one subtest fails, the others still run. The t.Fatalf call logs the error and stops that specific subtest immediately. You use t.Fatalf for unrecoverable state inside a subtest, but you stick to t.Errorf when you want the test to keep checking other assertions. The community convention for error checks is to verify the error condition first, then return early if an error was expected. This prevents comparing values when the function already failed.

Go test functions follow a strict naming rule. They must start with Test and accept exactly one *testing.T parameter. The testing package discovers them by name. If you name it testParseNumeric with a lowercase t, the runner ignores it. Public names start with a capital letter in Go, and test functions are no exception. The receiver name convention does not apply to test functions, but the capitalization rule does.

Where things go sideways

Table-driven tests look simple until you hit the edge cases. The most common mistake is capturing loop variables incorrectly. If you move the function call outside the subtest closure or reference the loop variable directly in a goroutine, you get a race condition or a stale value. The compiler catches the classic loop variable capture with loop variable tc captured by func literal in Go 1.22 and later. You avoid this by passing tc as a parameter to the closure or by using the subtest pattern shown above, which already isolates the scope.

Another trap is overcomplicating the struct. You do not need a separate struct type for every test file. Anonymous structs inside the slice literal are idiomatic. If you find yourself writing a dedicated testCase type, ask whether the test data actually needs to be shared across files. Usually it does not. Keep the definition local to the test function.

Error messages also matter. A vague t.Errorf("failed") tells you nothing about which case broke. Always include the input, the actual output, and the expected output in the error string. The testing package prints the subtest name automatically, so your error message only needs to explain the mismatch.

You will also see people compare errors using == nil instead of errors.Is. If your function wraps errors, err != nil still works for presence checks, but you should use errors.Is(err, target) when you care about the specific error type. The compiler will reject direct equality comparisons between error values if they are interfaces, complaining with invalid operation: cannot compare err == target (operator == not defined on error). Stick to the errors package for semantic error matching.

When your test needs temporary files or network listeners, you must clean them up. The t.Cleanup function registers a callback that runs after the subtest finishes, regardless of pass or fail. You call it early in the subtest body so the cleanup runs even if a later assertion panics. The testing package guarantees cleanup runs in reverse order of registration. This prevents resource leaks from masking test failures.

When to use tables versus alternatives

Use a table-driven test when you have a pure function or a deterministic operation that takes inputs and produces outputs. Use a single test function with manual setup when the scenario requires complex state, network calls, or database transactions that cannot be expressed as a simple data row. Use t.Run subtests when you need granular control over execution, want to run specific cases with the -run flag, or need to isolate failures so one bad case does not mask others. Use plain sequential assertions when you are testing a single happy path and adding a table would add more boilerplate than value.

Tables turn repetition into data. Write the logic once, feed it cases, and let the runner do the heavy lifting.

Where to go next