What is a table-driven test, and why is it idiomatic in Go?

4 minbeginnergotestingtable-driven-tests

Quick Answer

A table-driven test defines a slice (or map) of test cases — typically a struct with an input, an expected output, and a descriptive name — then loops over it, running the same test logic against every case. It's idiomatic in Go because the language has no built-in parameterized test annotations (unlike JUnit's @ParameterizedTest), and a table plus a loop achieves the same result with plain Go syntax, no test framework required beyond the standard testing package. This pattern keeps test logic in one place, makes adding a new case as simple as adding one more struct literal to the slice, and pairs naturally with t.Run to give each case its own named subtest, so a single failing input is easy to identify without hunting through a wall of near-identical test functions.

Detailed Answer

Table-driven tests are the default way most Go code gets tested, precisely because the language provides no separate parameterized-test mechanism.

The basic shape

func TestAdd(t *testing.T) {
    cases := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive numbers", 2, 3, 5},
        {"negative numbers", -2, -3, -5},
        {"zero", 0, 5, 5},
        {"mixed sign", -2, 5, 3},
    }

    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            result := Add(tc.a, tc.b)
            if result != tc.expected {
                t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, result, tc.expected)
            }
        })
    }
}

Why this beats one function per case

// The alternative: repetitive, and easy to forget updating consistently
func TestAddPositive(t *testing.T) { ... }
func TestAddNegative(t *testing.T) { ... }
func TestAddZero(t *testing.T) { ... }

Adding a new scenario in the table-driven version means adding one line to the slice literal. In the one-function-per-case version, it means writing (and remembering to write correctly) an entirely new test function, with all the same boilerplate repeated.

Testing error cases in the same table

cases := []struct {
    name    string
    input   string
    want    int
    wantErr bool
}{
    {"valid number", "42", 42, false},
    {"invalid input", "abc", 0, true},
}

for _, tc := range cases {
    t.Run(tc.name, func(t *testing.T) {
        got, err := ParseNumber(tc.input)
        if (err != nil) != tc.wantErr {
            t.Fatalf("unexpected error state: %v", err)
        }
        if got != tc.want {
            t.Errorf("got %d, want %d", got, tc.want)
        }
    })
}

Including a wantErr field lets the same table cover both success and failure cases, keeping all related scenarios for one function in a single, readable test.

Related Resources