Tests Are Table-Driven: Testing Without a Framework
Go's testing is built into the toolchain: table-driven tests, t.Run subtests, t.Helper, t.Cleanup, t.Parallel, comparing composites with slices.Equal and reflect.DeepEqual, and exercising HTTP handlers with httptest — a recorder for the fast path and a real server for the honest one. Compiled and run against Go 1.26.5.
Testing in Go is not a library you choose, a config you write, or an assertion DSL you learn. It ships in the toolchain: a testing package, a go test command, and one convention — a function named TestXxx(t *testing.T) in a file ending _test.go. That’s the entire apparatus. There is no expect(x).toBe(y), no assertEquals, no @Test annotation. You compare values with a plain if and report failure with a method on t. It feels sparse at first and then feels like a relief: every Go test suite anywhere works exactly this way.
Because there’s no assertion library to lean on, Go leans on structure instead, and the structure that has won is the table-driven test: describe your cases as data, then run the same check over every row. Everything below is run with go test -v against Go 1.26.5, and the output is real.
The table
Here is the shape you’ll write more than any other. A slice of anonymous structs, each a named case with inputs and the expected output, iterated by one loop:
func TestDiscount(t *testing.T) {
tests := []struct {
name string
price float64
pct int
want float64
}{
{"no discount", 40, 0, 40},
{"half off", 40, 50, 20},
{"full off", 40, 100, 0},
{"negative clamps to zero", 40, -10, 40},
{"over 100 clamps to 100", 40, 150, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Discount(tt.price, tt.pct); got != tt.want {
t.Errorf("Discount(%v, %d) = %v, want %v", tt.price, tt.pct, got, tt.want)
}
})
}
}
Two design choices are doing the work here. The cases are data, so adding a case is adding a line, and the edge cases — the clamps at zero and a hundred — sit right next to the happy path where you can see the coverage at a glance. And each case runs inside t.Run(name, func...), a subtest, which gives the case its own name in the output and its own pass/fail line. Note t.Errorf rather than t.Fatalf: Errorf records the failure and lets the loop continue, so one broken case doesn’t hide the other four. Reserve Fatalf for when continuing is pointless, like a setup step that failed.
Comparing things that aren’t numbers
== works for numbers, strings, and bools, but not for slices or maps — the compiler rejects slice == slice outright. For those you need a real comparison. The modern answer for slices is slices.Equal:
func TestTags(t *testing.T) {
tests := []struct {
name string
in string
want []string
}{
{"simple", "go,web,db", []string{"go", "web", "db"}},
{"trims spaces", " go , web ", []string{"go", "web"}},
{"drops blanks", "go,,web,", []string{"go", "web"}},
{"empty", "", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Tags(tt.in); !slices.Equal(got, tt.want) {
t.Errorf("Tags(%q) = %#v, want %#v", tt.in, got, tt.want)
}
})
}
}
For anything deeper — a map, a struct of slices, nested data — reach for reflect.DeepEqual, which walks the whole structure recursively. It’s slower and untyped, but it compares what == and slices.Equal can’t.
One sharp edge lives right here, and it’s caught more than one test author off guard. reflect.DeepEqual treats a nil slice and an empty non-nil slice as different, while slices.Equal treats them as equal:
reflect.DeepEqual(nil, empty): false
slices.Equal(nil, empty): true
reflect.DeepEqual(map, map): true
That is why the "empty" case above wants nil and not []string{} — Tags("") returns a nil slice, and had we used reflect.DeepEqual with an empty-literal want, a perfectly correct function would fail its test. For slices, prefer slices.Equal, which asks the question you actually mean.
Helpers that point at the right line
When several tests share a check, factor it into a helper. But a naive helper reports failures at its own line, which is useless — you want the failing test’s line. t.Helper() fixes exactly that: it marks the function as a helper so the test runner skips it when reporting the failure location.
func assertJSON(t *testing.T, body io.Reader, want Book) {
t.Helper()
var got Book
if err := json.NewDecoder(body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if got != want {
t.Errorf("body = %+v, want %+v", got, want)
}
}
Call t.Helper() as the first line of any function that takes a *testing.T and makes assertions. Without it, every failure blames line inside assertJSON; with it, the failure blames the call site, where the interesting information is.
Testing an HTTP handler two ways
Servers are the reason most of us reach for tests, and the net/http/httptest package gives two ways to exercise a handler, at two different altitudes.
The fast path is httptest.NewRecorder, a fake ResponseWriter that captures what the handler writes. You call the handler directly, no network, no port:
func TestHandlerRecorder(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/book", nil)
rec := httptest.NewRecorder()
Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
assertJSON(t, rec.Body, Book{Title: "Learning Go", Author: "Bodner"})
}
The honest path is httptest.NewServer, which stands up a real HTTP server on a real port and hands you its URL. You make an actual http.Get, exercising the full stack — routing, the client, the wire:
func TestHandlerServer(t *testing.T) {
srv := httptest.NewServer(Handler())
t.Cleanup(srv.Close)
resp, err := http.Get(srv.URL + "/book")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
assertJSON(t, resp.Body, Book{Title: "Learning Go", Author: "Bodner"})
}
Note t.Cleanup(srv.Close) instead of a defer. t.Cleanup registers teardown that runs when the test finishes, and unlike defer it survives being called from inside a helper and runs in the right order across nested subtests. Use the recorder for handler logic — it’s faster and needs no port — and the server when you want to prove the whole request actually travels end to end.
Running in parallel
Marking a test with t.Parallel() signals it can run alongside other parallel tests. The runner pauses it, gathers all the parallel tests in the package, and runs them concurrently:
func TestParallelA(t *testing.T) {
t.Parallel()
// ...
}
func TestParallelB(t *testing.T) {
t.Parallel()
// ...
}
It’s free speed for independent, I/O-bound tests, and it quietly enforces that your tests don’t secretly depend on each other or on shared mutable state. If two tests scribble on the same global, t.Parallel() will find out.
What it looks like when you run it
go test -v prints a line per test and per subtest. Here is the real, unedited output of the suite above:
=== RUN TestDiscount
=== RUN TestDiscount/no_discount
=== RUN TestDiscount/half_off
--- PASS: TestDiscount (0.00s)
--- PASS: TestDiscount/no_discount (0.00s)
--- PASS: TestDiscount/half_off (0.00s)
=== RUN TestHandlerRecorder
--- PASS: TestHandlerRecorder (0.00s)
=== RUN TestHandlerServer
--- PASS: TestHandlerServer (0.00s)
=== RUN TestParallelA
=== PAUSE TestParallelA
=== RUN TestParallelB
=== PAUSE TestParallelB
=== CONT TestParallelA
--- PASS: TestParallelA (0.00s)
=== CONT TestParallelB
--- PASS: TestParallelB (0.00s)
PASS
ok example.com/shop 0.314s
Read the parallel tests’ trace: both hit PAUSE, then both CONT together once the sequential tests finished. Subtest names replace spaces with underscores, which is why t.Run names avoid punctuation. The final ok and package timing is what your CI checks; without -v you’d see only that summary line.
Running just the test you care about
You rarely run everything while debugging one case. The -run flag takes a regular expression matched against test names, and it reaches into subtests with a slash. To run only the half off case of TestDiscount:
$ go test -v -run 'TestDiscount/half'
=== RUN TestDiscount
=== RUN TestDiscount/half_off
--- PASS: TestDiscount (0.00s)
--- PASS: TestDiscount/half_off (0.00s)
PASS
ok example.com/shop 0.409s
The parent test still starts — it has to, to reach its subtests — but only the matching subtest runs. This is the payoff of naming every case: each one is individually addressable. Drop the -v and the -run and you get the terse ok package time for the whole suite, which is what you commit to; scale that to go test ./... and the toolchain runs every test in the module, since tests live in the same package as the code they check rather than in a separate tree.
What a failure looks like
Because there are no assertions, a failure is just the message you wrote in t.Errorf. Suppose one table row claimed a wrong answer — that 40 at 50% off is 25:
=== RUN TestDiscount/half_off
shop_test.go:18: Discount(40, 50) = 20, want 25
--- FAIL: TestDiscount/half_off (0.00s)
--- FAIL: TestDiscount (0.00s)
FAIL
FAIL example.com/shop 0.267s
The subtest name pins which case broke, the message shows got and want, and shop_test.go:18 is the exact line — that’s t.Helper() and the subtest earning their keep together. go test exits non-zero, so CI fails. Fix the expected value to 20 and it goes green:
ok example.com/shop 0.276s
That is the whole loop. Write cases as a table, compare with the right tool for the type, report with a message that tells you got and want, and let go test decide.
Final thoughts
Go’s testing is the toolchain, not a framework: a TestXxx(*testing.T) in a _test.go file, run by go test. The table-driven pattern turns cases into data and subtests via t.Run, so adding coverage is adding a row and every failure names itself. Compare composites with slices.Equal (and mind that it, unlike reflect.DeepEqual, calls nil and empty equal); factor shared checks into helpers marked with t.Helper() so failures point at the call site; tear down with t.Cleanup; and let independent tests run with t.Parallel(). Exercise handlers with a recorder for speed and a real httptest server for honesty. No assertion library, no annotations — just values, an if, and a message.
Next: benchmarks, fuzzing, coverage — the rest of what go test does once your tests pass.
Comments