# Go Testing Tabledriven

> Guides Go test structure — a slice of named struct cases looped through t.Run so every case is an isolated, individually-runnable subtest; t.Helper in assertion helpers so failures point at the caller; t.Cleanup (LIFO) over defer for teardown; t.Parallel for concurrency (and the Go 1.22 loop-var change that removes the old `tc := tc` copy); t.Errorf to continue vs t.Fatalf to stop this test's goroutine; got-before-want messages that identify the input; cmp.Diff over reflect.DeepEqual; golden files under testdata/ with a -update flag; t.TempDir and TestMain. Auto-invokes when writing or editing _test.go files, table-driven tests, t.Run subtests, t.Helper, t.Cleanup, t.Parallel, golden files, or on "write tests for this" / "add a test case" requests. Routes fuzzing, benchmarks, and synctest to go-testing-advanced.

- Skill: `ctoth/go-testing-tabledriven` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-testing-tabledriven`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-testing-tabledriven/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-testing-tabledriven

---


# Go Testing — Table-Driven

> "Table driven testing is not a tool, package or anything else, it's just a way and perspective to write cleaner tests."
> — [Go Wiki — TableDrivenTests](https://go.dev/wiki/TableDrivenTests)

> "The test code is written once and amortized over all table entries, so it makes sense to write a careful test with good error messages."
> — [Go Wiki — TableDrivenTests](https://go.dev/wiki/TableDrivenTests)

A Go test grows by **adding a row, not a function**. The shape that makes that work — a slice of named cases looped through `t.Run` — is the structural floor this skill owns. It also owns the helper/cleanup/parallel mechanics and the assertion conventions (`got`/`want`, `cmp.Diff`, golden files) that keep failures legible. Fuzzing, benchmarks, `testing/synctest`, and coverage belong to **`go-testing-advanced`**.

---

## 1. The Table-Driven Shape

Define a `[]struct{ name string; in …; want … }`, then loop, running each case as a subtest with `t.Run(tc.name, …)`. "Given a table of test cases, the actual test simply iterates through all table entries and for each entry performs the necessary tests" ([TableDrivenTests](https://go.dev/wiki/TableDrivenTests)).

```go
func TestCount(t *testing.T) {
	tests := []struct {
		name string
		in   string
		want int
	}{
		{name: "empty", in: "", want: 0},
		{name: "two words", in: "hello world", want: 2},
		{name: "tabs and newlines", in: "a\tb\nc", want: 3},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			if got := Count(tc.in); got != tc.want {
				t.Errorf("Count(%q) = %d, want %d", tc.in, got, tc.want)
			}
		})
	}
}
```

The payoff is diagnostic: "When the test fails it is immediately obvious which test failed and why, even without having to read the test code" ([TableDrivenTests](https://go.dev/wiki/TableDrivenTests)).

---

## 2. Always Use `t.Run` Subtests

`t.Run` is not optional decoration. "Run runs f as a subtest of t called name. It runs f in a separate goroutine and blocks until f returns or calls t.Parallel" ([pkg.go.dev/testing](https://pkg.go.dev/testing)). Three things you lose without it:

- **Isolation.** With a bare loop and `t.Errorf`, one failing case keeps the loop going but every failure blends together. With subtests each case is a named, separately-reported unit.
- **A single-case runner.** Subtests "can be singled out on the command line using the `-run` … flag" ([Subtests](https://go.dev/blog/subtests)): `go test -run TestCount/two_words` runs exactly one row. The slash separates parent from subtest name; spaces in names become underscores.
- **Per-case `t.Parallel` and `t.Cleanup`** (§4, §5), which operate on the subtest's own `*testing.T`.

A `t.Errorf` inside the loop "is not an assertion. The test continues even after an error is logged" ([TableDrivenTests](https://go.dev/wiki/TableDrivenTests)) — good, because you want every case's verdict, not just the first failure.

---

## 3. `t.Helper` — Make Failures Point at the Caller

Factor repeated assertions into a helper and call `t.Helper()` first. "Helper marks the calling function as a test helper function. When printing file and line information, that function will be skipped" ([pkg.go.dev/testing](https://pkg.go.dev/testing)) — so the reported line is the *subtest* that called the helper, not the helper's interior.

```go
func wantFrequency(t *testing.T, in string, want map[string]int) {
	t.Helper() // failures below report the CALLER's line, not this one
	got := Frequency(in)
	if diff := cmp.Diff(want, got); diff != "" {
		t.Errorf("Frequency(%q) mismatch (-want +got):\n%s", in, diff)
	}
}
```

Without `t.Helper()`, every failure across every case points at the one `t.Errorf` line inside the helper, and you cannot tell which case failed from the output.

---

## 4. `t.Parallel` — and Why Go 1.22 Removed `tc := tc`

`t.Parallel()` "signals that this test is to be run in parallel with (and only with) other parallel tests" ([pkg.go.dev/testing](https://pkg.go.dev/testing)). Call it at the top of the subtest (and optionally in the parent) to run independent cases concurrently:

```go
for _, tc := range tests {
	t.Run(tc.name, func(t *testing.T) {
		t.Parallel()
		if got := Count(tc.in); got != tc.want {
			t.Errorf("Count(%q) = %d, want %d", tc.in, got, tc.want)
		}
	})
}
```

**The version-aware modernization:** pre-1.22, a parallel subtest closure captured the single shared loop variable, which the loop overwrote before the deferred parallel body ran — so everyone needed the `tc := tc` shadow copy. Go 1.22 fixed the root cause: "Previously, the variables declared by a 'for' loop were created once and updated by each iteration. In Go 1.22, each iteration of the loop creates new variables, to avoid accidental sharing bugs" ([Go 1.22 release notes](https://go.dev/doc/go1.22)). The new semantics apply to packages in a module whose `go.mod` declares `go 1.22` or later. **On a 1.22+ module, do not write `tc := tc` — it is dead code.** (See `go-version-feature-map`; the `copyloopvar` modernizer in `go fix` removes leftover copies — see `go-tooling-and-static-analysis`.)

A subtle ordering fact: a parent test "blocks until its test function returns and all of its subtests have completed," and parallel subtests are suspended until the parent function returns ([Subtests](https://go.dev/blog/subtests)). That is why parallel subtests only start running after the loop has finished launching them.

---

## 5. `t.Cleanup` over `defer`; `t.TempDir` for Files

`t.Cleanup(fn)` registers teardown tied to the test's lifetime: "Cleanup registers a function to be called when the test (or subtest) and all its subtests complete. Cleanup functions will be called in last added, first called order" ([pkg.go.dev/testing](https://pkg.go.dev/testing)) — LIFO, exactly like `defer`, but it survives across helper boundaries. Prefer it inside helpers: a `defer` in a helper runs when the *helper* returns, which is too early; `t.Cleanup` runs when the *test* ends.

```go
func newServer(t *testing.T) *Server {
	t.Helper()
	s := startServer()
	t.Cleanup(s.Close) // runs when the test finishes, not when newServer returns
	return s
}
```

For filesystem tests use `t.TempDir()`: it "returns a temporary directory for the test to use. The directory is automatically removed when the test and all its subtests complete" ([pkg.go.dev/testing](https://pkg.go.dev/testing)) — no manual `os.RemoveAll`, no leaked dirs, and a fresh unique dir per call.

---

## 6. `t.Errorf` (continue) vs `t.Fatalf` (stop) — and the Goroutine Rule

Pick by whether the test can meaningfully continue. `t.Errorf` "is equivalent to Logf followed by Fail" — it records the failure and keeps going. `t.Fatalf` "is equivalent to Logf followed by FailNow," and "FailNow marks the function as having failed and stops its execution by calling runtime.Goexit" ([pkg.go.dev/testing](https://pkg.go.dev/testing)). Rule of thumb:

- **`t.Fatalf`** when a precondition failed and the rest of the test would panic or test nothing — a setup error, a failed `os.ReadFile`, a nil result you are about to dereference.
- **`t.Errorf`** when you want to report this assertion and still run the others — the common case inside a table loop, where each `t.Errorf` flags one bad field without aborting the case.

**The goroutine trap:** "FailNow must be called from the goroutine running the test or benchmark function, not from other goroutines created during the test. Calling FailNow does not stop those other goroutines" ([pkg.go.dev/testing](https://pkg.go.dev/testing)). So never call `t.Fatal`/`t.Fatalf` (or anything that calls `FailNow`, including `require`-style helpers) from inside a `go func(){…}` you spawned — send the error back to the test goroutine and fail there, or use `t.Errorf` which is safe to call from any goroutine.

---

## 7. Message Conventions: got-before-want, Identify the Input

A failure message has a fixed grammar. **Actual value first, expected second:** "Test outputs should include the actual value that the function returned before printing the value that was expected" ([Google Style Guide — decisions](https://google.github.io/styleguide/go/decisions#got-before-want)). **Name the function and the input:** "failure messages should include the name of the function that failed" and "should include the function inputs if they are short" ([Google Style Guide](https://google.github.io/styleguide/go/decisions#identify-the-function)).

```go
// WRONG — want before got, no input, no function: "expected 3 but got 2" tells you nothing
t.Errorf("expected %d but got %d", tc.want, got)

