Skip to content

A table case is a subtest with t.Run

Level: 201 · anyone whose table-driven test reports every case under one name

One line: t.Run(name, func(t *testing.T)) turns each case of a table into a named subtest: go test -v reports TestParseQuantity/empty on its own line, -run 'TestParseQuantity/empty' runs it on its own, a failing case does not stop its siblings, and t.Fatal ends only the subtest it is called in.

ParseQuantity reads "12 crates" into a number and a unit. Its test is a slice of cases — a name, an input, and what to expect — and a loop that hands each case to t.Run. The driver writes the package and the test into a throwaway module and runs go test with -v, then with -run, then with -list. Durations are the only thing it removes.

Verified output of subtests_and_table_tests_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go mod init example
$ go test -v
=== RUN   TestParseQuantity
=== RUN   TestParseQuantity/crates
=== RUN   TestParseQuantity/negative
=== RUN   TestParseQuantity/empty
=== RUN   TestParseQuantity/three_fields
=== RUN   TestParseQuantity/not_a_number
--- PASS: TestParseQuantity
    --- PASS: TestParseQuantity/crates
    --- PASS: TestParseQuantity/negative
    --- PASS: TestParseQuantity/empty
    --- PASS: TestParseQuantity/three_fields
    --- PASS: TestParseQuantity/not_a_number
PASS
ok      example
exit status 0
$ go test -v -run 'TestParseQuantity/empty'
=== RUN   TestParseQuantity
=== RUN   TestParseQuantity/empty
--- PASS: TestParseQuantity
    --- PASS: TestParseQuantity/empty
PASS
ok      example
exit status 0
$ go test -v -run '/not'
=== RUN   TestParseQuantity
=== RUN   TestParseQuantity/not_a_number
--- PASS: TestParseQuantity
    --- PASS: TestParseQuantity/not_a_number
PASS
ok      example
exit status 0
$ go test -list .
TestParseQuantity
ok      example
exit status 0

Reading the output

Every case has a name. === RUN TestParseQuantity/three_fields: a subtest's name is its parent's name, a slash, and the name given to Run, with spaces replaced by underscores (testing, "Subtests and Sub-benchmarks" ↗). The --- PASS lines are indented under their parent's, and the parent's own verdict comes first: a parent passes only when every subtest has.

-run is one pattern per level. go help testflag splits the -run expression at each unbracketed / and matches each part against the corresponding level of the name (Testing flags ↗). -run 'TestParseQuantity/empty' runs the parent — it has to, to reach the subtest — and, of its cases, only empty. In -run '/not' the first element is empty, which matches every top-level test, and the second selects the case whose name contains not. Patterns are unanchored: '/empty' would also match a case called not_empty, so anchor when it matters — -run 'TestParseQuantity/^empty$'.

-list names only the top-level tests. Subtests come into being when the parent runs, so go test -list . cannot enumerate them. To see which cases exist, run the parent with -v.

The loop variable needs no copy. Each t.Run closure reads c from its own iteration, because since Go 1.22 a for loop declares a fresh variable per iteration (Go 1.22 release notes ↗). Code from before that carries a c := c line at the top of the loop body, and it was only ever needed when the subtests were parallel — Parallel subtests wait, Cleanup runs last.

One wrong case does not stop the others

The same table with a typo in it — the pallets case expects the unit "pallet" — and two more tests that fail twice each, one with t.Error and one with t.Fatal:

Verified output of subtests_one_failing_case_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go mod init example
$ go test -v -run TestParseQuantity
=== RUN   TestParseQuantity
=== RUN   TestParseQuantity/crates
=== RUN   TestParseQuantity/pallets
    quantity_test.go:24: ParseQuantity("7 pallets") = 7, "pallets"; want 7, "pallet"
=== RUN   TestParseQuantity/empty
--- FAIL: TestParseQuantity
    --- PASS: TestParseQuantity/crates
    --- FAIL: TestParseQuantity/pallets
    --- PASS: TestParseQuantity/empty
FAIL
exit status 1
FAIL    example
exit status 1
$ go test -v -run 'TestErrorCarriesOn|TestFatalStops'
=== RUN   TestErrorCarriesOn
    quantity_test.go:35: n = 12, want 13
    quantity_test.go:38: unit = "crates", want "crate"
--- FAIL: TestErrorCarriesOn
=== RUN   TestFatalStops
    quantity_test.go:45: n = 12, want 13
