---
title: 'Table-Driven Tests'
source: 'https://academia.sh/en/courses/go-production/table-driven-tests'
course: 'Production Go: Testing, Tooling and Performance'
language: en
updated: '2026-09-14T10:59:33+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Counts the case count in a table-driven test file from the source, reads the running subtest count from the run, and compares the two'
    - 'Shows, by running it, how naming and reporting of subtests opened with t.Run works'
    - 'Measures that adding a case does not always raise the coverage ratio, using two cases that run the same line'
---

# Table-Driven Tests

A five-case table's case count is counted from the source, and the number of running subtests is read only by running go test -v — the two match at 5. The limiting measurement shows that adding a sixth case that runs the same lines as an existing one leaves the coverage ratio unchanged at 100.0%.

The previous lesson measured that a test's verdict comes not from the source but from
the run; how many test functions were written could be counted from the source, but
which ones passed could only be learned by running `go test`. This lesson carries the
same distinction one level deeper: into how multiple **cases** are tested inside a
single test function. This time the case list itself is written in the source — one of
the topic's two "source" lines — but how many separate subtests that list produces when
run can still only be read from the run.

This pattern is called a table-driven test, and it is not specific to Go; the same
pattern appears in other languages' test libraries under the name parametrized test —
the taxonomy is not retold here. What this lesson adds is a narrow question: how does
the test package's own `t.Run` routine split a table into subtests, and is the
relationship between the case count and the running subtest count really one-to-one?
Answering this requires setting information that can be counted from the source next to
information read from the run, in the same measurement.

Testing a single function with dozens of inputs is a common need in production
codebases, and meeting that need by writing a separate `func Test...` for every input
produces a file that is both repetitive and hard to read. The table-driven form reduces
this repetition to a body written once; the real question here is whether the case
count standing behind that single body can be read from the source, or only from the
run.

## The Case List Is Written in the Source

One more function is added to the `inventory` package in this lesson: `Valid` checks in
turn that an item's name is not empty and that its quantity and price are positive, and
returns `false` at the first failed check. The table testing this function is written
as a slice variable where each row corresponds to one case.

`Valid`'s three checks are all written the same way: a condition, an early return. This
shape makes designing the table easier, because writing a separate case for each check
is enough — you do not need to look at the function's body to see which check a case
triggers; the case's own name (`"empty name"`, `"zero quantity"`, `"negative price"`,
`"negative quantity"`) already says which check it targets. The case name itself is also
source information: whoever writes it into the table states, while writing it, which
branch is being tested.

**TM4.** The `cases` slice in the test file below contains five rows; this number can be
read without running anything, just by counting the lines starting with `{"` in the
source — whoever wrote the table also controls, in the source itself, how many cases
were added.

The `tools.Setup` and `tools.Run` routines from the previous lesson are used here exactly
the same way; their definitions have not changed.

```go
// tools/tools.go — shared routines that call the language's own command family in a temporary module from inside a program
package tools

import (
	"os"
	"os/exec"
	"path/filepath"
)

func Setup(files map[string]string) string {
	dir, _ := os.MkdirTemp("", "trial")
	os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
	for name, content := range files {
		os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)
	}
	return dir
}

func Run(dir string, extraEnv []string, args ...string) (string, bool) {
	cmd := exec.Command("go", args...)
	cmd.Dir = dir
	cmd.Env = append(append(os.Environ(), "GOTOOLCHAIN=local"), extraEnv...)
	output, err := cmd.CombinedOutput()
	return string(output), err == nil
}
```

```go
// measurement.go — case count is counted from the source, running subtest count is read from the run
package main

import (
	"fmt"
	"strings"

	"lesson/tools"
)

const inventoryText = `package trial

type Item struct {
	Name     string
	Quantity int
	Price    int
}

func Valid(it Item) bool {
	if it.Name == "" {
		return false
	}
	if it.Quantity <= 0 {
		return false
	}
	if it.Price <= 0 {
		return false
	}
	return true
}
`

const testTextFive = `package trial

import "testing"

var cases = []struct {
	name     string
	item     Item
	expected bool
}{
	{"valid", Item{"pen", 3, 250}, true},
	{"empty name", Item{"", 3, 250}, false},
	{"zero quantity", Item{"pen", 0, 250}, false},
	{"negative price", Item{"pen", 3, -10}, false},
	{"negative quantity", Item{"pen", -1, 250}, false},
}

