Table-Driven Tests in Go: Patterns and Best Practices

Table-Driven Tests in Go: Patterns and Best Practices

Table-driven tests are the dominant Go testing pattern for a reason: they separate test data from test logic, make adding cases trivial, and produce readable failure messages. The Go standard library uses them extensively. This guide covers the full pattern — from basic structs to parallel execution, error formatting, and gotchas to avoid.

The Core Pattern

Instead of repeating the same test logic for each input, define a slice of test cases and loop over them:

func TestAdd(t *testing.T) {
    cases := []struct {
        a, b int
        want int
    }{
        {2, 3, 5},
        {-1, -2, -3},
        {0, 0, 0},
        {100, -100, 0},
    }

    for _, tc := range cases {
        got := Add(tc.a, tc.b)
        if got != tc.want {
            t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.want)
        }
    }
}

This works, but has a problem: when a case fails, you can't immediately tell which one without reading the error values carefully. Adding names fixes this.

Named Test Cases with t.Run

Wrap each case in t.Run to get named subtests:

func TestAdd(t *testing.T) {
    cases := []struct {
        name    string
        a, b    int
        want    int
    }{
        {"positive", 2, 3, 5},
        {"negative", -1, -2, -3},
        {"zeros", 0, 0, 0},
        {"cancel out", 100, -100, 0},
    }

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

Output on failure:

--- FAIL: TestAdd/cancel_out (0.00s)
    math_test.go:22: got 1; want 0

You know exactly which case failed. Run it in isolation:

go test -run TestAdd/cancel_out ./...

Struct Design: What to Include

A well-designed test case struct makes intent explicit:

func TestParseDate(t *testing.T) {
    cases := []struct {
        name    string
        input   string
        want    time.Time
        wantErr bool
    }{
        {
            name:  "valid ISO date",
            input: "2024-01-15",
            want:  time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
        },
        {
            name:    "empty string",
            input:   "",
            wantErr: true,
        },
        {
            name:    "invalid format",
            input:   "15/01/2024",
            wantErr: true,
        },
        {
            name:  "leap day",
            input: "2024-02-29",
            want:  time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC),
        },
        {
            name:    "invalid leap day",
            input:   "2023-02-29",
            wantErr: true,
        },
    }

    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            got, err := ParseDate(tc.input)

            if tc.wantErr {
                if err == nil {
                    t.Errorf("ParseDate(%q): expected error, got nil", tc.input)
                }
                return
            }

            if err != nil {
                t.Fatalf("ParseDate(%q): unexpected error: %v", tc.input, err)
            }

            if !got.Equal(tc.want) {
                t.Errorf("ParseDate(%q) = %v; want %v", tc.input, got, tc.want)
            }
        })
    }
}

Key decisions in struct design:

  • wantErr bool for cases that should error (use wantErrMsg string if you need to assert the message)
  • Input fields that show exactly what's being tested
  • Separate want and wantErr — don't use zero values to signal "expect error"

Error Message Formatting

Good error messages answer: what input caused this, what did we get, what did we expect?

// Bad — no context
t.Errorf("got %d; want %d", got, tc.want)

// Good — includes input
t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.want)

// For string inputs, quote them
t.Errorf("Parse(%q) = %q; want %q", tc.input, got, tc.want)

// For complex types, use %+v or %#v
t.Errorf("Process(%+v) = %+v; want %+v", tc.input, got, tc.want)

When the output is complex (a struct, slice, or map), use fmt.Sprintf with %+v and compare strings, or use reflect.DeepEqual:

if !reflect.DeepEqual(got, tc.want) {
    t.Errorf("ProcessItems(%v):\ngot:  %+v\nwant: %+v", tc.input, got, tc.want)
}

Multi-line format (\n) makes diffs readable when the values are long.

Parallel Table Tests

Mark the outer test and each subtest as parallel for faster execution:

func TestAdd(t *testing.T) {
    t.Parallel()

    cases := []struct {
        name    string
        a, b    int
        want    int
    }{
        {"positive", 2, 3, 5},
        {"negative", -1, -2, -3},
    }

    for _, tc := range cases {
        tc := tc // capture range variable (required before Go 1.22)
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            got := Add(tc.a, tc.b)
            if got != tc.want {
                t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.want)
            }
        })
    }
}