--- FAIL: TestFatalStops
FAIL
exit status 1
FAIL    example
exit status 1

The siblings still ran. --- FAIL: TestParseQuantity/pallets is followed by --- PASS: TestParseQuantity/empty, which came after it in the table. A subtest runs in a goroutine of its own, and t.Run returns when it is done, whatever the verdict (T.Run ↗); the parent's loop goes on to the next case. The parent is marked FAIL because one of its subtests is, and go test exits 1.

The message sits under the case that produced it, with the file and line. quantity_test.go:24: ParseQuantity("7 pallets") = 7, "pallets"; want 7, "pallet" is enough to find the case without reading the loop. Without -v, go test prints only the failing tests' output, so the messages are all a reader gets.

t.Error carries on; t.Fatal stops. TestErrorCarriesOn reports both wrong values; TestFatalStops reports the first and never checks the unit. Fatal is Log followed by FailNow, and FailNow ends the goroutine it is called from with runtime.Goexit (T.FailNow ↗). Inside a subtest that goroutine is the subtest's, which is why the pallets case's t.Fatalf on the error verdict would have stopped that case alone. FailNow has to be called from the goroutine running the test — a t.Fatal in a goroutine the test started is what the vet page is for.

subtests_and_table_tests_sh.sh and subtests_one_failing_case_sh.sh

subtests_and_table_tests_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# A table of cases, each run as a subtest with t.Run. go test -v names every
# subtest as Parent/case, and -run 'TestParseQuantity/empty' runs one of
# them. The durations go test prints -- "(0.00s)" after a name and the time
# after "ok  example" -- vary, so this script strips them.
set -u

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
export GOTOOLCHAIN=local

cat >quantity.go <<'GO'
// Package stock parses the quantities on a delivery note.
package stock

import (
    "errors"
    "strconv"
    "strings"
)

// ParseQuantity reads "12 crates" into its number and its unit.
func ParseQuantity(s string) (int, string, error) {
    fields := strings.Fields(s)
    if len(fields) != 2 {
        return 0, "", errors.New("want a number and a unit")
    }
    n, err := strconv.Atoi(fields[0])
    if err != nil {
        return 0, "", err
    }
    return n, fields[1], nil
}
GO

cat >quantity_test.go <<'GO'
package stock

import "testing"

func TestParseQuantity(t *testing.T) {
    cases := []struct {
        name    string
        in      string
        n       int
        unit    string
        wantErr bool
    }{
        {"crates", "12 crates", 12, "crates", false},
        {"negative", "-3 pallets", -3, "pallets", false},
        {"empty", "", 0, "", true},
        {"three fields", "1 2 3", 0, "", true},
        {"not a number", "many crates", 0, "", true},
    }
    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            n, unit, err := ParseQuantity(c.in)
            if (err != nil) != c.wantErr {
                t.Fatalf("ParseQuantity(%q): err = %v, want an error: %t", c.in, err, c.wantErr)
            }
            if n != c.n || unit != c.unit {
                t.Errorf("ParseQuantity(%q) = %d, %q; want %d, %q", c.in, n, unit, c.n, c.unit)
            }
        })
    }
}
GO

say() { printf '$ %s\n' "$*"; }

# go test's output without what varies: "(0.00s)" after a test's name and
# the time after "ok  example".
tidy() {
    sed -E 's/ \([0-9.]+s\)$//; s/^(ok|FAIL)([[:space:]]+example)[[:space:]]+[0-9.]+s$/\1\2/' test.txt
}

say 'go mod init example'
go mod init example 2>/dev/null || exit 1

say 'go test -v'
go test -v >test.txt 2>&1
status=$?
tidy
echo "exit status $status"

say "go test -v -run 'TestParseQuantity/empty'"
go test -v -run 'TestParseQuantity/empty' >test.txt 2>&1
status=$?
tidy
echo "exit status $status"

say "go test -v -run '/not'"
go test -v -run '/not' >test.txt 2>&1
status=$?
tidy
echo "exit status $status"

say 'go test -list .'
go test -list . >test.txt 2>&1
status=$?
tidy
echo "exit status $status"

subtests_one_failing_case_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# One case in the table is wrong. Its subtest fails, the other subtests still
# run and report, and go test exits 1. A second test file shows the
# difference between t.Error, which records the failure and carries on, and
# t.Fatal, which records it and ends that subtest at once.
set -u

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
export GOTOOLCHAIN=local

cat >quantity.go <<'GO'
package stock

import (
    "errors"
    "strconv"
    "strings"
)

