Table-driven tests are the default way most Go code gets tested, precisely because the language provides no separate parameterized-test mechanism.
The basic shape
func TestAdd(t *testing.T) {
cases := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -2, -3, -5},
{"zero", 0, 5, 5},
{"mixed sign", -2, 5, 3},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := Add(tc.a, tc.b)
if result != tc.expected {
t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, result, tc.expected)
}
})
}
}
Why this beats one function per case
// The alternative: repetitive, and easy to forget updating consistently
func TestAddPositive(t *testing.T) { ... }
func TestAddNegative(t *testing.T) { ... }
func TestAddZero(t *testing.T) { ... }
Adding a new scenario in the table-driven version means adding one line to the slice literal. In the one-function-per-case version, it means writing (and remembering to write correctly) an entirely new test function, with all the same boilerplate repeated.
Testing error cases in the same table
cases := []struct {
name string
input string
want int
wantErr bool
}{
{"valid number", "42", 42, false},
{"invalid input", "abc", 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseNumber(tc.input)
if (err != nil) != tc.wantErr {
t.Fatalf("unexpected error state: %v", err)
}
if got != tc.want {
t.Errorf("got %d, want %d", got, tc.want)
}
})
}
Including a wantErr field lets the same table cover both success and failure cases, keeping all related scenarios for one function in a single, readable test.
Related Resources
t.Run turns a flat test function into a tree of independently reportable, independently runnable subtests.
Granular failure reporting
func TestValidate(t *testing.T) {
cases := map[string]struct {
input string
valid bool
}{
"empty": {"", false},
"valid": {"ok@example.com", true},
"no-at": {"nope", false},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := Validate(tc.input)
if got != tc.valid {
t.Errorf("Validate(%q) = %v, want %v", tc.input, got, tc.valid)
}
})
}
}
go test -v -run TestValidate
=== RUN TestValidate/empty
--- PASS: TestValidate/empty
=== RUN TestValidate/no-at
--- FAIL: TestValidate/no-at
=== RUN TestValidate/valid
--- PASS: TestValidate/valid
You immediately know no-at is the failing case, without scanning error messages to figure out which table entry broke.
Running a single subtest
go test -run TestValidate/no-at
The / in -run targets a specific named subtest, which is invaluable when iterating on a fix for one failing case without re-running the entire suite every time.
Parallel subtests
for name, tc := range cases {
tc := tc // capture for the closure (needed pre-Go 1.22)
t.Run(name, func(t *testing.T) {
t.Parallel() // this subtest runs concurrently with other parallel subtests
// ...
})
}
Calling t.Parallel() inside a subtest tells the test runner it can run concurrently with sibling subtests that also called t.Parallel(), which can meaningfully speed up test suites with many independent, non-interfering cases.
Why this matters practically
Subtests are what make table-driven tests genuinely useful at scale. Without t.Run, a single failing case in a 50-entry table just reports "TestFoo failed," and you're left adding print statements to figure out which one.
Related Resources
Benchmarks in Go are just another kind of test function, following a specific naming and signature convention the tooling recognizes automatically.
A basic benchmark
func BenchmarkFibonacci(b *testing.B) {
for i := 0; i < b.N; i++ {
Fibonacci(20)
}
}
go test -bench=Fibonacci -benchmem
BenchmarkFibonacci-8 5000000 287 ns/op 0 B/op 0 allocs/op
b.N isn't a number you choose — the framework runs the loop with increasing b.N values until the total run time is long enough to measure reliably, then reports the stable per-iteration cost.
Excluding setup cost with ResetTimer
func BenchmarkProcessLargeFile(b *testing.B) {
data := loadLargeTestFixture() // expensive setup, shouldn't count toward the benchmark
b.ResetTimer() // timer restarts here
for i := 0; i < b.N; i++ {
Process(data)
}
}
Without b.ResetTimer(), the one-time cost of loadLargeTestFixture() would be included in the measurement, skewing the result, especially since it only happens once but b.N might be in the millions.
Comparing implementations with sub-benchmarks
func BenchmarkConcat(b *testing.B) {
b.Run("plus-operator", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = "a" + "b" + "c"
}
})
b.Run("strings-builder", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
sb.WriteString("a")
sb.WriteString("b")
sb.WriteString("c")
_ = sb.String()
}
})
}
go test -bench=BenchmarkConcat
This runs both variants under the same parent benchmark, letting you directly compare their ns/op output to see which approach is actually faster for your specific case, rather than assuming.
Why benchmarks aren't run by default
go test # runs only Test* functions
go test -bench=. # also runs Benchmark* functions matching "."(every benchmark)
Benchmarks are typically slower and noisier to run than unit tests, so they're opt-in via the -bench flag rather than part of every normal go test invocation.
Related Resources
Coverage tooling is built into go test directly, no separate installation needed.
Basic coverage percentage
go test -cover ./...
ok myapp/pkg/validate 0.003s coverage: 87.5% of statements
Generating a visual HTML report
go test -coverprofile=cover.out ./...
go tool cover -html=cover.out
This opens a browser view of your source code, with each line colored green (covered) or red (not covered), making it easy to spot exactly which branches your tests never exercise.
A common blind spot coverage reveals
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("divide by zero") // often the red, uncovered line
}
return a / b, nil
}
It's common to write a test for the happy path and forget the error branch entirely. A coverage report makes this specific gap visually obvious in a way that reading the test file alone often doesn't.
Why a high percentage isn't the same as good tests
func TestSomething(t *testing.T) {
result := DoSomethingComplicated()
_ = result // "covers" the function, but asserts NOTHING about correctness
}
This test would show 100% coverage of DoSomethingComplicated's statements, while verifying literally nothing about whether it behaves correctly. Coverage measures execution, not correctness — it's a tool for finding untested code, not a substitute for writing meaningful assertions.
Practical guidance
Use coverage reports to hunt for genuinely untested branches, especially error paths and edge cases. Don't treat a specific percentage — 80%, 90%, 100% — as a goal in itself. A team fixated purely on hitting a coverage number can end up with exactly the kind of assertion-free "coverage theater" shown above.
Related Resources
Go's structural typing means mocking rarely needs a heavyweight framework — a small interface and a hand-written struct are often enough.
Hand-rolled mock, using a consumer-defined interface
// Production code depends on a narrow interface, not a concrete *sql.DB
type UserFinder interface {
FindUser(id int) (*User, error)
}
func GreetUser(f UserFinder, id int) (string, error) {
u, err := f.FindUser(id)
if err != nil {
return "", err
}
return "Hello, " + u.Name, nil
}
// Test code: a fake implementation with canned behavior
type fakeUserFinder struct {
user *User
err error
}
func (f *fakeUserFinder) FindUser(id int) (*User, error) {
return f.user, f.err
}
func TestGreetUser(t *testing.T) {
fake := &fakeUserFinder{user: &User{Name: "Alice"}}
got, err := GreetUser(fake, 1)
if err != nil || got != "Hello, Alice" {
t.Errorf("got %q, %v", got, err)
}
}
No mocking library was needed at all — just a plain struct satisfying the interface.
Generated mocks for larger interfaces
//go:generate mockgen -source=finder.go -destination=mocks/finder_mock.go
func TestGreetUser_WithGeneratedMock(t *testing.T) {
ctrl := gomock.NewController(t)
mockFinder := mocks.NewMockUserFinder(ctrl)
mockFinder.EXPECT().FindUser(1).Return(&User{Name: "Alice"}, nil)
got, _ := GreetUser(mockFinder, 1)
if got != "Hello, Alice" {
t.Errorf("got %q", got)
}
}
Generated mocks add call expectations, argument matching, and call-count verification, which is worth the extra tooling once an interface is large or a test needs to assert on exactly how a dependency was called, not just what it returns.
Why this design works so well in Go
Because interfaces are satisfied implicitly, defining a small interface purely for testability costs nothing. You don't need to modify the real dependency at all. The interface can live entirely in the consuming package, scoped exactly to what that specific code needs.