Lesson 20 · Testing, tooling & performance

Table-driven tests

The Go testing idiom — one row per case, in our repo's exact style.

Your win: write an idiomatic table-driven test with subtests and testify, matching the shape reviewers expect in this codebase.

Testing is built into the language

No framework needed: a file ending _test.go, a function func TestXxx(t *testing.T), and go test ./.... Subtests come from t.Run(name, func(t *testing.T){...}), and t.Parallel() lets independent tests run concurrently.1

The table-driven pattern

Go's signature testing idiom: describe cases as a slice of structs, then loop, running each as a named subtest. One row per scenario — adding a case is one line.

cases := []struct {
    name    string
    in      Input
    want    Output
    wantErr bool
}{
    {name: "happy path",   in: ..., want: ...},
    {name: "empty input", in: ..., wantErr: true},
}
for _, tc := range cases {
    t.Run(tc.name, func(t *testing.T) {
        got, err := Fn(tc.in)                // act
        if tc.wantErr { require.Error(t, err); return }
        require.NoError(t, err)
        assert.Equal(t, tc.want, got)        // assert
    })
}
testify: require vs assert require.X stops the test on failure (use when continuing is pointless — e.g. a nil result you're about to deref). assert.X records the failure and continues (use to collect multiple field checks). Both read like English: require.NoError, assert.Equal, assert.ErrorIs.

Our repo's exact shape

The documented conventions (.claude/rules/go-test-style.md): t.Parallel(), a context.WithTimeout, testify, the // arrange // act // assert comments, and t.Run subtests.

internal/spike/modules/email/application/commands/create_email_handler_test.go:22
func TestCreateEmailHandler(t *testing.T) {
    t.Parallel()
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()
    ...
    t.Run("happy case", func(t *testing.T) { // arrange / act / assert inside })
}
Anchor Repo-side you'll see the full table form in repository tests (notification/.../repositories/media_test.go, tagmgmt/repositories/tag_test.go) and the t.Run-per-scenario form in handler tests (create_email_handler_test.go). Match whichever the file you're editing already uses.
Read this next

Learn Go with Tests + the testing package

The best hands-on path (TDD from lesson one), plus the authoritative API. Mocks — the other half of our tests — are Lesson 21.

quii.gitbook.io/learn-go-with-tests
pkg.go.dev/testing

Check yourself (from memory)

Q1. A table-driven test iterates over…

Cases as struct rows, each run as a named subtest. Adding a scenario is one line.

Q2. testify's require differs from assert in that require

require halts (fatal); assert records and continues. Use require before a deref, assert to collect field checks.

Q3. In our repo, tests begin with t.Parallel() and…

t.Parallel() + a bounded context.WithTimeout, then testify mocks — the documented house style.
Sketch the shape of an idiomatic table-driven test.
recall, then click to reveal
Define cases := []struct{ name string; in ...; want ...; wantErr bool }{...}; then for _, tc := range cases { t.Run(tc.name, func(t *testing.T){ t.Parallel(); got, err := Fn(tc.in); if tc.wantErr { require.Error(t, err); return }; require.NoError(t, err); assert.Equal(t, tc.want, got) }) }. One row per case, subtests name each, arrange/act/assert inside. Our repo adds context.WithTimeout and testify mocks.
Want the golden-file, fuzzing, or t.Cleanup patterns too? Ask me.

1. testing package; Learn Go with Tests.