Table-Driven Tests in Go: Patterns and Best Practices

Table-Driven Tests in Go: Patterns and Best Practices

Table-driven tests in Go group multiple test cases into a single slice of structs, then iterate with t.Run() for named subtests. Use t.Parallel() inside subtests for speed. Capture loop variables before goroutines. Break out of table-driven style when setup is too complex per case.

Table-driven tests are idiomatic Go. They reduce repetition, make cases self-documenting, and let the testing framework report exactly which scenario failed. Once you understand the pattern deeply — including subtleties around parallelism and variable capture — you will write cleaner, faster, and more maintainable test suites.

The Core Pattern

At its simplest, a table-driven test defines a slice of structs, each representing one scenario:

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

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

The t.Run() call creates a named subtest. When this test fails, Go reports TestAdd/mixed_signs rather than just TestAdd, giving you immediate clarity about which case broke. You can also run a single subtest with go test -run TestAdd/zero_values.

Structuring Test Cases for Readability

As functions grow more complex, your test struct needs more fields. Use named struct literals and group fields logically:

func TestParseConfig(t *testing.T) {
    type testCase struct {
        name    string
        input   string
        want    Config
        wantErr bool
        errMsg  string
    }

    tests := []testCase{
        {
            name:  "valid minimal config",
            input: `{"host":"localhost","port":8080}`,
            want:  Config{Host: "localhost", Port: 8080},
        },
        {
            name:    "missing required host",
            input:   `{"port":8080}`,
            wantErr: true,
            errMsg:  "host is required",
        },
        {
            name:    "invalid JSON",
            input:   `not json`,
            wantErr: true,
            errMsg:  "invalid character",
        },
        {
            name:  "port defaults to 3000",
            input: `{"host":"localhost"}`,
            want:  Config{Host: "localhost", Port: 3000},
        },
    }

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

            if tt.wantErr {
                if err == nil {
                    t.Fatalf("expected error containing %q, got nil", tt.errMsg)
                }
                if !strings.Contains(err.Error(), tt.errMsg) {
                    t.Errorf("error = %q, want to contain %q", err.Error(), tt.errMsg)
                }
                return
            }

            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }
            if got != tt.want {
                t.Errorf("ParseConfig() = %+v, want %+v", got, tt.want)
            }
        })
    }
}

Notice the wantErr / errMsg pattern. It is more explicit than storing an error value in the struct, because constructing sentinel errors for test tables creates coupling. A string fragment check is usually sufficient and more resilient to refactoring.

Parallel Subtests

Running subtests in parallel can significantly cut wall-clock time for I/O-heavy or compute-heavy test suites:

func TestFetchUser(t *testing.T) {
    tests := []struct {
        name   string
        userID int
        want   string
    }{
        {"admin user", 1, "alice"},
        {"regular user", 2, "bob"},
        {"guest user", 3, "charlie"},
    }

    for _, tt := range tests {
        tt := tt // capture range variable — critical before Go 1.22
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            got, err := FetchUserName(tt.userID)
            if err != nil {
                t.Fatalf("FetchUserName(%d): %v", tt.userID, err)
            }
            if got != tt.want {
                t.Errorf("got %q, want %q", got, tt.want)
            }
        })
    }
}

The line tt := tt is the classic Go loop variable capture fix. Before Go 1.22, all goroutines launched inside the loop shared the same tt variable, which would be overwritten on each iteration. Shadowing it with a local copy ensures each subtest closure captures its own value. In Go 1.22 and later, the loop variable semantics changed and this is no longer necessary — but it is harmless to keep for compatibility.

t.Parallel() signals that this subtest can run concurrently with other parallel subtests. The parent test function itself blocks until all parallel subtests finish.

A More Realistic Parallel Pattern

When testing database interactions or HTTP handlers you often need shared state — a test database or a mock server — set up before parallel subtests run:

