Language Internals
Part 9 of 11 · Go Language ProficiencyPackages, Testing, Fuzzing & Benchmarks
Packages, exported names, table tests, fuzzing, and benchmarks.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
What is exported?
Answer
A name that starts with a capital letter. Lowercase stays in the package. There is no export keyword.
L2
Where do tests live?
Answer
In files named with the _test.go suffix, usually package mathx or mathx_test.
L3
Why a table instead of copied functions?
Answer
Each case is data. t.Run names the failure. Adding a row does not copy the assertion.
L4
t.Fatal or t.Error?
Answer
Fatal stops this test function. Error records the failure and keeps going, which matters when later checks are still useful.
L5
What must a fuzz target guarantee?
Answer
It must not panic on the inputs the fuzzer generates, and it must check a property you can state.
L6
What does b.N mean?
Answer
The benchmark harness picks N so the loop runs long enough to measure. You do not set N yourself.
L7
Which flag belongs on concurrent tests?
Answer
go test -race. Fuzz and bench can take it too when the code under test shares memory.
Failure modes
Fatal inside a loop without t.Run
The first bad row hides every later row.
Fuzz target panics
The fuzzer treats the panic as a failure, which is correct, and you had no property to assert.
Benchmark allocates in the timer
Setup inside the loop dominates b.N and the number is noise.
Exported test helper
A capital name leaks a helper into the public API of the non-test package.
Misconceptions
Go needs a third-party test runner to start.
go test runs tables, fuzz, and benchmarks from the stdlib.
t.Error aborts the test.
It records and continues. t.Fatal aborts that test function.
Fuzzing is only for security teams.
It is the right hammer for parsers and decoders that must not panic.
An external test package can see lowercase names.
package foo_test is outside. It sees only exports, which is the point.
Interviewer traps
Saying Fatal and Error are the same.
Fatal stops the function. Error continues.
Setting b.N by hand.
The harness sets N. Reset the timer after setup.
Fuzzing without a seed.
f.Add gives the corpus a starting input.
Design scenario
Same prompt for every reader.
Requirements
t.Run per row, FuzzParse that must not panic, BenchmarkParse with -benchmem, -race on the concurrent package.
Traffic / scale
CI on every change. The fuzzer gets a short fuzztime.
Latency
The benchmark is how you notice an accidental allocation, not a production load test.
Consistency
A failing row names itself. Later rows still run if you use Error inside a subtest on purpose.
Availability
A panic in the parser is a fuzz failure, not a swallowed log line.
Failure assumptions
- One giant TestAll stops at the first Fatal and hides the rest.
- The fuzzer is skipped because it is not in the stdlib.
Constraints
- Use the testing package. Do not add a runner to explain the idea.
- Say which names are exported.
Prompt
Lock down a small Max and a parser with a table, one fuzz target, and a benchmark.
Tables in the stdlib
Prefer
testing plus t.Run
Cases are data. The failure names the row. Fuzz and bench are the same command.
- Capital letters export.
- Fatal stops one test function.
- Fuzz starts from f.Add.
Alternative
A runner framework
vitest each and pytest parametrize are the same shape with a dependency.
- The assertion library is separate.
- Fuzz is a plugin or hypothesis.
- The idea is still a table.
Overview
A package is one directory. Max is exported. max is not. Tests sit beside the code in _test.go. The idiomatic test is a slice of cases and t.Run.
Decisions
- 1
Step 1 Package API - Capitalized names are exported
- nextStep 2 Table-driven test with t.Run
- 2
Step 2 Table-driven test with t.Run
- nextStep 3 go test -race ./...
- 3
Step 3 go test -race ./...
- nextStep 4 FuzzX with f.Add seeds, then go test -fuzz
- t.Fatal from a spawned goroutineFailure path - call t.Fatal only from the test goroutine
- 4
Step 4 FuzzX with f.Add seeds, then go test -fuzz
- nextStep 5 Fuzzer finds a failing input
- ?
Step 5 Fuzzer finds a failing input
- yesStep 6a Input saved under testdata/fuzz - fix and keep it as a regression
- noStep 6b Benchmark with b.Loop, compare runs with benchstat
- 6
Step 6a Input saved under testdata/fuzz - fix and keep it as a regression
- 7
Step 6b Benchmark with b.Loop, compare runs with benchstat
- 8
Failure path - call t.Fatal only from the test goroutine
Lesson map
Packages, Testing, Fuzzing & Benchmarks
Packages, exported names, table tests, fuzzing, and benchmarks.
Architecture. Step 1 Package API - Capitalized names are exported Ready. Step 2 Table-driven test with t.Run Ready. Step 3 go test -race ./... Ready. Step 4 FuzzX with f.Add seeds, then go test -fuzz Ready. Step 5 Fuzzer finds a failing input Ready. Step 6a Input saved under testdata/fuzz - fix and keep it as a regression Ready. Step 6b Benchmark with b.Loop, compare runs with benchstat Ready. Failure path - call t.Fatal only from the test goroutine Ready
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB A["Step 1 Package API - Capitalized names are exported Ready"] B["Step 2 Table-driven test with t.Run Ready"] C["Step 3 go test -race ./... Ready"] D["Step 4 FuzzX with f.Add seeds, then go test -fuzz Ready"] E["Step 5 Fuzzer finds a failing input Ready"] F["Step 6a Input saved under testdata/fuzz - fix and keep it as a regression Ready"] G["Step 6b Benchmark with b.Loop, compare runs with benchstat Ready"] X["Failure path - call t.Fatal only from the test goroutine Ready"] A -->|continues| B B -->|continues| C C -->|continues| D D -->|continues| E E -->|yes| F E -->|no| G C -->|t.Fatal from a spawned goroutine| X
Press Run. Snippets must be self-contained — no network, files, or native modules.
That loop is the table. t.Run is what gives each row its own name and its own Fatal.
Rosetta — table test
package mathx
import "testing"
func Max(a, b int) int {
if a > b {
return a
}
return b
}
func TestMax(t *testing.T) {
tests := []struct {
name string
a, b, want int
}{
{"a bigger", 3, 2, 3},
{"b bigger", 1, 4, 4},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Max(tt.a, tt.b); got != tt.want {
t.Fatalf("got %d want %d", got, tt.want)
}
})
}
}import { describe, it, expect } from "vitest";
function max(a: number, b: number) {
return a > b ? a : b;
}
describe("max", () => {
it.each([
["a bigger", 3, 2, 3],
["b bigger", 1, 4, 4],
])("%s", (_name, a, b, want) => {
expect(max(a, b)).toBe(want);
});
});import pytest
def max2(a: int, b: int) -> int:
return a if a > b else b
@pytest.mark.parametrize("a,b,want", [(3, 2, 3), (1, 4, 4)])
def test_max(a, b, want):
assert max2(a, b) == wantMax is public to other packages. The test function is not part of the library API. t.Fatalf stops that subtest. Sibling subtests still run.
Rosetta — fuzz and benchmark
package parse
import "testing"
func Parse(s string) ([]int, error) { return nil, nil }
func FuzzParse(f *testing.F) {
f.Add("1,2,3")
f.Fuzz(func(t *testing.T, s string) {
_, _ = Parse(s)
})
}
func BenchmarkParse(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = Parse("1,2,3")
}
}
// go test -fuzz=FuzzParse -fuzztime=30s
// go test -bench=. -benchmem -raceimport fc from "fast-check";
function parseOk(s: string): boolean {
return typeof s === "string";
}
// fc is the default export. A named import { fc } does not resolve.
fc.assert(fc.property(fc.string(), (s) => parseOk(s)));def parse_ok(text: str) -> bool:
return isinstance(text, str)f.Add seeds the corpus. The fuzz function must not panic. TypeScript and Python reach for fast-check or Hypothesis. Those are not required to understand the Go command. -benchmem prints allocations. -race belongs on concurrent packages. Go 1.27.1 still uses this testing API.
An external test package (package mathx_test) imports the public API only. That is the right place to prove you did not depend on lowercase helpers.
Interview Q&A
t.Fatal or t.Error?
Answer
Fatal stops this test function. Error records the failure and continues.
What does t.Run change?
Answer
The row gets a name, and Fatal inside the subtest does not skip the other rows.
What is a fuzz target for?
Answer
Parsers and decoders. Seed it, mutate, and fail on panic or a broken property.
Who sets b.N?
Answer
The testing harness. Do setup before the loop or call ResetTimer.
How is a name exported?
Answer
The first letter is uppercase. There is no export keyword.
When do you add -race?
Answer
When the package starts goroutines that share memory. The detector is not a style check.
Internal or external test package?
Answer
Same package to test lowercase helpers. External package to test the public surface only.
Did 1.27 replace go test?
Answer
No. Tables, fuzz, and benchmarks are the same commands on Go 1.27.1. The toolchain pin is what makes CI run that version.
Pitfalls
- Copy-pasted test functions instead of a row.
t.Fatalin a loop with no subtest, hiding later cases.- A fuzz target with no seed.
- Setup work inside the benchmark loop.
A parser test panics on one input. Say whether that belongs in a table row or a fuzz target, and which command you would run for 30 seconds.