Skip to content

Day 043 — Table-Driven Tests & Subtests

Month 2 · Week 3 · ⬅ Day 042 · Day 044 ➡ · Journal index

🎯 Learning Objective

Write idiomatic Go tests using the table-driven pattern and t.Run subtests, so each case is named, isolated, and individually runnable.

📚 Topics

  • testing.T, *_test.go, the TestXxx(t *testing.T) signature
  • Table-driven cases as a []struct
  • Subtests with t.Run(name, func) · t.Error vs t.Fatal

📖 Reading / Sources

📝 Notes

  • A test file ends in _test.go, lives in the same package (white-box) or package foo_test (black-box), and exports func TestXxx(t *testing.T). go test ./... discovers them.
  • Table-driven tests: declare a slice of anonymous structs (one row per case, with a name field) and loop. Adding a case is one line, not one function → [[table-driven-tests]].
  • Subtests: wrap each row in t.Run(tc.name, func(t *testing.T){...}). Benefits: named failures, go test -run TestX/case_name to target one, and independent failure isolation.
  • t.Error/t.Errorf mark the test failed but keep going (good for collecting all failing rows); t.Fatal/t.Fatalf call runtime.Goexit and stop that subtest (use when later assertions would panic).
  • Failure messages should read got vs want: t.Errorf("F(%q) = %q; want %q", in, got, want). No assertion library needed.
  • t.Fatal only stops the current goroutine — never call it from a goroutine you spawned; the test won't actually fail. → [[t-fatal-goroutine-trap]]
  • Subtest names get spaces replaced by _ in the CLI; keep them short and unique.

💻 Code Examples

func TestReverse(t *testing.T) {
    cases := []struct {
        name, in, want string
    }{
        {"empty", "", ""},
        {"ascii", "Hello", "olleH"},
        {"accented", "héllo", "olléh"}, // é stays one rune
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            if got := Reverse(tc.in); got != tc.want {
                t.Errorf("Reverse(%q) = %q; want %q", tc.in, got, tc.want)
            }
        })
    }
}

Full code: exercises/month-02/week-3/reverse/ · Run: go test ./exercises/month-02/week-3/reverse

🏋️ Exercises / Practice

Exercise Status Link
Reverse rune-aware reversal + table test exercises/month-02/week-3/reverse
wordfreq.Count/Top with named subtests exercises/month-02/week-3/wordfreq

🐛 Mistakes Made

  • Used t.Fatal inside a t.Run subtest expecting later rows to still run — they did, because Fatal only stops that subtest's goroutine. That's actually the right behavior; my mental model was off.
  • Compared maps with == (compile error) → switched to reflect.DeepEqual.

❓ Open Questions

  • When is black-box (package foo_test) worth the import friction over white-box? (Answer forming: when you want to test only the exported API and avoid coupling to internals.)

🧠 Active Recall (answer without looking)

  1. Q: Difference between t.Error and t.Fatal?
    A

Error marks failure and continues; Fatal marks failure and stops the current test/subtest goroutine via runtime.Goexit. Never call Fatal from a spawned goroutine. 2. Q: Why give each table row a name and wrap it in t.Run?

A

Named subtests pinpoint which case failed, isolate failures, and let you run one with go test -run TestX/name.

🪶 Feynman Reflection

A table-driven test is a spreadsheet of inputs and expected outputs that one loop walks through. t.Run turns each row into its own mini-test with a name, so the report tells you exactly which row broke instead of a single anonymous failure.

🕳️ Knowledge Gaps

  • Setup/teardown patterns (TestMain, per-subtest cleanup) — covered tomorrow with t.Cleanup/helpers.

✅ Summary

I can structure tests as named rows looped through t.Run, and I know when to keep going (Error) vs stop (Fatal).

⏭️ Next Steps / Prep for Tomorrow

  • Day 044: parallel subtests with t.Parallel, the loop-variable capture trap, and t.Helper/t.Cleanup.

Time spent Difficulty Confidence
90 min 🟦🟦⬜⬜⬜ 🟦🟦🟦⬜⬜

Suggested commit: test(week-3): table-driven tests and subtests (day 043)