skip to content

In Go, what is a table-driven test, and what does running each case through t.Run give you?

level: juniorimportance: must knowfreq 78%

answer

  1. cases become data, not code
  2. one loop body, one assertion
  3. every row needs a name
  4. t.Run makes a row its own test
  5. named rows can be filtered and fail alone

basics

~20 s

A table-driven test declares a slice of case structs (name, input, want) and loops over them, calling t.Run(tc.name, ...) so every row becomes a named subtest that fails and can be re-run on its own.

solid answer

~50 s

The cases become data instead of code: a slice of anonymous structs, one field for the case name, one or more for the inputs, one for the expected result. The loop body is written once and holds the only assertion. Wrapping the body in `t.Run(tc.name, func(t *testing.T) { ... })` turns each row into a subtest, which buys three things. Failures are reported as `TestParseExpr/precedence` rather than a bare line number, so the output names the case. Each subtest gets its own `*testing.T`, so a fatal failure in one row ends that row only and the remaining rows still run. And the name becomes addressable, so `go test -run 'TestParseExpr/precedence'` re-runs the single case you are debugging. Adding a case is then adding one line to the table, which is exactly what makes the pattern the Go default.

code

go · 22 lines
go
func TestParseExpr(t *testing.T) {
	cases := []struct {
		name string
		in string
		want int
	}{
		{name: "single number", in: "7", want: 7},
		{name: "addition", in: "1+2", want: 3},
		{name: "precedence", in: "1+2*3", want: 7},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got, err := ParseExpr(tc.in)
			if err != nil {
				t.Fatalf("ParseExpr(%q) error: %v", tc.in, err)
			}
			if got != tc.want {
				t.Errorf("ParseExpr(%q) = %d, want %d", tc.in, got, tc.want)
			}
		})
	}
}

go deeper

for a junior

Be ready to write one from memory on a whiteboard: the anonymous struct with name, in and want, the range loop, and t.Run(tc.name, func(t *testing.T){...}) around the assertion.

for a middle

Explain what t.Run buys over a plain loop - a named subtest, its own *testing.T so one fatal row does not end the others, and a name that go test -run can select.

for a senior

Show judgment about the table's shape: keyed literals, names that describe behaviour, the input echoed in the failure message, and a loop body with no per-case branching.

for a principal

Talk about the table as documentation other engineers extend: the cost of adding a case should be one line, and that property is what keeps coverage growing after you leave the codebase.

## What the pattern is A table-driven test separates the *cases* from the *checking*. Instead of writing one `Test` function per input, or one long function with ten copy-pasted blocks, you declare a table of cases and write the assertion once: ```go func TestParseExpr(t *testing.T) { cases := []struct { name string in string want int }{ {name: "single number", in: "7", want: 7}, {name: "addition", in: "1+2", want: 3}, {name: "precedence", in: "1+2*3", want: 7}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got, err := ParseExpr(tc.in) if err != nil { t.Fatalf("ParseExpr(%q) error: %v", tc.in, err) } if got != tc.want { t.Errorf("ParseExpr(%q) = %d, want %d", tc.in, got, tc.want) } }) } } ``` The struct type is usually anonymous and declared inline, because it exists only for this test. The conventional field names are `name`, then the inputs, then `want` (and often `wantErr`). Nothing about this needs a framework: it is a slice literal, a `for ... range`, and the standard library's `testing` package. ## What t.Run actually does `t.Run(name string, f func(t *testing.T)) bool` starts `f` **in a new goroutine with a fresh `*testing.T`** and blocks until it returns, then reports whether it succeeded. Three consequences matter: **1. The case gets a name in the output.** The subtest's full name is the parent's name, a slash, and the sanitised case name — `TestParseExpr/precedence`. With `-v` you see a `=== RUN` and a `--- PASS`/`--- FAIL` line per row. Without `t.Run`, a failure tells you the line number of the shared assertion, which is the same line for all fifty rows; you then have to print the input yourself to know which row broke. **2. Failures are isolated.** `t.Fatal`/`t.Fatalf` stop the goroutine they are called on. Inside a subtest, that ends *that subtest*; the parent's loop moves on to the next row and you learn about every failing case in one run. Call `t.Fatalf` directly in the loop body with no `t.Run` and the first bad row ends the whole test function, hiding the other nine failures behind it. **3. The case is addressable.** Because the subtest has a name, `go test -run 'TestParseExpr/precedence'` runs exactly that row — the fast inner loop when you are fixing one behaviour. ## Writing the table well - **Always include a `name` field**, and make it describe the behaviour (`"empty input"`, `"multibyte rune in identifier"`), not the data (`"case 3"`). The name is what appears in CI output and what you will type after `-run`. - **Keep the loop body one straight path.** The body is the specification of what the function should do; every `if tc.something` branch in it means the table is describing two different tests. - **Put the input in the failure message**, not only the name: `t.Errorf("ParseExpr(%q) = %d, want %d", tc.in, got, tc.want)` reads as a sentence and reproduces the call. - **Use keyed struct literals** (`{name: ..., in: ...}`) rather than positional ones. Keyed rows survive adding a field to the struct and are readable when a row has six columns. - **Slice, not map.** `[]struct{...}` keeps the rows in source order, which is the order a reader scans them in. A `map[string]struct{...}` keyed by case name is also seen in the wild — it enforces unique names and drops the `name` field, at the cost of iterating in a randomised order. ## Why Go leans on this so hard Go has no parameterised-test annotation and no assertion library in the standard toolchain, so the language's own features carry the load: a composite literal for the data, `range` for the iteration, and `t.Run` for the naming and isolation. The payoff is that the cost of a new case is one line of data, which is precisely the property you want when a reviewer says "add a row for the empty string". The pattern also documents the function under test: a reader who scans the table sees the accepted inputs and the expected outputs side by side, which is often clearer than the implementation. The common beginner mistakes are leaving out `t.Run` (losing names, isolation and filtering), leaving out the `name` field (so subtests end up called `#00`, `#01`), and drifting the assertion into the table itself — a `want func(t *testing.T, got int)` column — which reintroduces the per-case code the table was supposed to remove.

  • What do you lose if you loop over the table but skip t.Run?
    Three things: the per-case name in the output, so a failure points at the shared assertion line for all rows; the isolation, so the first t.Fatal ends the whole test and hides later failures; and the ability to re-run one case with go test -run, since there is no subtest name to match.
  • Why a slice of structs rather than a map keyed by case name?
    A slice preserves source order, so the rows run and read in the order they are written, and it allows a row to be added anywhere. A map keyed by the case name enforces unique names and removes the name field, but iterates in a randomised order, so the output ordering shifts between runs.
  • How do you express a case that is expected to fail?
    Add a field such as wantErr bool or wantErr string to the case struct and branch on it once in the loop body: if the case wants an error, assert an error came back and return; otherwise assert no error and compare the value. One branch that every row sets deliberately is fine, unlike a flag most rows ignore.

The table is a spreadsheet of inputs and expected outputs; the loop body is the single formula applied to every row.

saying these in an interview costs you the question

  • Thinks each case needs its own Test function
  • Believes a failing row stops the remaining rows when t.Run is used
  • Omits the name field, so subtests are called #00 and #01
  • Puts per-case assertion code in the table instead of data
  • Calls t.Fatalf in the loop body outside t.Run and hides later failures