Hermes Wiki
Developer/Languages/Go/TestingInGo/Fundamentals/table-driven-tests-subtests-and-benchmarking-in-go

Table-Driven Tests, Subtests, and Benchmarking in Go

Concept

Go's testing package deliberately has no assertion library, no test-class hierarchy, and no fixture-decorator system — a test is just a function named TestXxx(t *testing.T), and structure comes from convention, not framework machinery. The dominant convention is the table-driven test: a slice of structs, each holding one case's inputs and expected output, iterated with t.Run(name, func(t *testing.T) {...}) to register each row as its own subtest. This isn't a stylistic preference — t.Run gives each row an isolated pass/fail result, its own -run regex target (go test -run TestParse/empty_input), and independent t.Parallel() eligibility, none of which a single loop with bare t.Errorf calls provides. A failing row reports its own name and line, and the rest of the table keeps running instead of the whole test function aborting or blurring together.

func TestParseDuration(t *testing.T) {
    cases := []struct {
        name    string
        input   string
        want    time.Duration
        wantErr bool
    }{
        {"seconds", "5s", 5 * time.Second, false},
        {"empty", "", 0, true},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            got, err := ParseDuration(tc.input)
            if tc.wantErr {
                if err == nil {
                    t.Fatalf("expected error, got nil")
                }
                return
            }
            if got != tc.want {
                t.Errorf("got %v, want %v", got, tc.want)
            }
        })
    }
}

testify (stretchr/testify) is the de facto standard third-party addition — not a replacement for testing, but an assert/require package that turns if got != want { t.Errorf(...) } boilerplate into assert.Equal(t, want, got), plus a suite package for shared setup/teardown when a group of tests genuinely needs it. It's additive: testify tests still run under plain go test, still use t.Run for subtests, and a Go engineer can read testify-based tests without learning a parallel test runner.

Benchmarking uses the same table-driven shape with testing.B in place of testing.T: func BenchmarkXxx(b *testing.B), run via go test -bench=., with the loop body executed b.N times (Go's benchmark harness adjusts N upward until the timing is statistically stable). b.Run sub-benchmarks let one function benchmark several input sizes or implementations side by side, and b.ReportAllocs() surfaces allocations-per-op alongside ns/op — critical for catching a change that's faster in raw CPU time but regresses under GC pressure.

Tradeoffs

Approach Benefit Cost
Table-driven + t.Run subtests (stdlib only) Zero dependencies; every Go engineer already knows the idiom; isolated pass/fail per case; targetable via -run More boilerplate per assertion than a fluent assertion library; no built-in "assert and continue vs. fatal" distinction beyond Errorf/Fatalf
Table-driven + testify assert/require Readable one-line assertions (assert.Equal, require.NoError); rich diff output on failure; suite for shared setup when genuinely needed One more dependency in go.mod; assert.* continues after failure while require.* aborts the subtest — easy to pick the wrong one and get a confusing partial-failure report
gomock / interface-based mocking Type-checked expectations, generated from an interface, catches call-signature drift at compile time Adds a codegen step (mockgen) to the build; overuse turns tests into mirrors of the implementation rather than checks on behavior
testing.B benchmarks with -bench Built into the toolchain, integrates with pprof and benchstat for regression comparison across commits Noisy on a shared/loaded CI runner unless pinned to dedicated hardware or run with enough iterations; easy to benchmark the wrong thing (e.g., dominated by test-data setup instead of the code under test)

When to use / when not to

  • Default to table-driven + t.Run for any function with more than one meaningfully distinct input case — which is most functions worth testing at all.
  • Reach for testify once assertion boilerplate is genuinely slowing reviews down, or a suite needs shared setup/teardown across many tests in a file; don't add it to a package that has two simple tests and no real duplication.
  • Use b.Run sub-benchmarks whenever comparing more than one implementation or input size — a single flat benchmark makes regressions and input-size effects indistinguishable in benchstat output.
  • Skip benchmarking code that isn't on a hot path or hasn't shown up in profiling — benchmarks are maintenance cost too, and a benchmark nobody ever reruns to check for regressions isn't earning its keep.

Common pitfall

Looping over the table without t.Run, or reusing the loop variable inside a t.Run closure without shadowing it locally (tc := tc) on Go versions before 1.22's per-iteration loop variable semantics. Both produce test output that's hard to attribute to a specific row — a shared-variable bug in particular can make every subtest silently check the last row's data instead of its own, passing when it should fail. On Go 1.22+ the loop-variable capture bug is fixed at the language level, but the t.Run isolation benefit (independent pass/fail, -run targeting, t.Parallel() support) still requires actually calling t.Run per case — a bare for loop with t.Errorf inside never gets it regardless of Go version.

Engineering Lens

The stdlib-first design of Go's testing package is itself a signal worth reading in a review: table-driven subtests are cheap enough in raw syntax that "we didn't have time to write proper tests" rarely holds up, and a PR introducing a new function without a table of cases is a legitimate review comment on its own. The testify-vs-stdlib-only decision is a good proxy for a team's testing maturity — teams that reach for testify tend to also use require correctly (fail fast on setup preconditions, assert for the actual behavior under test) rather than defaulting everything to assert and getting confusing multi-failure output. Benchmarks earn their keep only when paired with benchstat in CI or a regular local habit — a benchmark that exists but is never rerun before merging a "performance" PR is decoration, not evidence.

Sources

Hermes Wiki