Lesson 2 · The unit-test foundation

Table-driven tests & subtests

One case, one subtest — the shape every test here takes, plus the parallelism trap that has bitten every Go developer.

Your win: structure a test as a set of named subtests, run them in parallel with a bounded context, and avoid the loop-variable capture bug that used to silently break parallel table tests.

Table-driven: enumerate the cases

The idiomatic Go pattern — and the house pattern here — is table-driven testing: instead of one big test with many ifs, you enumerate each case and run them uniformly.1 Each case becomes a subtest via t.Run:

the shape (idiomatic)
func TestCreateEmail(t *testing.T) {
    t.Parallel()

    t.Run("return error when repo fails", func(t *testing.T) { /* case 1 */ })
    t.Run("return nil when success to create", func(t *testing.T) { /* case 2 */ })
    t.Run("return error when commit fails", func(t *testing.T) { /* case 3 */ })
}

t.Run("name", fn) gives each case its own name, its own pass/fail line in the output, and its own *testing.T.2 You can run one case alone: go test -run 'TestCreateEmail/commit_fails'. In this repo the "table" is usually this sequence of t.Run blocks rather than a literal slice — same idea, one sub-test per case.

The naming convention (required here) Sub-test names follow "return <result> when <condition>" — e.g. "return error when failed to get learning time", "return nil when learning time already attached", "return nil when success to create new learning time" (go-test-style.md:74-81). The name is the spec: reading the sub-test list tells you exactly which behaviours are covered.

Parallel + bounded: two house rules

Two lines appear at the top of nearly every test here (~2,377 files use the first):

internal/spike/modules/email/application/commands/create_email_handler_test.go:22-24
t.Parallel()                                                       // run alongside other parallel tests
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)  // fail, don't hang
defer cancel()

t.Parallel() marks the test to run concurrently — the whole suite finishes faster, and (paired with -count=3, Lesson 12) it flushes out order-dependent bugs. context.WithTimeout means a call that hangs fails the test at 15 seconds instead of blocking the run forever.

Parallel has a cost: independence Parallel subtests share nothing. That's why the house style builds a fresh set of mocks per sub-test (the newSuite() factory, Lesson 3) — two cases running at once must not touch the same mock, or they'd corrupt each other's expectations. Independence isn't optional once you go parallel.

⚠️ The loop-variable capture trap

When you do write a literal table and loop with parallel subtests, there's a classic bug. Historically, the loop variable was shared across iterations, so every parallel subtest saw the last case:

the trap (pre-Go 1.22)
for _, tt := range cases {
    tt := tt                             // ← THE FIX: shadow the loop var per iteration
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()                     // without `tt := tt`, all subtests see the last case
        // … use tt …
    })
}
Know both the bug and the modern fix The tt := tt shadow was mandatory for years and you'll see it all over older tests. Go 1.22 changed loop semantics so each iteration gets a fresh variable — the bug is gone on modern Go, and the shadow line is now redundant. For the interview: explain why it was needed (a captured, shared loop var + deferred parallel execution) and that 1.22 fixed it. The repo's t.Run-sequence style mostly sidesteps this entirely, since there's no loop variable to capture.2
Read this next

Table-driven tests & subtests

The two canonical Go references — the pattern, and how subtests give you names and controlled parallelism.

go.dev — Table-Driven Tests
go.dev — Using Subtests and Sub-benchmarks · in-repo .claude/rules/go-test-style.md:74-81

Check yourself (from memory)

Q1. What does t.Run("name", fn) create?

A subtest — one case of a table-driven test, individually named and runnable with -run Test/name.

Q2. Why does the house style build fresh mocks for every parallel subtest?

Independence is required once parallel: two cases running at once must not touch the same mock. Hence newSuite() per case.

Q3. The tt := tt line inside a parallel table loop exists to…

Pre-Go-1.22 the loop var was shared, so parallel subtests all saw the last case; shadowing fixed it. Go 1.22 made it unnecessary.
Recall: table-driven structure + the two rules + the trap.
shape + parallel/timeout + loop-var, then reveal
Table-driven: enumerate cases, run each as a subtest via t.Run("return <result> when <condition>", fn) — each gets a name, a pass/fail line, its own *testing.T, and -run Test/name selectability. Two house rules: t.Parallel() (near-universal; run concurrently → faster + flushes order bugs, esp. with -count=3) and ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) (hang → fail, not block). Parallel ⇒ independence ⇒ fresh mocks per case (newSuite()). Trap: in a parallel literal-table loop, add tt := tt before t.Run (pre-Go-1.22 the shared loop var made every parallel subtest see the last case); Go 1.22 fixed loop semantics so it's now redundant.
🎯 Interview one-liner "How do you write table-driven tests?" → "Each case is a named subtest via t.Run, so it has its own name and pass/fail line. I mark them parallel and give each a timed context. Historically you had to shadow the loop variable (tt := tt) or every parallel subtest captured the last case — Go 1.22 fixed that at the language level, but I still know why it was needed."
Next: the exact house skeleton — the newSuite factory and the assertExpected helper that make each subtest self-contained. Ask me about the loop-var trap if it's still fuzzy — it's a favourite interview question.

1. go.dev — Table-Driven Tests. In-repo naming: .claude/rules/go-test-style.md:74-81.

2. go.dev — Subtests & Sub-benchmarks. In-repo: …/create_email_handler_test.go:22-24.