// RIGHT — function, input, got, then want
t.Errorf("Count(%q) = %d, want %d", tc.in, got, tc.want)
```

In a table-driven test the subtest name already labels the case, but echoing the literal input in the message still saves a lookup.

---

## 8. `cmp.Diff` over `reflect.DeepEqual`

For anything bigger than a scalar — structs, slices, maps — compare with `cmp.Diff` from `github.com/google/go-cmp/cmp`, not `reflect.DeepEqual`. The style guide is explicit: "`reflect.DeepEqual` should not be used for checking equality, as it is sensitive to changes in unexported fields and other implementation details … Prefer using `cmp` for new code" ([Google Style Guide](https://google.github.io/styleguide/go/decisions#test-error-semantics)). `cmp.Diff` "returns a human-readable report of the differences between two values" ([pkg.go.dev/cmp](https://pkg.go.dev/github.com/google/go-cmp/cmp)), so a failure shows the exact field that differs instead of a flat `false`.

```go
if diff := cmp.Diff(want, got); diff != "" {
	t.Errorf("Frequency(%q) mismatch (-want +got):\n%s", in, diff)
}
```

Argument order is **`cmp.Diff(want, got)`** with the message labeled `(-want +got)`: the first arg is the `-` side, the second the `+` side. Two cautions: `cmp` "is intended to only be used in tests" and "may panic if it cannot compare the values"; and "unexported fields are not compared by default; they result in panics unless suppressed by using an Ignore option" — reach for `cmpopts.IgnoreUnexported`/`cmpopts.EquateApprox` from `github.com/google/go-cmp/cmp/cmpopts` ([pkg.go.dev/cmp](https://pkg.go.dev/github.com/google/go-cmp/cmp)). If you cannot add the dependency, `reflect.DeepEqual` for the boolean check is the stdlib fallback, but you lose the diff.

---

## 9. Golden Files for Large Expected Output

When the expected value is a big blob (rendered text, JSON, generated code), store it in a file under `testdata/` rather than a giant string literal. The `go` tool ignores any directory named `testdata`, so it never interferes with the build. Compare the result against the file, and gate regeneration behind a `-update` flag so refreshing the expectation is one command:

```go
var update = flag.Bool("update", false, "regenerate golden files")

