Write an idiomatic table-driven test with subtests. Why is this the preferred Go style?
func TestParseDuration(t *testing.T) {
tests := []struct {
name string
in string
want time.Duration
wantErr bool
}{
{name: "seconds", in: "5s", want: 5 * time.Second},
{name: "mixed", in: "1h30m", want: 90 * time.Minute},
{name: "empty", in: "", wantErr: true},
{name: "garbage", in: "abc", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := time.ParseDuration(tt.in)
if (err != nil) != tt.wantErr {
t.Fatalf("ParseDuration(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr)
}
if got != tt.want {
t.Errorf("ParseDuration(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}
Benefits: adding a case is one line; each case is named and individually runnable (go test -run 'TestParseDuration/mixed'); t.Fatal in one subtest doesn't abort the others; failure messages follow the Go convention Func(input) = got, want want.
Gotchas: use named fields in the struct literals so the table stays readable; use a map keyed by name only if order doesn't matter (map iteration is randomized, which is actually a nice way to catch order-dependence); prefer go-cmp's cmp.Diff for comparing structs instead of reflect.DeepEqual so you get a readable diff. Don't put logic (if/else on the case) inside the loop body — split into separate tests instead. Since Go 1.22 each loop iteration has its own tt, so the old tt := tt copy is no longer needed even when subtests call t.Parallel().
More on Performance, Profiling & Testing
- Q376What does this benchmark report, and why is it wrong?
- Q377How do you run benchmarks rigorously and compare two implementations? Explain -benchmem, ReportAllocs, and benchstat.
- Q379What does this test print, and in what order?
- Q380What are the rules and pitfalls of t.Parallel()?
- Q381Compare t.Cleanup, defer, and TestMain for setup/teardown. When is each appropriate?
- Q382How does native fuzzing work in Go? Write a fuzz test and explain the corpus.