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.