func TestReportGolden(t *testing.T) {
	got := Report(input)
	golden := filepath.Join("testdata", "report.golden")
	if *update {
		if err := os.WriteFile(golden, []byte(got), 0o644); err != nil {
			t.Fatalf("writing golden file: %v", err)
		}
	}
	want, err := os.ReadFile(golden)
	if err != nil {
		t.Fatalf("reading golden (run `go test -update` to create it): %v", err)
	}
	if diff := cmp.Diff(string(want), got); diff != "" {
		t.Errorf("Report() mismatch (-want +got):\n%s", diff)
	}
}
```

`go test -update` writes the golden file; a normal `go test` compares against it. Review golden diffs in code review exactly as you would review the code that produced them — a golden file is only as trustworthy as the run that generated it.

---

## 10. Test the Public API; `TestMain` for Suite Setup

Put tests in an **external test package** — `package foo_test` in the same directory — so the test compiles against `foo`'s exported surface only. This proves the public API is usable as shipped and stops tests from depending on internals that may change. Drop to internal `package foo` tests only for the genuinely unexported logic that the public API cannot reach.

For one-time setup/teardown around the whole package (a shared database, a global fixture), define `TestMain`: it runs `m.Run()` and passes the result to `os.Exit`. "TestMain is a low-level primitive and should not be necessary for casual testing needs, where ordinary test functions suffice" ([pkg.go.dev/testing](https://pkg.go.dev/testing)) — reach for it only when per-test `t.Cleanup`/helpers genuinely cannot express the setup. Gate slow suites with `if testing.Short()` and run `go test -short` for the fast path.

---

## 11. Routing to Related Skills

- `go-idiomatic-discipline` — the policy root; "clear AND correct" applies to test code too.
- `go-testing-advanced` — fuzzing, benchmarks (`b.Loop`), `testing/synctest`, coverage, and the stdlib-vs-testify stance. This skill owns *structure*; that one owns those techniques.
- `go-error-handling` — testing error paths: assert with `errors.Is(err, ErrSentinel)` / `errors.As`, never by string-matching the message.
- `go-version-feature-map` — the Go 1.22 loop-var change that removes `tc := tc`, and which idiom your `go` directive allows.
- `go-tooling-and-static-analysis` — `go test -race` in CI, and the `go fix` modernizer that strips leftover `tc := tc` copies.

---

## 12. Reference Files

High-frequency table-driven testing anti-patterns in LLM-generated Go, each with wrong/right code and citations:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

