Go’s testing package is famously simple, but its table-driven test and benchmark pattern is a masterclass in expressive power.

Here’s a prime example of a function we might want to test:

// package main
package main

import "fmt"

func add(a, b int) int {
	return a + b
}

func main() {
	fmt.Println(add(2, 3))
}

Now, let’s write some tests for it.

Table-Driven Tests

Instead of writing a separate TestAddOne, TestAddTwo, etc., we can define a slice of structs, where each struct represents a single test case.

// package main
package main

import "testing"

func TestAdd(t *testing.T) {
	// Define a slice of structs, where each struct is a test case.
	testCases := []struct {
		name     string // A descriptive name for the test case.
		a        int
		b        int
		expected int
	}{
		{"positive numbers", 2, 3, 5},
		{"negative numbers", -1, -1, -2},
		{"zero and positive", 0, 5, 5},
		{"positive and zero", 10, 0, 10},
		{"zero and negative", 0, -7, -7},
		{"negative and zero", -3, 0, -3},
		{"two zeros", 0, 0, 0},
	}

	// Iterate over the test cases.
	for _, tc := range testCases {
		// Use t.Run to create a subtest for each case. This is crucial for
		// clear output and allows running individual cases with `go test -run TestAdd/case_name`.
		t.Run(tc.name, func(t *testing.T) {
			actual := add(tc.a, tc.b)
			if actual != tc.expected {
				// t.Errorf provides a formatted error message if the test fails.
				t.Errorf("add(%d, %d) = %d; expected %d", tc.a, tc.b, actual, tc.expected)
			}
		})
	}
}

When you run go test, if a test fails, you’ll see output like this:

--- FAIL: TestAdd (0.00s)
    --- FAIL: TestAdd/negative_numbers (0.00s)
        main_test.go:23: add(-1, -1) = -2; expected -2
FAIL
exit status 1
FAIL	command-line-arguments	0.003s

Notice how t.Run annotates the failure with the specific test case name. If you wanted to run only the negative numbers test, you’d use go test -run TestAdd/negative_numbers.

Table-Driven Benchmarks

The same pattern applies to benchmarking. Benchmarks are functions that measure the performance of your code. They must start with Benchmark and accept a *testing.B argument.

// package main
package main

import "testing"

func BenchmarkAdd(b *testing.B) {
	// Define test cases for benchmarking.
	testCases := []struct {
		name string
		a    int
		b    int
	}{
		{"positive numbers", 2, 3},
		{"negative numbers", -1, -1},
		{"zero and positive", 0, 5},
	}

	// Outer loop: iterate over benchmark cases.
	for _, tc := range testCases {
		// Use b.Run to create a sub-benchmark for each case.
		b.Run(tc.name, func(b *testing.B) {
			// ResetTimer is important if setup occurs before the loop.
			b.ResetTimer()
			// Inner loop: this is the code being benchmarked.
			// The loop runs b.N times, where b.N is determined by the testing framework.
			for i := 0; i < b.N; i++ {
				add(tc.a, tc.b)
			}
		})
	}
}

To run benchmarks, use go test -bench .. The output might look like this:

goos: linux
goarch: amd64
pkg: command-line-arguments
cpu: Intel(R) Core(TM) i7-8700K CPU @ 3.70GHz
BenchmarkAdd/positive_numbers-12          	1000000000	         0.2500 ns/op
BenchmarkAdd/negative_numbers-12          	1000000000	         0.2500 ns/op
BenchmarkAdd/zero_and_positive-12         	1000000000	         0.2500 ns/op
PASS
ok  	command-line-arguments	3.750s

The ns/op indicates nanoseconds per operation. The -12 after the benchmark name refers to the number of CPU cores used.

The most surprising thing about table-driven tests and benchmarks is how little boilerplate is required to achieve such a powerful and organized testing structure. You’re not just writing tests; you’re defining a declarative specification for your function’s behavior and performance.

When you have a function with many distinct input-output pairs or performance characteristics to verify, this pattern scales beautifully. It keeps your test file clean, readable, and maintainable, making it easy to add new cases or modify existing ones without duplicating logic. The use of t.Run and b.Run is key here, as it allows the go test tool to isolate and report on individual test or benchmark cases, providing granular feedback and control.

The next logical step in optimizing your Go testing suite is to explore fuzzing with testing/fuzz.

Want structured learning?

Take the full Golang course →