The tc := tc capture is the most common bug with parallel table tests before Go 1.22. Without it, all goroutines share the same tc variable, which will have the last value from the loop by the time they run. Go 1.22 fixed this at the language level.

Testing Error Types and Values

When you need to assert the specific error, not just that one occurred:

var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")

func TestGet(t *testing.T) {
    cases := []struct {
        name    string
        key     string
        want    string
        wantErr error
    }{
        {"exists", "key1", "value1", nil},
        {"missing", "key2", "", ErrNotFound},
        {"forbidden", "secret", "", ErrPermission},
    }

    store := setupTestStore(t)

    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            got, err := store.Get(tc.key)

            if !errors.Is(err, tc.wantErr) {
                t.Errorf("Get(%q) error = %v; want %v", tc.key, err, tc.wantErr)
            }

            if err == nil && got != tc.want {
                t.Errorf("Get(%q) = %q; want %q", tc.key, got, tc.want)
            }
        })
    }
}

Use errors.Is instead of == for error comparison — it handles wrapped errors correctly.

Naming Conventions

Test case names should describe the scenario, not the expected result:

// Bad names — describe what happens, not what's being tested
{"returns 5", 2, 3, 5},
{"returns error", "", true},

// Good names — describe the scenario
{"two positive integers", 2, 3, 5},
{"empty input returns error", "", true},
{"overflow wraps to negative", math.MaxInt, 1, math.MinInt},

For HTTP handler tests, name by HTTP context:

cases := []struct {
    name           string
    method         string
    path           string
    body           string
    wantStatus     int
    wantBodyContains string
}{
    {"GET returns user", "GET", "/users/1", "", 200, `"id":1`},
    {"POST creates user", "POST", "/users", `{"name":"alice"}`, 201, `"name":"alice"`},
    {"DELETE unknown user", "DELETE", "/users/999", "", 404, "not found"},
    {"invalid method", "PATCH", "/users/1", "", 405, ""},
}

Sharing Setup Across Cases

When test cases need a shared fixture (database connection, HTTP server), set it up once before the loop:

func TestUserRepository(t *testing.T) {
    db := setupTestDB(t) // t.Cleanup registered inside
    repo := NewUserRepository(db)

    cases := []struct {
        name    string
        userID  int
        want    *User
        wantErr bool
    }{
        {"existing user", 1, &User{ID: 1, Name: "Alice"}, false},
        {"missing user", 999, nil, true},
    }

    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            got, err := repo.FindByID(tc.userID)

            if tc.wantErr {
                if err == nil {
                    t.Error("expected error, got nil")
                }
                return
            }

            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }

            if !reflect.DeepEqual(got, tc.want) {
                t.Errorf("FindByID(%d):\ngot:  %+v\nwant: %+v", tc.userID, got, tc.want)
            }
        })
    }
}

If each case needs isolated state (e.g., separate DB transactions), set up and tear down inside the loop:

for _, tc := range cases {
    t.Run(tc.name, func(t *testing.T) {
        tx, err := db.Begin()
        if err != nil {
            t.Fatal(err)
        }
        t.Cleanup(func() { tx.Rollback() })

        // use tx for this case
    })
}

When to Not Use Table-Driven Tests

Table-driven tests work best when multiple inputs share the exact same test logic. They're a poor fit when:

  • Each case has substantially different setup or assertions
  • There are only 1-2 cases — a simple test function is cleaner
  • Cases have complex interdependencies or ordering requirements

Don't force table structure onto tests that aren't naturally tabular. The goal is readability, not uniformity.

Quick Reference

// Basic structure
cases := []struct {
    name    string
    input   InputType
    want    OutputType
    wantErr bool
}{
    {"descriptive name", input, expected, false},
}

for _, tc := range cases {
    tc := tc
    t.Run(tc.name, func(t *testing.T) {
        t.Parallel() // if safe
        got, err := FunctionUnderTest(tc.input)
        // assertions
    })
}

Table-driven tests make test suites easy to extend — adding a new edge case is a one-liner in the cases slice, not a new function. That low friction is why the Go community landed on this as the default approach.

HelpMeTest pairs well with Go table tests by continuously running end-to-end scenarios against your deployed service, catching integration regressions that unit-level tables can't cover.

Read more

Start now free