// ParseQuantity reads "12 crates" into its number and its unit.
func ParseQuantity(s string) (int, string, error) {
    fields := strings.Fields(s)
    if len(fields) != 2 {
        return 0, "", errors.New("want a number and a unit")
    }
    n, err := strconv.Atoi(fields[0])
    if err != nil {
        return 0, "", err
    }
    return n, fields[1], nil
}
GO

cat >quantity_test.go <<'GO'
package stock

import "testing"

func TestParseQuantity(t *testing.T) {
    cases := []struct {
        name    string
        in      string
        n       int
        unit    string
        wantErr bool
    }{
        {"crates", "12 crates", 12, "crates", false},
        {"pallets", "7 pallets", 7, "pallet", false}, // the table is wrong: the unit is "pallets"
        {"empty", "", 0, "", true},
    }
    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            n, unit, err := ParseQuantity(c.in)
            if (err != nil) != c.wantErr {
                t.Fatalf("ParseQuantity(%q): err = %v, want an error: %t", c.in, err, c.wantErr)
            }
            if n != c.n || unit != c.unit {
                t.Errorf("ParseQuantity(%q) = %d, %q; want %d, %q", c.in, n, unit, c.n, c.unit)
            }
        })
    }
}

// Two checks fail in each of these. t.Error reports both; t.Fatal reports
// the first and stops.
func TestErrorCarriesOn(t *testing.T) {
    n, unit, _ := ParseQuantity("12 crates")
    if n != 13 {
        t.Errorf("n = %d, want 13", n)
    }
    if unit != "crate" {
        t.Errorf("unit = %q, want %q", unit, "crate")
    }
}

func TestFatalStops(t *testing.T) {
    n, unit, _ := ParseQuantity("12 crates")
    if n != 13 {
        t.Fatalf("n = %d, want 13", n)
    }
    if unit != "crate" {
        t.Fatalf("unit = %q, want %q", unit, "crate")
    }
}
GO

say() { printf '$ %s\n' "$*"; }

tidy() {
    sed -E 's/ \([0-9.]+s\)$//; s/^(ok|FAIL)([[:space:]]+example)[[:space:]]+[0-9.]+s$/\1\2/' test.txt
}

say 'go mod init example'
go mod init example 2>/dev/null || exit 1

say 'go test -v -run TestParseQuantity'
go test -v -run TestParseQuantity >test.txt 2>&1
status=$?
tidy
echo "exit status $status"

say "go test -v -run 'TestErrorCarriesOn|TestFatalStops'"
go test -v -run 'TestErrorCarriesOn|TestFatalStops' >test.txt 2>&1
status=$?
tidy
echo "exit status $status"

What to do

  • Give every case a name that reads as a sentence in the --- FAIL line — empty, three fields, not a number — and use the name, not the input, to say what the case is about.
  • t.Fatalf for a verdict the rest of the checks depend on, t.Errorf for everything else. A case that got an unexpected error has nothing to compare; a case whose number is wrong may still have a right unit worth reporting.
  • Rerun one case with -run 'TestName/^case$' while fixing it, and the whole table before committing. The -v transcript shows which cases ran.
  • Keep the table flat: a slice of structs, one row per case, expected values next to the input. When a case needs set-up of its own, that is a sign it wants its own t.Run block rather than a column.

Practice

Give the error cases of the table above the error text they expect — "want a number and a unit" for the empty string and for three fields, and strconv.Atoi's own message for "many crates" — and check it with t.Fatalf, so that a wrong error never goes on to compare numbers. Name the cases so that -run '/err' runs exactly those three, and run them.

Solution

subtests_and_table_tests_kata_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// Kata: a table whose error cases carry the error text they expect, one
// subtest per case, t.Fatalf for the error verdict, and a -run pattern that
// selects the error cases by name. The program writes the module to a
// temporary directory, runs go test there, and prints the transcript with
// the durations stripped.
//
//  go build subtests_and_table_tests_kata_go.go && ./subtests_and_table_tests_kata_go
package main

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

const source = `package stock

import (
    "errors"
    "strconv"
    "strings"
)

func ParseQuantity(s string) (int, string, error) {
    fields := strings.Fields(s)
    if len(fields) != 2 {
        return 0, "", errors.New("want a number and a unit")
    }
    n, err := strconv.Atoi(fields[0])
    if err != nil {
        return 0, "", err
    }
    return n, fields[1], nil
}
`

