Table-Driven Tests — Junior¶
Table of Contents¶
- Introduction
- Prerequisites
- Glossary
- The Problem We Are Solving
- Your First Table-Driven Test
- Why Each Field Exists
t.Runand Subtests- Subtest Naming Conventions
- Running One Row from the Command Line
- The
tc := tcStory - Go 1.22 Loop Scope Change
- Reading Failure Output
- Adding a New Case
- Negative Cases —
wantErr - Slice vs Map Tables
- Common Mistakes
- What to Read Next
- Self-Check
Introduction¶
A table-driven test is the most common way to write tests in Go. Instead of writing five test functions for five cases of the same behavior, you write one test function that loops through a slice of cases, where each case carries its inputs and expected outputs.
In this file you will:
- See the canonical pattern step by step.
- Learn why Go programmers prefer this over BDD-style frameworks.
- Understand
t.Runand what subtest names look like. - Learn the historical
tc := tcline and what changed in Go 1.22.
You do not need to know anything beyond func TestX(t *testing.T) and go test. We start from zero.
Prerequisites¶
- You can write a basic test:
package mymath
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
if got != 5 {
t.Errorf("Add(2,3) = %d, want 5", got)
}
}
- You know how to run
go testandgo test -vin a package. - You know what a
structand asliceare.
If any of those is unfamiliar, read 01-testing-basics first.
Glossary¶
- Test case — one row of inputs and expected outputs. Sometimes called a "case", a "row", or a "scenario".
- Test function — the top-level
func TestX(t *testing.T)that thego testtool finds and runs. - Subtest — a nested test created with
t.Run(name, func(t *testing.T) { ... }). Appears in output asTestX/name. - Assertion — a comparison between an actual value (
got) and an expected value (want), typically usingt.Errorfort.Fatalf. - Table — the slice (or map) of test cases. Almost always written as
[]struct{ name, in, want ... }. gotandwant— the conventional variable names in Go tests for "what we got" and "what we expected".
The Problem We Are Solving¶
Suppose you wrote a function:
package mymath
// Abs returns the absolute value of n.
func Abs(n int) int {
if n < 0 {
return -n
}
return n
}
A naive test suite looks like this:
func TestAbs_positive(t *testing.T) {
if got := Abs(5); got != 5 {
t.Errorf("Abs(5) = %d, want 5", got)
}
}
func TestAbs_negative(t *testing.T) {
if got := Abs(-7); got != 7 {
t.Errorf("Abs(-7) = %d, want 7", got)
}
}
func TestAbs_zero(t *testing.T) {
if got := Abs(0); got != 0 {
t.Errorf("Abs(0) = %d, want 0", got)
}
}
Three problems:
- Duplication. The same assertion shape (
Abs(x) = got, want X) repeats three times. Adding a fourth case means copy-pasting a fourth function and renaming. - Cluttered file. As soon as you have 20 cases, the file is 200 lines of mostly-identical scaffolding.
- Hard to scan. To answer "what inputs are tested?", you have to read every function body.
Table-driven tests fix all three.
Your First Table-Driven Test¶
Here is the same Abs function tested as a table:
package mymath
import "testing"
func TestAbs(t *testing.T) {
cases := []struct {
name string
in int
want int
}{
{"positive", 5, 5},
{"negative", -7, 7},
{"zero", 0, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Abs(tc.in)
if got != tc.want {
t.Errorf("Abs(%d) = %d, want %d", tc.in, got, tc.want)
}
})
}
}
Run it:
$ go test -v
=== RUN TestAbs
=== RUN TestAbs/positive
=== RUN TestAbs/negative
=== RUN TestAbs/zero
--- PASS: TestAbs (0.00s)
--- PASS: TestAbs/positive (0.00s)
--- PASS: TestAbs/negative (0.00s)
--- PASS: TestAbs/zero (0.00s)
PASS
ok example.com/mymath 0.001s
Every row appears as its own subtest with its own name. Adding a fourth case is one line:
That's it. The duplication is gone, the scaffolding is one loop, and every input/output pair is visible at a glance.
Why Each Field Exists¶
The struct has three fields. Let's understand each one.
name string — the case label¶
This becomes the subtest's name. Without name, the output is just TestAbs and you can't tell which row failed.
A good name describes the intent of the case, not the input itself:
- Good:
"positive","large_negative","max_int". - Less good:
"5","-7","abs_of_negative_one"(too long, redundant).
in int — the input¶
What you pass to the function under test. Field names vary by convention: in, input, args. If your function takes multiple parameters, use args and put a sub-struct, or have separate fields:
cases := []struct {
name string
a, b int
want int
}{
{"both positive", 2, 3, 5},
{"with zero", 0, 7, 7},
}
want int — the expected output¶
What you expect the function to return. Field names vary: want, expected. The Go community prefers want because it pairs naturally with got.
t.Run and Subtests¶
The t.Run function — added in Go 1.7 (August 2016) — creates a subtest. Its signature:
When you call t.Run("positive", subFunc) inside TestAbs, Go:
- Creates a new
*testing.Tfor the subtest. - Runs
subFunc(newT)in a goroutine. - Blocks the parent until the subtest completes (unless the subtest calls
t.Parallel). - Reports the subtest's result with the name
TestAbs/positive.
If the subtest calls t.Errorf, only that subtest fails. The parent test continues, and the other subtests still run. This is the whole point of using t.Run instead of just looping.
Without t.Run:
for _, tc := range cases {
got := Abs(tc.in)
if got != tc.want {
t.Errorf("[%s] Abs(%d) = %d, want %d", tc.name, tc.in, got, tc.want)
}
}
This still works, but:
- All cases run inside the same test, so you can't use
-run TestAbs/positiveto run one row. - A
t.Fatalfin one row stops the whole test, not just the row. - Output is less structured.
The t.Run version is uniformly better. Use it.
Subtest Naming Conventions¶
Conventions evolved from the standard library and large Go codebases:
- Lowercase, snake_case or hyphen-case.
"empty_input","already-clean". Avoid spaces — Go rewrites them to underscores in output anyway. - Describe the case, not the input.
"trailing_whitespace"is better than"hello___". - Keep short. Subtest names show up in CI logs; keep them under ~30 characters.
- Prefix for grouping. If your table covers multiple categories, prefix:
"email/valid_simple","email/invalid_no_at","phone/valid_us". The slash adds another subtest level for-runfiltering. - Use the input itself only if short and unique. For string tables,
tc.inis often the natural name:
This works until you have two rows with the same in (then Go suffixes the duplicates with #01, #02 — see "Common Mistakes").
Running One Row from the Command Line¶
A huge benefit of t.Run is filtering. Suppose your 50-row test for Abs has a flaky case large_negative. Run only that one:
The -run flag is a regex. Its semantics for slash-separated names:
- Split by
/into segments. - Each segment is a substring regex against the corresponding name level.
- So
-run TestAbs/largematchesTestAbs/large_negativeandTestAbs/large_positive. - To anchor exactly:
-run '^TestAbs$/^large_negative$'.
You will use -run daily when debugging a specific case. Get comfortable.
The tc := tc Story¶
This pattern is historical in Go versions before 1.22. You will see it in older code and need to recognize it.
The problem: in Go versions before 1.22, a for ... range loop reused the same loop variable across iterations:
// Go 1.21 and earlier
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// bug: by the time this runs, tc holds the LAST row
})
}
When the subtest had t.Parallel(), it didn't run immediately — it paused, the loop kept iterating, tc got overwritten, and by the time the parallel goroutine actually executed, tc was the last row. Every parallel subtest tested the same row.
The fix: shadow tc with a per-iteration copy by declaring a new variable with the same name:
for _, tc := range cases {
tc := tc // new variable, scoped to this iteration
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// now safe: tc is a fresh variable per iteration
})
}
This is not magic. It's just creating a new local variable that copies the current loop variable's value. The closure captures the new local, not the shared loop variable.
You will see tc := tc (and also i := i, name := name, row := row) all over older Go code. It is one of the most common idioms that confuses newcomers.
Go 1.22 Loop Scope Change¶
Go 1.22 (released February 2024) changed the rule. In Go 1.22+, each iteration of a for ... range loop has its own copy of the loop variable. So:
// Go 1.22+ modules (go.mod declares "go 1.22" or higher)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// safe — tc is per-iteration
})
}
This works because the language itself now does what tc := tc used to do manually.
How do you know which behavior you have? Check go.mod:
If the go line is 1.22 or higher, the new behavior applies. If it's 1.21 or lower, the old behavior applies and you need tc := tc.
The change came from issue 60078. It was discussed for years before landing. In 2026 you can mostly forget about it, but in legacy codebases (and old StackOverflow answers) the tc := tc pattern is everywhere.
Linters: copyloopvar (built into vet in recent Go versions) flags tc := tc shadows as redundant in Go 1.22+ modules. The flag is a suggestion to delete the line.
Reading Failure Output¶
When a subtest fails, you see:
=== RUN TestAbs
=== RUN TestAbs/positive
=== RUN TestAbs/negative
abs_test.go:18: Abs(-7) = -7, want 7
=== RUN TestAbs/zero
--- FAIL: TestAbs (0.00s)
--- PASS: TestAbs/positive (0.00s)
--- FAIL: TestAbs/negative (0.00s)
--- PASS: TestAbs/zero (0.00s)
FAIL
Three things to notice:
- The failing subtest is named.
TestAbs/negativetells you exactly which row. - The error message includes the input. That's why we wrote
Abs(%d) = %d, want %dinstead of justmismatch. Always include the input in failure messages. - Other subtests still ran.
TestAbs/zeropassed even thoughTestAbs/negativefailed. This is the value oft.Runover a flat loop.
Adding a New Case¶
The win of table-driven tests is that adding a case is one line. Suppose someone reports Abs(math.MinInt32) returns the wrong value (it's a classic overflow bug). To add a regression test:
That's the whole change. Compile, run:
You'll see the failure. Now fix Abs, rerun, watch it pass. The new case stays in the table forever, protecting against regression.
This low cost of adding cases is why table-driven tests encourage thorough coverage. With duplicated functions, adding a case is a chore. With a table, it's a habit.
Negative Cases — wantErr¶
Many functions return (value, error). To test them, add a wantErr field:
func TestParseInt(t *testing.T) {
cases := []struct {
name string
in string
want int
wantErr bool
}{
{"positive", "42", 42, false},
{"negative", "-7", -7, false},
{"empty", "", 0, true},
{"junk", "abc", 0, true},
{"too_large", "999999999999999999999", 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseInt(tc.in)
if (err != nil) != tc.wantErr {
t.Fatalf("ParseInt(%q) error = %v, wantErr = %v", tc.in, err, tc.wantErr)
}
if !tc.wantErr && got != tc.want {
t.Errorf("ParseInt(%q) = %d, want %d", tc.in, got, tc.want)
}
})
}
}
The key line is if (err != nil) != tc.wantErr — a compact way to assert "got an error iff we expected one". If wantErr is true and err is non-nil, the condition is true != true → false, no failure. If wantErr is false but err is non-nil, the condition is true != false → true, fail.
The middle file will show how to assert on which error you got (errors.Is, errors.As), not just "any error". For now, the boolean form is fine.
Slice vs Map Tables¶
Most tables are slices. Sometimes you'll see maps:
cases := map[string]struct {
in int
want int
}{
"positive": {5, 5},
"negative": {-7, 7},
"zero": {0, 0},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := Abs(tc.in)
if got != tc.want {
t.Errorf("Abs(%d) = %d, want %d", tc.in, got, tc.want)
}
})
}
Differences:
- Map: keys are the names. If two cases have the same name, the second overwrites the first — a small safety net against name collisions. But you can also accidentally delete a case this way.
- Map: iteration order is randomized. Each run, the cases run in a different order. This can surface bugs that depend on execution order, but it can also make output harder to scan.
- Slice: deterministic order. Easier to reason about.
Junior advice: use slices. They're simpler. You'll occasionally see maps in the wild; recognize them but don't use them as your default.
Common Mistakes¶
Mistake 1 — forgetting tc := tc in old Go¶
If your go.mod says go 1.21 or older and your table uses t.Parallel(), you must shadow:
Without the shadow, every parallel subtest sees the last row.
Mistake 2 — duplicate case names¶
cases := []struct{ name string; in, want int }{
{"big", 100, 100},
{"small", 1, 1},
{"big", -100, 100}, // oops, same name
}
Go disambiguates the second "big" with #01: TestAbs/big, TestAbs/big#01. Failures on TestAbs/big#01 are hard to map back. Always pick unique names.
Mistake 3 — generic failure messages¶
This tells you nothing. Include tc.in, got, tc.want:
Or use tc.name as a fallback if the input is complex:
Mistake 4 — t.Fatal outside t.Run¶
func TestX(t *testing.T) {
db, err := openDB()
if err != nil { t.Fatal(err) } // OK — aborts whole test, makes sense if DB is required
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
row, err := db.Query(tc.in)
if err != nil { t.Fatal(err) } // OK — only aborts this subtest
...
})
}
}
t.Fatal aborts the current test (the parent if called outside t.Run, the subtest if inside). Used carelessly outside t.Run, it can hide failures across rows.
Mistake 5 — comparing in wrong order¶
// Wrong direction in failure message
t.Errorf("Abs(%d) = %d, want %d", tc.in, tc.want, got)
// ^^^^^^^^ ^^^
// swapped
Convention: got first, want second.
Mistake 6 — relying on package state¶
var counter int
func TestCounter(t *testing.T) {
cases := []struct{ inc int; want int }{
{1, 1},
{2, 3},
{3, 6},
}
for _, tc := range cases {
t.Run(fmt.Sprint(tc.inc), func(t *testing.T) {
counter += tc.inc
if counter != tc.want { t.Errorf(...) }
})
}
}
This works as long as the rows run in order. Add t.Parallel, run with -shuffle on, or someone else's test mutates counter — it breaks. Avoid shared mutable state in tables.
Mistake 7 — assuming map order¶
If you use a map table and rely on a particular order, you have a bug. Maps don't guarantee order. Use a slice if order matters.
What to Read Next¶
- Middle —
-runregex, parallel tables, helpers, golden files. - Senior — designing tables for complex domains, matrix tests, nested tables.
- Find the Bug — practice diagnosing broken table-driven tests.
- Tasks — hands-on exercises.
Self-Check¶
Try answering each without re-reading:
- What's the difference between using
t.Runinside a loop and just looping? - What does
tc := tcdo, and why is it (mostly) unnecessary in Go 1.22+? - How do you run only one row of a table from the command line?
- Why is
t.Errorf("fail")a bad assertion message? - What's wrong with using a map for a table where rows depend on each other?
If any of these stumped you, re-read the corresponding section before moving on.
Worked Example — Testing a Reverse Function¶
Let's apply everything you just learned to a complete example.
The function:
// Reverse returns s reversed.
func Reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
The naive test:
func TestReverse(t *testing.T) {
if got := Reverse("hello"); got != "olleh" {
t.Errorf("Reverse(hello) = %q, want %q", got, "olleh")
}
}
That's one case. Five lines for one input. Let's table-drive it:
func TestReverse(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"single", "a", "a"},
{"two_chars", "ab", "ba"},
{"english", "hello", "olleh"},
{"palindrome", "level", "level"},
{"with_space", "go go", "og og"},
{"unicode", "héllo", "olléh"},
{"emoji", "🚀x🚀", "🚀x🚀"},
{"mixed", "a1b2c3", "3c2b1a"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Reverse(tc.in)
if got != tc.want {
t.Errorf("Reverse(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
Look at what we got:
- Nine cases in ~15 lines (vs ~45 lines as nine functions).
- Each case is one line of
(name, input, expected). - Failures clearly identify the row (
TestReverse/unicode). - Adding a tenth case is one line.
Run it:
$ go test -v
=== RUN TestReverse
=== RUN TestReverse/empty
=== RUN TestReverse/single
=== RUN TestReverse/two_chars
=== RUN TestReverse/english
=== RUN TestReverse/palindrome
=== RUN TestReverse/with_space
=== RUN TestReverse/unicode
=== RUN TestReverse/emoji
=== RUN TestReverse/mixed
--- PASS: TestReverse (0.00s)
--- PASS: TestReverse/empty (0.00s)
--- PASS: TestReverse/single (0.00s)
...
Now, suppose someone reports a bug: Reverse("café") returns "éfac" correctly, but a contributor adds a "byte-level" implementation that breaks it. Add a regression case:
Run only the new case to verify:
Done. The new test is permanent.
Walking Through t.Run Mechanics¶
Let's slow down and trace exactly what happens when you call t.Run("hello", subFunc) inside TestReverse.
Step 1: A new *testing.T is created¶
The parent t (the one passed to TestReverse) is the top-level test handle. When you call t.Run, Go creates a fresh *testing.T for the subtest. Inside subFunc, the parameter t refers to this new handle, not the parent.
Step 2: A goroutine is spawned¶
The new subtest runs in its own goroutine. This is invisible to you but matters because it means the subtest can call t.Parallel (which only works in a goroutine other than the parent's).
Step 3: The parent blocks¶
Despite the goroutine, the call t.Run(...) blocks the parent until subFunc returns (or calls t.Parallel). So you can think of sequential subtests as "synchronous" from the caller's view.
Step 4: Failures propagate to the parent¶
If subFunc calls t.Errorf, the subtest is marked failed. The parent isn't automatically failed, but t.Run returns false, and the parent's overall status will reflect that a subtest failed.
Step 5: Cleanup runs¶
Anything registered with t.Cleanup inside the subtest body runs when the subtest's goroutine ends.
When You Should Skip Table-Driven (At This Level)¶
There are a few cases where a table is overkill or wrong. As a junior, you don't need to refactor your way out of every duplicated test, but recognize:
- One case. If you'll only ever have one input, just write the function. No table needed.
- Two trivial cases. Borderline. If a third case is likely, table; if not, two functions.
- Wildly different cases. If case A needs three setup files and case B needs a network mock and case C needs nothing, the table starts carrying a lot of conditional logic. Three separate functions are simpler.
A rule of thumb: when in doubt, write the table. Adding rows is cheap; merging back from separate functions is expensive.
Tips for Naming Your Table¶
I introduce one more nuance here because it's easy to overlook.
Use describe-the-case naming, not echo-the-input¶
Bad: {"hello", "hello", "olleh"} — the name is just the input, which is redundant.
Better: {"english_word", "hello", "olleh"} — the name tells you why this case exists.
Best (when the input itself is meaningful): {"5_char_english", "hello", "olleh"} — names capture the case's intent.
Group prefix¶
If you have rows that cover related behaviors, prefix them:
{"empty/string", "", ""},
{"empty/just_spaces", " ", " "},
{"alpha/lower", "abc", "cba"},
{"alpha/upper", "ABC", "CBA"},
{"alpha/mixed", "AbC", "CbA"},
The slashes create another subtest level: TestReverse/empty/string, TestReverse/alpha/lower. Now -run TestReverse/alpha runs all alpha cases. This is the simplest form of nested subtests — you'll see the full nested form (with literal t.Run inside t.Run) in the middle file.
Don't include trivia in the name¶
{"this_is_case_42_from_the_old_test_suite", ...} — too long, the history isn't useful in the name. Put history in a comment if you must:
A Bigger Example — Validating a Form¶
Real production code rarely tests pure functions like Abs or Reverse. More realistic: a form validator.
type Form struct {
Name string
Email string
Age int
}
func (f *Form) Validate() error {
if f.Name == "" {
return errors.New("name is required")
}
if len(f.Name) > 100 {
return errors.New("name too long")
}
if !strings.Contains(f.Email, "@") {
return errors.New("email is invalid")
}
if f.Age < 0 || f.Age > 150 {
return errors.New("age out of range")
}
return nil
}
Test it with a table:
func TestForm_Validate(t *testing.T) {
cases := []struct {
name string
form Form
wantErr bool
}{
{
name: "all_valid",
form: Form{Name: "Ada", Email: "ada@example.com", Age: 30},
wantErr: false,
},
{
name: "empty_name",
form: Form{Email: "ada@example.com", Age: 30},
wantErr: true,
},
{
name: "name_too_long",
form: Form{Name: strings.Repeat("a", 101), Email: "ada@example.com", Age: 30},
wantErr: true,
},
{
name: "email_no_at",
form: Form{Name: "Ada", Email: "adaexample.com", Age: 30},
wantErr: true,
},
{
name: "negative_age",
form: Form{Name: "Ada", Email: "ada@example.com", Age: -5},
wantErr: true,
},
{
name: "age_too_high",
form: Form{Name: "Ada", Email: "ada@example.com", Age: 200},
wantErr: true,
},
{
name: "boundary_age_zero",
form: Form{Name: "Ada", Email: "ada@example.com", Age: 0},
wantErr: false,
},
{
name: "boundary_age_150",
form: Form{Name: "Ada", Email: "ada@example.com", Age: 150},
wantErr: false,
},
{
name: "boundary_name_100_chars",
form: Form{Name: strings.Repeat("a", 100), Email: "ada@example.com", Age: 30},
wantErr: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.form.Validate()
if (err != nil) != tc.wantErr {
t.Errorf("Validate() error = %v, wantErr = %v", err, tc.wantErr)
}
})
}
}
Things to notice:
- Nine cases covering happy paths, edge cases, and boundaries.
- One happy path (
all_valid) plus several negative cases (each broken field). - Three boundary cases that test the edges of allowed ranges (
Age == 0,Age == 150,Name == 100 chars). These are crucial — bugs hide at boundaries. - Each case takes one line of struct literal. Adding a tenth boundary case is one more line.
This table covers the validator more thoroughly than 9 hand-written test functions ever would, because the cost of adding a case is so low that you actually do it.
Why Table-Driven Wins for Coverage¶
Coverage isn't just about line coverage (what code ran) — it's about case coverage (what scenarios you've considered). Hand-written tests fall into "wrote three, called it done" patterns because each test costs ~10 lines of scaffolding. Table-driven tests cost one line per case, which encourages thinking through:
- Happy paths.
- Empty inputs.
- Boundary values.
- Off-by-one inputs.
- Inputs that previously caused bugs.
- Inputs from real production traces.
A well-designed table can have 30 rows for a function that, by hand, you'd write three tests for. That's not over-testing — that's appropriate coverage of the input space.
Field Order Conventions¶
When designing the row struct, put fields in reading order:
- Name first — humans scan top-to-bottom, the name anchors the row.
- Inputs next — what we feed in.
- Expected outputs next — what we expect out.
- Expected errors last — error expectations come after value expectations.
// Good order
type row struct {
name string
input string
want int
wantErr bool
}
// Less idiomatic
type row struct {
wantErr bool // assertion before inputs?
name string
want int
input string
}
This is convention, not law, but it's how the stdlib writes its tables and it's what reviewers expect.
t.Fatalf vs t.Errorf Inside a Subtest¶
Both mark the test as failed. The difference:
t.Errorfcontinues executing the rest of the subtest body.t.Fatalfreturns from the subtest body immediately.
Use t.Fatalf when continuing would be useless or unsafe:
t.Run(tc.name, func(t *testing.T) {
f, err := os.Open(tc.path)
if err != nil {
t.Fatalf("open: %v", err) // can't continue without f
}
defer f.Close()
info, err := f.Stat()
if err != nil {
t.Errorf("stat: %v", err) // can still continue with f
}
if info != nil && info.Size() != tc.wantSize {
t.Errorf("size = %d, want %d", info.Size(), tc.wantSize)
}
})
Two t.Errorf calls can both fire in one subtest, producing two error lines. A t.Fatalf stops at the first failure.
A common pattern: use t.Fatalf for setup failures, t.Errorf for assertion failures.
A Quick Tour of testing.TB¶
testing.TB is an interface that both *testing.T and *testing.B satisfy. Use it when writing a helper that should work for both tests and benchmarks:
func loadFixture(tb testing.TB, name string) []byte {
tb.Helper()
b, err := os.ReadFile("testdata/" + name)
if err != nil {
tb.Fatal(err)
}
return b
}
// Now usable in both:
func TestX(t *testing.T) { data := loadFixture(t, "x.json"); ... }
func BenchmarkX(b *testing.B) { data := loadFixture(b, "x.json"); ... }
You won't write a lot of these as a junior, but you'll see them in stdlib and third-party packages.
More Failure-Message Patterns¶
Your failure messages are the documentation that fires when something breaks. Here are conventions worth memorizing.
The full "got = X, want Y" form¶
This is the gold standard. Reader gets the function call, the actual result, the expected result. No need to open the source to understand the failure.
Long-form for complex inputs¶
Multi-line is OK when single-line would wrap awkwardly. The leading newlines and indentation help structure the output.
Error-only assertions¶
When you expect an error, include the input but no need for got.
Skipping in a row¶
Skipf records why the row was skipped — visible in -v output.
Reading go test Verbose Output¶
Run go test -v on a real test file and study the output carefully. Key markers:
=== RUN TestName— top-level test starting.=== RUN TestName/sub— subtest starting.=== PAUSE TestName/sub— subtest paused viat.Parallel.=== CONT TestName/sub— paused subtest resumed.=== NAME TestName/sub— output associated with this subtest follows.file_test.go:42: message—t.Log/t.Error/t.Fataloutput (indented).--- PASS: TestName/sub (0.00s)— subtest passed.--- FAIL: TestName/sub (0.00s)— subtest failed.--- SKIP: TestName/sub (0.00s)— subtest skipped.PASS/FAIL— overall package result.
If a row prints --- FAIL, the lines above it (indented, with file:line prefix) are the failure messages — the output of your t.Errorf and t.Logf calls.
In parallel tests, PAUSE/CONT markers tell you when each subtest paused and resumed. Use these to debug ordering questions.
Tip — Using testing.Short to Tier Tests¶
testing.Short() returns true when go test -short is passed. You can mark slow rows or slow tests:
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if testing.Short() && tc.slow {
t.Skip("slow case skipped in -short mode")
}
...
})
}
This gives developers a fast feedback loop locally (go test -short) while preserving full coverage in CI (go test).
A common convention: top-level CI runs without -short; developer save-loop runs with -short.
Tip — Failing Fast vs Failing All¶
Inside a single subtest, you can fail fast (t.Fatal) or keep going (t.Errorf):
t.Errorfrecords the failure and continues. Two assertions can both fail in one subtest, producing two error lines.t.Fatalfrecords the failure and stops the subtest.
For tables, fail fast on setup and accumulate failures on assertions:
t.Run(tc.name, func(t *testing.T) {
parsed, err := Parse(tc.in)
if err != nil { t.Fatalf("parse: %v", err) } // setup; fail fast
if parsed.Name != tc.wantName { t.Errorf("name = %s, want %s", parsed.Name, tc.wantName) }
if parsed.Age != tc.wantAge { t.Errorf("age = %d, want %d", parsed.Age, tc.wantAge) }
if parsed.Email != tc.wantEmail { t.Errorf("email = %s, want %s", parsed.Email, tc.wantEmail) }
})
If the parse fails, the subtest stops — the field checks would be meaningless on a nil result. But if parse succeeds and two of three fields are wrong, both are reported in one run, saving a developer round-trip.
A Tiny Style Note — Single-Letter tc vs Spelled-Out tt¶
You'll see both:
tc is more common in modern code; tt is older and is the convention in the Go stdlib's own tests. Both are fine — pick one and use it consistently in your codebase. Some linters can enforce uniformity.
A Complete Worked Example — Email Validator¶
Let's walk one more complete example end-to-end. The function:
// IsValidEmail returns true if s is a syntactically valid email.
func IsValidEmail(s string) bool {
if s == "" { return false }
at := strings.Index(s, "@")
if at < 1 { return false } // no @, or starts with @
if at == len(s)-1 { return false } // ends with @
if strings.Count(s, "@") > 1 { return false } // multiple @
domain := s[at+1:]
if !strings.Contains(domain, ".") { return false } // no TLD
return true
}
Naive testing: three or four functions, copy-pasted.
Table-driven testing: one function with a thoughtful spread of cases.
func TestIsValidEmail(t *testing.T) {
cases := []struct {
name string
in string
want bool
}{
// Happy paths
{"simple", "a@b.co", true},
{"with_dots", "first.last@example.com", true},
{"plus_addressing", "a+tag@example.com", true},
{"subdomain", "a@mail.example.co.uk", true},
{"numbers", "user123@example.com", true},
// Negative — empty / minimal
{"empty", "", false},
{"just_at", "@", false},
{"only_local", "a", false},
{"only_domain", "@example.com", false},
{"trailing_at", "a@", false},
// Negative — structural
{"no_at", "no_at_here.example.com", false},
{"two_ats", "a@b@c.com", false},
{"no_dot_in_domain", "a@nodomain", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := IsValidEmail(tc.in)
if got != tc.want {
t.Errorf("IsValidEmail(%q) = %v, want %v", tc.in, got, tc.want)
}
})
}
}
Count the rows: 13 cases. Each case is one line. Adding a 14th — say a regression case for an internationalized email — is one more line.
This is how mature Go codebases look. Read the stdlib's net/mail package tests for a deeper example of the same shape.
Final Tip — Don't Over-Engineer¶
You'll be tempted, after reading all of this, to apply every pattern to every test. Don't.
- A function with one obvious use case doesn't need 30 rows.
- A test for "does the constructor return non-nil" doesn't need to be table-driven.
- A test that exists to satisfy coverage doesn't need three negative cases.
Tables are a tool. The right test for a piece of code is the one that catches bugs and reads clearly. Sometimes that's a 50-row table. Sometimes that's a 5-line function. Calibrate by reading other people's tests in the codebases you respect.