Lesson 7 • Advanced
Testing in Go 🐹
By the end of this lesson you'll write real Go tests with the built-in testing package — single tests, table-driven tests with subtests, and benchmarks — and run them with go test. No third-party libraries needed; it's all in the standard library.
What You'll Learn in This Lesson
- Put tests in _test.go files and write func TestXxx(t *testing.T)
- Report failures with t.Errorf (keep going) and t.Fatalf (stop now)
- Write table-driven tests and run each case as a t.Run subtest
- Run tests with go test, -v, -run, and measure with -cover
- Add a benchmark with Benchmark + *testing.B and the -bench flag
- Avoid classic traps like comparing slices or maps with ==
_test.go file in a Go module and run go test -v to see the same output. Install Go from go.dev if you haven't already.1️⃣ Your First Test
Go's test runner is built into the language — there's no framework to install. Three conventions are all you need: the file name ends in _test.go, each test function is named Test + a capitalised name, and it takes a single t *testing.T. Go has no assert keyword: you compare the values yourself and call a method on t when something's wrong. Read this worked example, then run it.
// math.go — the code you want to test.
package mathutil
func Add(a, b int) int {
return a + b
}
// math_test.go — tests MUST live in a file ending in _test.go,
// in the same package. The compiler treats _test.go files specially:
// they are only built when you run "go test", never in your app.
package mathutil
import "testing"
// Rules for a test function:
// - name starts with Test followed by a Capital letter (TestAdd)
// - it takes exactly one argument: t *testing.T
func TestAdd(t *testing.T) {
got := Add(2, 3) // call the thing under test
want := 5 // what you expect
// There is no assert keyword in Go. You compare yourself and,
// on failure, report it via t. t.Errorf marks the test failed
// but keeps running the rest of the function.
if got != want {
t.Errorf("Add(2, 3) = %d; want %d", got, want)
}
}$ go test
ok myproject/mathutil 0.003s
$ go test -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok myproject/mathutil 0.003smath_test.go in a Go module and run go test -v in your own terminal.2️⃣ Reporting Failures: t.Errorf vs t.Fatalf
When a check fails you tell the test runner with a method on t. t.Errorf records the failure but keeps running the rest of the function, so one run can surface several issues. t.Fatalf records it and stops the test right there — use it after setup or an error check, when the lines below would otherwise crash. Both take a printf-style format string.
package mathutil
import "testing"
func Divide(a, b int) int { return a / b }
func TestDivide(t *testing.T) {
// t.Fatalf reports the failure AND stops this test immediately
// (via runtime.Goexit). Use it when continuing makes no sense —
// here, if b is 0 the next line would panic, so we bail out first.
b := 2
if b == 0 {
t.Fatalf("b is 0; cannot divide") // stops the test now
}
got := Divide(10, b)
// t.Errorf reports the failure but KEEPS GOING, so one run can
// surface several problems at once.
if got != 5 {
t.Errorf("Divide(10, 2) = %d; want 5", got)
}
}$ go test -v
=== RUN TestDivide
--- PASS: TestDivide (0.00s)
PASS
ok myproject/mathutil 0.002sgo test -v. Try changing want to a wrong value to see a failure message.Now you try. The test below is almost complete — fill in the two blanks marked ___ using the hints, then run it. You're writing the expected value and the comparison.
package mathutil
import "testing"
func Double(n int) int { return n * 2 }
// 🎯 YOUR TURN — finish this test, then run "go test -v".
func TestDouble(t *testing.T) {
got := Double(4)
want := ___ // 👉 the number you expect Double(4) to return
// 👉 compare got and want; on mismatch call t.Errorf with a message
if got ___ want { // 👉 use the "not equal" operator here
t.Errorf("Double(4) = %d; want %d", got, want)
}
}
// ✅ Expected output when correct:
// === RUN TestDouble
// --- PASS: TestDouble (0.00s)
// PASS___, then run go test -v and check your output matches the expected lines in the comment.3️⃣ Table-Driven Tests with Subtests
This is the pattern you'll write most in Go. Instead of copy-pasting a near-identical test for every input, you describe the cases as data in a slice of structs, then loop over them. Wrapping each case in t.Run(name, func) turns it into a named subtest, so the report tells you precisely which case broke and you can re-run just that one. Adding a new case is a single line.
package mathutil
import "testing"
// Table-driven tests are Go's signature pattern: describe each case
// as DATA in a slice, then loop and run each one as a named subtest.
func TestAdd(t *testing.T) {
// An anonymous struct describes the shape of one test case.
tests := []struct {
name string // a label for this case (shows up in -v output)
a, b int // inputs
want int // expected result
}{
{"two positives", 2, 3, 5},
{"two negatives", -1, -2, -3},
{"with zero", 0, 7, 7},
{"mixed signs", -5, 10, 5},
}
for _, tt := range tests {
// t.Run starts a SUBTEST named after the case. Each subtest
// passes or fails on its own, so you see exactly which case broke.
t.Run(tt.name, func(t *testing.T) {
if got := Add(tt.a, tt.b); got != tt.want {
t.Errorf("Add(%d, %d) = %d; want %d",
tt.a, tt.b, got, tt.want)
}
})
}
}$ go test -v
=== RUN TestAdd
=== RUN TestAdd/two_positives
=== RUN TestAdd/two_negatives
=== RUN TestAdd/with_zero
=== RUN TestAdd/mixed_signs
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/two_positives (0.00s)
--- PASS: TestAdd/two_negatives (0.00s)
--- PASS: TestAdd/with_zero (0.00s)
--- PASS: TestAdd/mixed_signs (0.00s)
PASS
ok myproject/mathutil 0.004sgo test -v to see each subtest reported on its own line (spaces in case names become underscores).Your turn again. The table below tests a Max function but is missing a case. Add one row that covers two equal numbers, then run it.
package mathutil
import "testing"
func Max(a, b int) int {
if a > b {
return a
}
return b
}
func TestMax(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"a bigger", 9, 4, 9},
{"b bigger", 2, 8, 8},
// 🎯 YOUR TURN — add ONE more case to the table below.
// 👉 a case where both numbers are equal, e.g. 5 and 5.
___, // 👉 {"name", a, b, want} — fill in all four fields
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Max(tt.a, tt.b); got != tt.want {
t.Errorf("Max(%d, %d) = %d; want %d",
tt.a, tt.b, got, tt.want)
}
})
}
}
// ✅ Expected: your new subtest appears and passes, e.g.
// --- PASS: TestMax/equal (0.00s)___ with a struct literal like {"equal", 5, 5, 5}, then run go test -v and confirm your new subtest passes.4️⃣ Running Tests: go test, -v, -run, -cover
You drive everything from the command line. go test runs the package's tests and prints ok or FAIL. Add -v for a line per test, -run to filter by a name regex (great with subtests), and -cover for the percentage of your code the tests exercise. ./... means "this package and everything below it".
# Run every test in the current package
go test
# -v: verbose — show each test and subtest as it runs
go test -v
# -run: only tests whose name matches a regex.
# "TestAdd" runs TestAdd; "TestAdd/zero" targets one subtest.
go test -run TestAdd
go test -run 'TestAdd/with_zero' -v
# -cover: what percentage of statements your tests exercise
go test -cover
# ./... means "this package and every package below it"
go test ./...
# Combine them freely:
go test -v -run TestMax -cover ./...$ go test -cover
PASS
coverage: 92.3% of statements
ok myproject/mathutil 0.004s
$ go test -run 'TestAdd/with_zero' -v
=== RUN TestAdd
=== RUN TestAdd/with_zero
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/with_zero (0.00s)
PASS
ok myproject/mathutil 0.003s5️⃣ Benchmarks (a quick note)
Once your code is correct, you may want to know if it's fast. A benchmark is named Benchmark + capital, takes a b *testing.B, and runs your code in a for i := 0; i < b.N; i++ loop. The framework raises b.N until the timing is stable. Benchmarks don't run with a plain go test — you opt in with -bench.
package mathutil
import "testing"
// A BENCHMARK measures speed. Name it Benchmark + Capital, take a
// *testing.B, and loop exactly b.N times. The framework keeps raising
// b.N until the timing is stable, then reports the per-operation cost.
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
// Benchmarks DON'T run with a plain "go test" — you opt in with -bench.
// go test -bench=. run all benchmarks
// go test -bench=. -benchmem also report memory per operation$ go test -bench=. -benchmem
goos: linux
goarch: amd64
pkg: myproject/mathutil
BenchmarkAdd-8 1000000000 0.27 ns/op 0 B/op 0 allocs/op
PASS
ok myproject/mathutil 0.34s-bench flag: try go test -bench=. -benchmem in your terminal.Pro Tips
- 💡 Name failure messages well: the convention is
"Add(2, 3) = %d; want %d"— show the call, the result you got, and what you want. - 💡 Compare slices/maps with
reflect.DeepEqual(orslices.Equal/maps.Equalin Go 1.21+) — never with==. - 💡 Run
go test -race ./...in CI — the race detector catches concurrency bugs that only appear under load. - 💡 Use
t.Helper()inside an assertion helper so failures point at the calling test, not at the helper.
Common Errors (and the fix)
- "no test files": your file isn't named
_test.go. A name likemath-test.goortests.gois ignored bygo test. Rename it to end in exactly_test.go. - Test silently never runs: the function name must be
Test+ a Capital letter.func testAddorfunc Testaddwon't be picked up — usefunc TestAdd. - "invalid operation: ... (slice can only be compared to nil)": you used
==on slices or maps. Usereflect.DeepEqual(got, want)instead. - "TestXxx has wrong signature": a test must take exactly
t *testing.T— not*testing.Band no extra parameters. - Benchmark "ran 0 times": you looped a fixed number instead of
b.N. The loop must befor i := 0; i < b.N; i++.
📋 Quick Reference — Go Testing
| Task | Code / Command |
|---|---|
| Test file name | something_test.go |
| Test function | func TestX(t *testing.T) |
| Fail, keep going | t.Errorf("got %d; want %d", g, w) |
| Fail, stop now | t.Fatalf("setup failed: %v", err) |
| Subtest | t.Run(name, func(t *testing.T){…}) |
| Run all / verbose | go test · go test -v |
| Run one (sub)test | go test -run 'TestX/case' |
| Coverage | go test -cover ./... |
| Benchmark | go test -bench=. -benchmem |
Frequently Asked Questions
Q: Why must my test file end in _test.go?
The go tool only compiles _test.go files when you run go test — they are excluded from your normal build, so test code never ships in your binary. A file named tests.go or math-test.go is treated as ordinary source, its Test functions are ignored by go test, and you will see 'no test files'. The exact suffix _test.go is required.
Q: What is the difference between t.Errorf and t.Fatalf?
Both mark the test as failed and print a formatted message. t.Errorf keeps running the rest of the test function, so a single run can report several problems at once. t.Fatalf stops the current test immediately. Use Fatalf when continuing makes no sense — for example after a setup step or an error check fails and the following lines would panic.
Q: Why do table-driven tests use t.Run for each case?
t.Run creates a named subtest, so each row of your table passes or fails independently and the -v output tells you exactly which case broke (e.g. TestAdd/with_zero). You can also target one case with go test -run 'TestAdd/with_zero'. Without t.Run, a single failing row just fails the whole test with no clear label.
Q: How do I run only one test or one subtest?
Use the -run flag with a regex. go test -run TestAdd runs only TestAdd. go test -run 'TestAdd/with_zero' targets a single subtest inside it. Combine with -v to see what ran. To test every package in a project, add ./... — for example go test -run TestMax ./...
Q: Why can't I compare two slices or maps with ==?
In Go, == only works on comparable types. Slices and maps are not comparable, so got == want is a compile error ('invalid operation: slice can only be compared to nil'). Compare them with reflect.DeepEqual(got, want), or in Go 1.21+ use slices.Equal / maps.Equal for slices and maps of comparable elements.
Q: Do benchmarks run when I run go test?
No. A plain go test runs your Test functions but skips Benchmark functions. You opt into benchmarks with the -bench flag, e.g. go test -bench=. to run them all, and add -benchmem to also report bytes and allocations per operation.
Mini-Challenge: Test a String Reverser
No blanks this time — just a brief and an outline. The Reverse function is written for you; write a table-driven test for it from scratch, run it, and check each case passes. This is exactly the kind of test you'll write every day in real Go projects.
package strutil
import "testing"
// The function under test (already written):
func Reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
// 🎯 MINI-CHALLENGE: write a table-driven test for Reverse.
// 1. Name it TestReverse and take t *testing.T.
// 2. Build a []struct slice of cases: a name, an input, and a want.
// Include at least: "hello" -> "olleh", "" -> "", "a" -> "a".
// 3. Loop the cases with t.Run(tt.name, ...) as a subtest.
// 4. Inside, if Reverse(tt.input) != tt.want, call t.Errorf.
//
// ✅ Expected: go test -v shows one PASS subtest per case, e.g.
// --- PASS: TestReverse/hello (0.00s)
// --- PASS: TestReverse/empty (0.00s)
// your code herego test -v and confirm a PASS subtest per case.🎉 Lesson Complete!
- ✅ Tests live in
_test.gofiles; functions arefunc TestXxx(t *testing.T) - ✅
t.Errorffails but continues;t.Fatalffails and stops the test - ✅ Table-driven tests describe cases as data and run each with
t.Runsubtests - ✅ Drive runs with
go test,-v,-run, and-cover - ✅ Benchmarks use
Benchmark+b.Nand run with-bench - ✅ Compare slices and maps with
reflect.DeepEqual, never== - ✅ You've finished the Go course — you can now build, test, and ship production Go. Next, explore Go modules, database access, and Docker deployment.
Sign up for free to track which lessons you've completed and get learning reminders.