func TestValid(t *testing.T) {
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := Valid(c.item); got != c.expected {
				t.Errorf("Valid(%v) = %v, expected %v", c.item, got, c.expected)
			}
		})
	}
}
`

func caseCount(source string) int {
	return strings.Count(source, "\n\t{\"")
}

func main() {
	fmt.Println("-- source: case count --")
	fmt.Println("in the five-case table:", caseCount(testTextFive))

	fmt.Println()
	fmt.Println("-- run: running subtest count --")
	dir := tools.Setup(map[string]string{"inventory.go": inventoryText, "inventory_test.go": testTextFive})
	output, _ := tools.Run(dir, nil, "test", "-v", "./...")
	fmt.Println("running subtest count:", strings.Count(output, "--- PASS: TestValid/"))
}
```

```
-- source: case count --
in the five-case table: 5

-- run: running subtest count --
running subtest count: 5
```

**TM5.** The case count read from the source and the running subtest count read from
the run match in this measurement: 5. This match is not a coincidence, it is the
consequence of `t.Run`'s own rule — as the loop walks the `cases` slice, each turn's
`t.Run(c.name, ...)` call opens a subtest, and the test package reports this subtest
with the name it was given (`c.name`): lines such as `TestValid/valid`,
`TestValid/empty_name` appear in the output. The `strings.Count` call counts how many of
these lines end in `PASS`, and as long as the table is written correctly this number
always comes out equal to the length of `cases` — the link between the case count and
the subtest count is built into the loop itself.

This match shows that a single `TestValid` function actually runs five separate checks,
even though it does not appear in the source as five separate functions. In the previous
lesson, what was counted from the source was `func Test` occurrences, giving the test
function count; here what is counted is the data rows inside the same function, giving
the subtest count. Both are information that can be read from the source, but at two
separate levels: how many test functions a file has, and how many subtests a single
test function opens.

Subtest names also go through a small transformation of their own: space characters
inside `c.name` are converted to underscores by the test package, so the `"empty name"`
case appears in the output as `TestValid/empty_name`. This transformation does not
affect the measurement because `strings.Count` only looks for the `--- PASS: TestValid/`
prefix, not the full name — but anyone who reads a case name from the source and wants
to search for the exact same string in the output needs to know this transformation,
because `"empty name"` in the source and `empty_name` at run time point to the same
case without being the same byte sequence.

## The Limiting Measurement: Adding a Case May Not Raise Coverage

Adding a case to the table intuitively looks like "testing more." The measurement below
shows this is not always true: a sixth case that reruns a line `Valid` already tests is
added to the five-case table — the name is still empty, but the quantity and price
differ — and the coverage ratio is measured for both tables.

```go
// coverage.go — a new case that runs the same line does not change the coverage ratio
package main

import (
	"fmt"
	"strings"

	"lesson/tools"
)

const inventoryText = `package trial

type Item struct {
	Name     string
	Quantity int
	Price    int
}

func Valid(it Item) bool {
	if it.Name == "" {
		return false
	}
	if it.Quantity <= 0 {
		return false
	}
	if it.Price <= 0 {
		return false
	}
	return true
}
`

const testTextFive = `package trial

import "testing"

var cases = []struct {
	name     string
	item     Item
	expected bool
}{
	{"valid", Item{"pen", 3, 250}, true},
	{"empty name", Item{"", 3, 250}, false},
	{"zero quantity", Item{"pen", 0, 250}, false},
	{"negative price", Item{"pen", 3, -10}, false},
	{"negative quantity", Item{"pen", -1, 250}, false},
}

func TestValid(t *testing.T) {
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := Valid(c.item); got != c.expected {
				t.Errorf("Valid(%v) = %v, expected %v", c.item, got, c.expected)
			}
		})
	}
}
`

const testTextSix = `package trial

import "testing"

var cases = []struct {
	name     string
	item     Item
	expected bool
}{
	{"valid", Item{"pen", 3, 250}, true},
	{"empty name", Item{"", 3, 250}, false},
	{"zero quantity", Item{"pen", 0, 250}, false},
	{"negative price", Item{"pen", 3, -10}, false},
	{"negative quantity", Item{"pen", -1, 250}, false},
	{"empty name, different amount", Item{"", 9, 500}, false},
}

func TestValid(t *testing.T) {
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := Valid(c.item); got != c.expected {
				t.Errorf("Valid(%v) = %v, expected %v", c.item, got, c.expected)
			}
		})
	}
}
`

func caseCount(source string) int {
	return strings.Count(source, "\n\t{\"")
}

func coverageRatio(output string) string {
	for _, s := range strings.Split(output, "\n") {
		if i := strings.Index(s, "coverage: "); i >= 0 {
			return strings.TrimSpace(s[i+len("coverage: "):])
		}
	}
	return "none"
}

func main() {
	dirFive := tools.Setup(map[string]string{"inventory.go": inventoryText, "inventory_test.go": testTextFive})
	outputFive, _ := tools.Run(dirFive, nil, "test", "-cover", "./...")
	fmt.Println("case count in the five-case table:", caseCount(testTextFive))
	fmt.Println("coverage ratio in the five-case table:", coverageRatio(outputFive))

	dirSix := tools.Setup(map[string]string{"inventory.go": inventoryText, "inventory_test.go": testTextSix})
	outputSix, _ := tools.Run(dirSix, nil, "test", "-cover", "./...")
	fmt.Println("case count in the six-case table:", caseCount(testTextSix))
	fmt.Println("coverage ratio in the six-case table:", coverageRatio(outputSix))
}
```