func TestUserRepository(t *testing.T) {
    db := setupTestDB(t) // creates schema, seeds data

    tests := []struct {
        name   string
        query  UserQuery
        count  int
    }{
        {"all active users", UserQuery{Status: "active"}, 3},
        {"admins only", UserQuery{Role: "admin"}, 1},
        {"empty result", UserQuery{Status: "deleted"}, 0},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            users, err := db.QueryUsers(tt.query)
            if err != nil {
                t.Fatalf("QueryUsers: %v", err)
            }
            if len(users) != tt.count {
                t.Errorf("got %d users, want %d", len(users), tt.count)
            }
        })
    }
}

setupTestDB(t) registers a t.Cleanup() callback to tear the database down, so you do not need to manage cleanup manually inside each subtest.

Handling Error Cases Without Boolean Flags

An alternative to wantErr bool is to use a helper error type or a function in the struct:

tests := []struct {
    name    string
    input   string
    check   func(t *testing.T, got Result, err error)
}{
    {
        name:  "success case",
        input: "valid",
        check: func(t *testing.T, got Result, err error) {
            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }
            if got.Value != "processed:valid" {
                t.Errorf("got %q", got.Value)
            }
        },
    },
    {
        name:  "empty input rejected",
        input: "",
        check: func(t *testing.T, got Result, err error) {
            if !errors.Is(err, ErrEmptyInput) {
                t.Errorf("want ErrEmptyInput, got %v", err)
            }
        },
    },
}

for _, tt := range tests {
    tt := tt
    t.Run(tt.name, func(t *testing.T) {
        got, err := Process(tt.input)
        tt.check(t, got, err)
    })
}

The closure-based approach gives each case full control over assertions. It is more verbose but removes the need for conditional logic in the loop body.

When Table-Driven Tests Become Unwieldy

Table-driven tests are not always the right tool. Recognize these warning signs:

Per-case setup diverges too much. If each test case needs a different mock configuration, a different server, or calls to multiple setup functions, the struct grows enormous and the loop body fills with conditionals. At that point, separate Test* functions are cleaner.

Ordering matters between cases. Tables imply independence. If case 2 depends on state left by case 1, you have a sequential integration test, not a table. Use t.Run() without a table, or use a testify/suite.

Assertions are complex and unique per case. When the check function closure approach above starts looking like a full test function body, extract it.

The struct has many zero-value fields. A struct with 12 fields where each case fills only 3 different ones is a signal to split into multiple focused test functions or use the options pattern.

A good rule of thumb: if you cannot read the test table and immediately understand what each row is asserting, the table has grown too complex.

Naming Subtests Well

Subtest names appear in failure output and can be used as run filters. Follow these conventions:

  • Use lowercase with spaces: "returns error on empty input" not "ReturnsErrorOnEmptyInput"
  • Be specific about the scenario, not the function: "port defaults to 3000" not "default port test"
  • Avoid redundant prefixes: "valid input" not "test valid input"

Go converts spaces to underscores in the test name for filtering purposes, so go test -run TestParse/port_defaults_to_3000 works even though you wrote the name with spaces.

Combining with testify

If you use testify/assert, the loop body becomes more expressive:

for _, tt := range tests {
    tt := tt
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        got, err := ParseConfig([]byte(tt.input))
        if tt.wantErr {
            assert.Error(t, err)
            assert.Contains(t, err.Error(), tt.errMsg)
            return
        }
        assert.NoError(t, err)
        assert.Equal(t, tt.want, got)
    })
}

assert continues the test after a failure; require stops immediately. Use require for preconditions (non-nil pointer, successful parse) and assert for the actual value checks.

Key Takeaways

Table-driven tests shine when you have a pure function with multiple input/output combinations. Define a named struct inside the test function, use t.Run() for subtests, shadow the loop variable before closures, and call t.Parallel() when subtests are independent. Break out of the pattern when setup complexity or assertion diversity makes the table harder to read than separate test functions would be. The goal is clarity — the pattern serves that goal, not the other way around.

Read more

Start now free