Lesson 02 / 16
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%.
Published
In this lesson
- 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
Contents
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.
// 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
}
// 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.
// 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
casesslice were read without calling any tool. t.Runsplits 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?
To keep your progress and take notes, Log in
My notes
Log in to take notes.