Table-Driven Tests in Go
Write table-driven tests in Go - a slice of test cases looped over in one test function - with clear names, failure messages and error cases.
When you test one function with many inputs, copying the same if got != want block ten times gets messy fast. Go code solves this with table-driven tests: put the inputs and expected outputs in a slice, then loop over it. It is the most common testing style in the standard library and in Go projects generally.
From Repeated Checks to a Table
Here is the repetitive version:
if got := Slug("Hello World"); got != "hello-world" { t.Errorf(...) }
if got := Slug(" Go "); got != "go" { t.Errorf(...) }
if got := Slug(""); got != "" { t.Errorf(...) }
And the table-driven version:
// file: slug_test.go
package textutil
import (
"strings"
"testing"
)
func Slug(title string) string {
return strings.Join(strings.Fields(strings.ToLower(title)), "-")
}
func TestSlug(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "two words", input: "Hello World", want: "hello-world"},
{name: "extra spaces", input: " Go Docs ", want: "go-docs"},
{name: "empty", input: "", want: ""},
{name: "already a slug", input: "go-docs", want: "go-docs"},
}
for _, tc := range tests {
got := Slug(tc.input)
if got != tc.want {
t.Errorf("%s: Slug(%q) = %q, want %q", tc.name, tc.input, got, tc.want)
}
}
}
Each row is one case. Adding a new case is one line. The loop body is written once, so every case gets the same clear failure message.
The struct type is anonymous, declared right inside the slice literal, because it is only used here. The field names input and want are the convention, and tc (test case) or tt is the usual loop variable.
Testing Functions That Return Errors
Add a field that says whether an error is expected:
// file: port_test.go
package config
import (
"fmt"
"strconv"
"testing"
)
func ParsePort(s string) (int, error) {
p, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("port %q: %w", s, err)
}
if p < 1 || p > 65535 {
return 0, fmt.Errorf("port %d out of range", p)
}
return p, nil
}
func TestParsePort(t *testing.T) {
tests := []struct {
name string
in string
want int
wantErr bool
}{
{"valid", "8080", 8080, false},
{"lowest", "1", 1, false},
{"zero", "0", 0, true},
{"too high", "70000", 0, true},
{"not a number", "http", 0, true},
}
for _, tc := range tests {
got, err := ParsePort(tc.in)
if (err != nil) != tc.wantErr {
t.Errorf("%s: ParsePort(%q) error = %v, wantErr %v", tc.name, tc.in, err, tc.wantErr)
continue
}
if got != tc.want {
t.Errorf("%s: ParsePort(%q) = %d, want %d", tc.name, tc.in, got, tc.want)
}
}
}
(err != nil) != tc.wantErr is true when an error appeared but was not expected, or was expected but did not appear. The continue skips the value check in that case, since the value is meaningless when the error check already failed.
When the exact error matters, store a target error in the table and compare with errors.Is instead of a bool.
Do not compare error text in tables:
A wantErr: "port 0 out of range" string field breaks as soon as someone improves the message. Use a bool, or a sentinel error checked with errors.Is.
Using a Map for the Table
Some people use a map keyed by the case name:
tests := map[string]struct {
input string
want string
}{
"two words": {"Hello World", "hello-world"},
"empty": {"", ""},
}
for name, tc := range tests { ... }
Map iteration order is random in Go, so cases run in a different order each time. That can expose tests that accidentally depend on each other. A slice keeps a fixed order, which makes output easier to compare between runs. Both are fine.
Failure messages without the case name:
A message like got "go", want "go-docs" from a 30-row table forces you to hunt for the failing row. Always include the case name or the input in the message, or use subtests, which add the name automatically.
What Makes a Good Table
- Cover normal input, edge cases (empty, zero, maximum), and invalid input.
- Keep each row short. If a row needs ten fields, the function may be doing too much.
- Give every row a name that says what is special about it.
A strong table-driven test:
New cases are one-line additions, every failure message names the case and shows input, got and want, and the table covers both success and error paths.
Summary
Table-driven tests put cases in a slice of anonymous structs and loop over them, so the checking code is written once. Add a wantErr field or a sentinel error for failure cases, include the case name in every message, and prefer a slice for a stable order. Combine tables with t.Run subtests to get named, individually runnable cases.