Skip to main content
eLearner.app
Module 9 · Lesson 2 of 542/50 in the course~14 min
Module lessons (2/5)

Table-driven tests

Table-driven tests are Go's idiomatic pattern: instead of writing N separate TestThisSpecificCase functions, you declare a slice of cases and iterate. There is ONE assertion block, and the test data is separated from the logic.

The base pattern

Go
func TestAbs(t *testing.T) {
    cases := []struct {
        name string
        in   int
        want int
    }{
        {"pos", 3, 3},
        {"neg", -3, 3},
        {"zero", 0, 0},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            if got := Abs(tc.in); got != tc.want {
                t.Errorf("Abs(%d) = %d; voglio %d", tc.in, got, tc.want)
            }
        })
    }
}

Three ingredients:

  1. Slice of anonymous struct with the usual fields: name, the inputs, the expected output (and maybe an expected error).
  2. for _, tc := range cases — the execution loop.
  3. t.Run(tc.name, func(t *testing.T) { ... }) — every case becomes a sub-test with its own name.

Why t.Run?

t.Run creates a sub-test that has:

  • Individual name shown in the output: TestAbs/pos, TestAbs/neg, ...
  • Its own t *testing.T: a failure in one case does not stop the others (unless t.Fatal is used).
  • Command-line filtering: go test -run TestAbs/neg runs only that case.
  • Optional parallelism: call t.Parallel() inside the func to parallelize.
Bash
go test -v -run TestAbs/neg

Cases with expected errors

Go
cases := []struct {
    name    string
    in      string
    want    int
    wantErr bool
}{
    {"ok", "42", 42, false},
    {"bad", "x", 0, true},
}
for _, tc := range cases {
    t.Run(tc.name, func(t *testing.T) {
        got, err := Atoi(tc.in)
        if (err != nil) != tc.wantErr {
            t.Fatalf("err = %v, wantErr = %v", err, tc.wantErr)
        }
        if !tc.wantErr && got != tc.want {
            t.Errorf("got %d, want %d", got, tc.want)
        }
    })
}

The loop closure pitfall (pre-Go 1.22)

In versions before Go 1.22, the tc variable was shared across iterations: calling t.Parallel() inside t.Run caused all sub-tests to run against the same last case. The fix was:

Go
for _, tc := range cases {
    tc := tc // local rebind (pre-1.22)
    t.Run(tc.name, func(t *testing.T) {
        t.Parallel()
        // ...
    })
}

Naming the cases

Best practices:

  • A short but evocative name: "empty input", "too large", "negative".
  • No spaces if you want to filter easily with -run (or use underscores).
  • Always include a "happy path" case, an "edge" case (zero, empty) and at least one error case.

Exercises

Exercise#go.m9.l2.e1
Attempts: 0Loading…

Define a cases slice of anonymous struct with fields in and want, populated with 3 cases to test Abs (positive, negative, zero).

Loading editor…

Solution available after 3 attempts

Exercise#go.m9.l2.e2
Attempts: 0Loading…

Add t.Run with tc.name to turn each case into a named sub-test.

Loading editor…

Solution available after 3 attempts

Quiz#go.m9.l2.e3
Ready

What is the main advantage of the table-driven pattern over N separate Test functions?

Go
for _, tc := range cases {
  t.Run(tc.name, func(t *testing.T) { ... })
}
Answer options