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:
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.
"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):
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.
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:
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 …
})
}
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
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?
-run Test/name.
Q2. Why does the house style build fresh mocks for every parallel subtest?
newSuite() per case.
Q3. The tt := tt line inside a parallel table loop exists to…
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.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."
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.