Every Go codebase converges on the same test shape: a slice of cases, a
loop, one t.Run per case. It is the closest thing Go has to a testing
idiom, and it is worth writing deliberately rather than by habit.
The shape
func TestParseDuration(t *testing.T) {
tests := []struct {
name string
give string
want time.Duration
wantErr string
}{
{
name: "seconds",
give: "30s",
want: 30 * time.Second,
},
{
name: "compound",
give: "1h30m",
want: 90 * time.Minute,
},
{
name: "missing unit",
give: "30",
wantErr: "missing unit",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseDuration(tt.give)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
Three things earn their place here.
name is required, not optional. It is what go test -run matches
against and what a failure report prints. tests[2] tells you nothing;
TestParseDuration/missing_unit tells you everything.
give and want, not input and expected. Short, and they line
up in the struct literal so the table reads as a table. This is Uber’s
convention and it holds up.
Error cases live in the same table. A separate TestParseDurationErrors
duplicates the setup and drifts from the success path over time. A
wantErr string field and an early return keeps one table.
Match on error values, not strings
ErrorContains above is the pragmatic choice, but string matching on
error text makes the message part of your API — reword it and the test
breaks for no real reason. Where you control the error, prefer a
sentinel or a typed error:
want error // e.g. ErrMissingUnit
...
require.ErrorIs(t, err, tt.want)
Subtests, and the parallel trap
t.Run gives each case its own failure scope: one case failing does not
stop the rest, and -run 'TestX/case_name' isolates a single one.
Adding t.Parallel() inside the subtest used to require re-binding the
loop variable, because every closure captured the same tt. Go 1.22
changed loop variables to be per-iteration, so as of any module
declaring go 1.22 or later:
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // tt is per-iteration since Go 1.22 — no shadowing needed
...
})
}
The tt := tt line you still see in older code is a no-op now, not a
bug — but new code should not add it.
Keep tests hermetic
A table-driven test that reaches the network is still a flaky test with
extra structure. No live services, no dependence on wall-clock time, no
ordering assumptions between cases. When you need a real API response,
save it once in testdata/ and read it from disk:
body, err := os.ReadFile(filepath.Join("testdata", tt.fixture))
require.NoError(t, err)
testdata/ is ignored by the go tool, so fixtures never end up in a
build. The payoff is that the suite runs identically on your laptop, in
CI, and on a plane.
When not to use a table
A table is for one function across many inputs. If each case needs different setup, different mocks, or a different call sequence, the struct grows a field per special case and every case pays for the others’ complexity. That is the signal to write separate test functions — the table has stopped describing a table.