Testing (Table-Driven)

intermediate go testing

Testing in Go is built right into the language. No frameworks, no extra installs — we just write functions and run go test. That’s a big selling point.

In simple language, the testing package is the standard library’s tool for writing and running tests, and the go command knows how to find and run them.

The basic rules

Tests live in files ending in _test.go. So the tests for math.go go in math_test.go, right next to it in the same package.

A test is just a function named TestXxx that takes one argument, *testing.T. The name must start with a capital T-est and the next letter must be uppercase.

// math_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
	got := Add(2, 3)
	if got != 5 {
		t.Errorf("Add(2, 3) = %d; want 5", got) // report failure, keep going
	}
}

t.Errorf marks the test as failed but keeps running the rest of the function. If we want to stop immediately (say, after a fatal setup error), we use t.Fatal / t.Fatalf instead — that fails and bails out of the function right there.

In simple language: t.Error = “this is wrong, but carry on”; t.Fatal = “this is wrong, stop now”.

The table-driven pattern (learn this cold)

This is the idiomatic way to test in Go, and it’s the answer interviewers want to hear. Instead of writing five near-identical test functions, we make a slice of test cases and loop over them.

Each case becomes its own subtest with t.Run, so they show up individually in the output and we can run just one.

func TestAdd(t *testing.T) {
	tests := []struct {
		name string
		a, b int
		want int
	}{
		{"positives", 2, 3, 5},
		{"with zero", 0, 7, 7},
		{"negatives", -2, -3, -5},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) { // named subtest per case
			if got := Add(tc.a, tc.b); got != tc.want {
				t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.want)
			}
		})
	}
}

Here’s how that flow looks:

[]struct cases → loop → t.Run(name) subtest each
"positives" "with zero" "negatives"
↓ for _, tc := range tests ↓
PASS TestAdd/positives PASS TestAdd/with_zero FAIL TestAdd/negatives

Why we love this: adding a new case is one line, the failure message tells us exactly which case broke, and we can run a single case with go test -run TestAdd/negatives.

Running tests

go test                 # run tests in the current package
go test ./...           # run tests in this package and ALL subpackages
go test -v ./...        # verbose: show each test and subtest name
go test -run TestAdd     # run only tests matching this regex
go test -cover ./...     # show % of code covered by tests

The ./... pattern is the one we use most — it means “everything under here, recursively”.

No assertion library needed

Coming from other languages, people often ask “where’s assert?” Go doesn’t ship one on purpose — we use plain if checks and t.Error. It’s more verbose but dead simple.

If we really want assertions, the popular third-party choice is testify (github.com/stretchr/testify), which gives us assert.Equal(t, want, got) and friends. Lots of teams use it, but it’s optional — plenty of big Go codebases use the standard library only.

Interview soundbite

Go testing is built in: tests go in _test.go files as func TestXxx(t *testing.T), run with go test ./.... We use t.Error to fail-and-continue and t.Fatal to fail-and-stop. The idiomatic pattern is table-driven tests — a slice of case structs looped over with t.Run subtests — which keeps tests DRY and pinpoints which case failed. No assertion library is needed, though testify is the popular optional one.