const test = `package stock

import "testing"

func TestParseQuantity(t *testing.T) {
    cases := []struct {
        name, in string
        n        int
        unit     string
        errText  string // "" when no error is expected
    }{
        {"crates", "12 crates", 12, "crates", ""},
        {"err empty", "", 0, "", "want a number and a unit"},
        {"err three fields", "1 2 3", 0, "", "want a number and a unit"},
        {"err not a number", "many crates", 0, "", "strconv.Atoi: parsing \"many\": invalid syntax"},
    }
    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            n, unit, err := ParseQuantity(c.in)
            got := ""
            if err != nil {
                got = err.Error()
            }
            if got != c.errText {
                t.Fatalf("ParseQuantity(%q): error %q, want %q", c.in, got, c.errText)
            }
            if n != c.n || unit != c.unit {
                t.Errorf("ParseQuantity(%q) = %d, %q; want %d, %q", c.in, n, unit, c.n, c.unit)
            }
        })
    }
}
`

var durations = regexp.MustCompile(`(?m)( \([0-9.]+s\)$)|(^(ok|FAIL)(\s+example)\s+[0-9.]+s$)`)

func main() {
    dir, err := os.MkdirTemp("", "kata")
    if err != nil {
        panic(err)
    }
    defer os.RemoveAll(dir)
    must(os.WriteFile(filepath.Join(dir, "quantity.go"), []byte(source), 0o644))
    must(os.WriteFile(filepath.Join(dir, "quantity_test.go"), []byte(test), 0o644))
    init := exec.Command("go", "mod", "init", "example")
    init.Dir = dir
    must(init.Run())

    run(dir, "go", "test", "-v", "-run", "/err")
}

func run(dir string, name string, args ...string) {
    fmt.Println("$", name, joinArgs(args))
    cmd := exec.Command(name, args...)
    cmd.Dir = dir
    out, _ := cmd.CombinedOutput()
    fmt.Print(durations.ReplaceAllString(string(out), "$3$4"))
    fmt.Println("exit status", cmd.ProcessState.ExitCode())
}

func joinArgs(args []string) string {
    s := ""
    for i, a := range args {
        if i > 0 {
            s += " "
        }
        s += a
    }
    return s
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

Verified output of subtests_and_table_tests_kata_go.go — regenerated by tools/run_examples.py, never hand-typed.

$ go test -v -run /err
=== RUN   TestParseQuantity
=== RUN   TestParseQuantity/err_empty
=== RUN   TestParseQuantity/err_three_fields
=== RUN   TestParseQuantity/err_not_a_number
--- PASS: TestParseQuantity
    --- PASS: TestParseQuantity/err_empty
    --- PASS: TestParseQuantity/err_three_fields
    --- PASS: TestParseQuantity/err_not_a_number
PASS
ok      example
exit status 0

In other languages

  • The Rust library's What a test asserts ↗ — Rust has no subtests: each #[test] function is one test, a table is a for loop inside it, and the first failing assert! ends the function, so a Rust table reports one case per run unless the loop collects failures itself. cargo test name filters by substring the way -run does, with no per-level /.
  • Python's unittest.TestCase.subTest ↗ is the same idea: a with self.subTest(case=...) block reports its failure and lets the loop continue. pytest reaches it with @pytest.mark.parametrize ↗, which generates one test per row and names it test_parse[empty].

Sources

  • testing, "Subtests and Sub-benchmarks" ↗, T.Run ↗, T.FailNow ↗.
  • go help testflag ↗ — how -run and -skip are split on / and matched per level.
  • Using Subtests and Sub-benchmarks ↗ — Marcel van Lohuizen, the Go blog, 2016: why subtests were added in Go 1.7, table-driven tests, and running a subset from the command line.
  • Go 1.22 release notes, "Changes to the language" ↗ — the per-iteration loop variable.
  • Alan A. A. Donovan and Brian W. Kernighan, The Go Programming Language (Addison-Wesley, 2015), chapter 11, "Testing" — 11.2, "Test Functions", and 11.2.5, "Writing Effective Tests".
  • Jon Bodner, Learning Go, 2nd ed. (O'Reilly, 2024), chapter 15, "Writing Tests" — "Running Table Tests" and "Reporting Test Failures".
  • Go in Practice, 2nd ed. (Manning, 2025), chapter 6, "Formatting, testing, debugging, and benchmarking" — 6.3, "Unit testing in Go": "Creating a test suit with table-driven tests" and "Annotating tests with names".