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
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:
- Slice of anonymous struct with the usual fields:
name, the inputs, the expected output (and maybe an expected error). for _, tc := range cases— the execution loop.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 (unlesst.Fatalis used). - Command-line filtering:
go test -run TestAbs/negruns only that case. - Optional parallelism: call
t.Parallel()inside the func to parallelize.
go test -v -run TestAbs/negCases with expected errors
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:
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
Define a cases slice of anonymous struct with fields in and want, populated with 3 cases to test Abs (positive, negative, zero).
Solution available after 3 attempts
Add t.Run with tc.name to turn each case into a named sub-test.
Solution available after 3 attempts
What is the main advantage of the table-driven pattern over N separate Test functions?
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { ... })
}