```
case count in the five-case table: 5
coverage ratio in the five-case table: 100.0% of statements
case count in the six-case table: 6
coverage ratio in the six-case table: 100.0% of statements
```

**TM6.** The case count rises from 5 to 6, but the coverage ratio is the same in both:
`100.0% of statements`. The reason is that the coverage tool measures at the **line**
level: the sixth case reruns `Valid`'s first `if it.Name == "" { return false }` line —
the second case (`"empty name"`) had already run this line once, and in the tool's eyes
it does not matter whether a line runs twice or once, only whether it ran at all. Even
though the new case carries a different `Quantity` and `Price`, `Valid` returns at the
first check without ever looking at these values; the coverage tool cannot see this
difference because what it measures is which **lines** ran, not which **values** were
tried.

In the five-case table, which case runs which line can be traced one by one: `"valid"`
passes all three checks and reaches the final `return true` line, `"empty name"` stops
at the first `return false` line, `"zero quantity"` at the second `return false`,
`"negative price"` reaches and stops at the third `return false`, and
`"negative quantity"` stops again at the second `return false` — the same line as
`"zero quantity"`, despite being a different case. This also clarifies what the sixth
case in the six-case table repeats: the new case `"empty name, different amount"`
shares its first line with `"empty name"`, just as `"negative quantity"` shares its
second line with `"zero quantity"` — in the coverage tool's eyes this overlap makes no
difference at all, it does not count as a second touch.

This is a limit specific to this lesson within the topic's measurement axis: the
coverage tool produces run information (it cannot be known from the source, only
learned by running), but the information it produces is itself coarse — it does not
distinguish whether a line ran once or ten times, or with which inputs it ran. The
five-case table already runs each of `Valid`'s four early returns and its final `true`
return exactly once, so coverage is already at its highest point; the sixth case cannot
climb past this point, because no new line is left to run. The topic's closing lesson
widens this limit and re-measures coverage with a completely different set of cases.

This measurement's production-side counterpart is concrete: during a review, the phrase
"coverage ratio rose to a hundred percent" does not mean that the case most recently
added to the table actually tests a new behavior. A case that reruns the same line with
a different input grows the table without leaving any trace in the coverage report — the
file gets longer, the run time increases, but the number the tool returns stays the
same. This is why coverage ratio is not on its own a sufficient measure for deciding
whether a case is **valuable**; seeing which line has still never run requires looking
at the detailed, line-by-line dump the coverage tool produces — the topic's closing
lesson reads exactly this detail.

This coarseness of the coverage tool is not a flaw but a natural consequence of what it
measures. The tool tracks which lines of the source text have a counterpart in the
compiled binary and how many times each was triggered during a run; tracking **which
value** triggered a line would take the data the tool keeps to an entirely different
level. In a short, lightly branching function like `Valid`, this coarseness makes it easy
to reach the highest ratio with as few as five cases; a more heavily branching function
might need far more cases for the same ratio, but the tool itself asks the same question
every time: did this line run at least once.

## Summary

- The case count in a table-driven test file can be counted from the source; in this
  lesson the five rows in the `cases` slice were read without calling any tool.
- `t.Run` splits a table into subtests and reports each subtest with the given name; in
  this lesson the running subtest count matched the case count one-to-one.
- Case count comes from the source, but running subtest count can only be learned by
  running `go test -v` — even though the two land on the same number here, they arrive
  by different paths.
- The limiting measurement: adding a case that reruns the same line left the coverage
  ratio unchanged; the coverage tool counts not how many times or with which value a
  line ran, but only whether it ran at all.
- The fake dependency family and coverage measurement were built in the Unit Testing
  and Test-Driven Development course; they are not retold here, only measured through
  Go's own machinery.

## Next Step

This lesson measured how a table's case count matches the running subtest count, and
that adding a case does not always raise coverage. Both measurements relied on the same
`tools.Setup` and `tools.Run` routines; these routines continue unchanged through the
rest of the topic too — except for the HTTP lesson, which calls the handler directly.
The next lesson carries the same question into the network layer: a server's response to
a request is also information that can only be learned by running it, but this time
without ever opening a real port — how can server-side and client-side test helpers be
set up inside the same process, with the network never engaging at all?
