Day 14: Testing
Core Concept: Testing Is Built In
Go’s testing framework is part of the standard library — no third-party runner required.
go test ./... # run all tests
go test -v ./... # verbose output
go test -run TestFoo ./... # run tests matching "TestFoo"
go test -count=1 ./... # disable test caching
go test -race ./... # race detector
Test File Layout
day-13/
├── calc.go
└── calc_test.go // same package: package main (or package calc)
Tests live in _test.go files. The test binary is built separately from the main binary.
Basic Test
import "testing"
func TestAdd(t *testing.T) {
got := add(2, 3)
want := 5
if got != want {
t.Errorf("add(2,3) = %d; want %d", got, want)
}
}
Table-Driven Tests (Idiomatic Go)
func TestDivide(t *testing.T) {
cases := []struct {
name string
a, b float64
want float64
wantErr bool
}{
{"positive", 10, 2, 5, false},
{"negative divisor", -10, 2, -5, false},
{"divide by zero", 1, 0, 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := divide(tc.a, tc.b)
if (err != nil) != tc.wantErr {
t.Fatalf("unexpected error: %v", err)
}
if !tc.wantErr && got != tc.want {
t.Errorf("got %v; want %v", got, tc.want)
}
})
}
}
t.Run creates subtests — run a single subtest with -run TestDivide/divide_by_zero.
Benchmarks
func BenchmarkWordCount(b *testing.B) {
text := strings.Repeat("the quick brown fox ", 1000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
wordCount(text)
}
}
go test -bench=. -benchmem ./...
b.N is set by the framework to run long enough for a stable measurement. -benchmem shows allocations per operation.
testify (External)
github.com/stretchr/testify provides cleaner assertions:
import "github.com/stretchr/testify/assert"
assert.Equal(t, want, got)
assert.NoError(t, err)
assert.ErrorIs(t, err, ErrNotFound)
Test Helpers
func TestMain(m *testing.M) {
// global setup
os.Exit(m.Run())
}
Labs
Lab 1: First Test — Table-Driven TestAdd
What you’ll practise: Writing your first test function with multiple table cases.
Task:
Create calc.go with an add(a, b int) int function, then write a table-driven test in calc_test.go covering positive numbers, negative numbers, zero, and a large value close to math.MaxInt32.
Steps:
- Create
calc.gowith theaddfunction - Create
calc_test.gowith acasesslice of anonymous structs - Loop over
casesusingt.Run— but for this lab use a plainifcheck without subtests - Run
go test -v ./...and observe the PASS/FAIL output
// calc_test.go
func TestAdd(t *testing.T) {
cases := []struct {
name string
a, b int
want int
}{
{"positive", 2, 3, 5},
{"negative", -4, -6, -10},
{"zero", 0, 0, 0},
{"large", math.MaxInt32, 1, math.MaxInt32 + 1},
}
for _, tc := range cases {
got := add(tc.a, tc.b)
if got != tc.want {
t.Errorf("%s: add(%d, %d) = %d; want %d", tc.name, tc.a, tc.b, got, tc.want)
}
}
}
Expected output:
--- PASS: TestAdd (0.00s)
ok day-13 0.001s
Checkpoint: go test -v ./... exits 0 with all cases passing.
Lab 2: Subtests — Isolate Each Case with t.Run
What you’ll practise: Using t.Run to create named subtests and filter them from the command line.
Task:
Refactor the TestAdd from Lab 1 so each case runs as a subtest via t.Run(tc.name, ...). Then use the -run flag to execute only one subtest.
Steps:
- Wrap the inner
ifcheck int.Run(tc.name, func(t *testing.T) { ... }) - Run all subtests:
go test -v -run TestAdd ./... - Run only the negative case:
go test -v -run TestAdd/negative ./... - Intentionally break one case and observe the isolated failure output
for _, tc := range cases {
tc := tc // capture range variable
t.Run(tc.name, func(t *testing.T) {
got := add(tc.a, tc.b)
if got != tc.want {
t.Errorf("add(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.want)
}
})
}
Expected output:
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/positive (0.00s)
--- PASS: TestAdd/negative (0.00s)
--- PASS: TestAdd/zero (0.00s)
--- PASS: TestAdd/large (0.00s)
Checkpoint: go test -v -run TestAdd/negative ./... runs exactly one subtest.
Lab 3: Test Helpers — assertEq with t.Helper
What you’ll practise: Writing a reusable generic test helper that reports the correct line number.
Task:
Write a generic assertEq[T comparable] helper function. Use it in at least two different test functions. Observe how t.Helper() makes failures point to the call site, not the helper body.
Steps:
- Write the helper in
calc_test.go(or atestutil_test.gofile) - Remove
t.Helper()first — run a failing test and note the line reported - Add
t.Helper()back — observe the line number now points to the caller - Use
assertEqin bothTestAddand a newTestMultiplyfunction
func assertEq[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Errorf("got %v; want %v", got, want)
}
}
func TestMultiply(t *testing.T) {
assertEq(t, multiply(3, 4), 12)
assertEq(t, multiply(-2, 5), -10)
assertEq(t, multiply(0, 99), 0)
}
Expected output:
--- PASS: TestMultiply (0.00s)
Checkpoint: A deliberately wrong expected value shows the line in TestMultiply, not inside assertEq.
Lab 4: Testify — Cleaner Assertions
What you’ll practise: Using github.com/stretchr/testify assert and require packages.
Task:
Rewrite Lab 1’s TestAdd using assert.Equal. Add a divide(a, b float64) (float64, error) function, then write a test that uses assert.Error and require.NoError to distinguish expected vs unexpected errors.
Steps:
go get github.com/stretchr/testify(already in this module’s go.mod)- Import
"github.com/stretchr/testify/assert"and"github.com/stretchr/testify/require" - Rewrite
TestAddusingassert.Equal(t, want, got) - Write
TestDividewith a zero-divisor case usingassert.Errorand a normal case usingrequire.NoErrorthenassert.Equal
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDivide(t *testing.T) {
t.Run("normal", func(t *testing.T) {
got, err := divide(10, 2)
require.NoError(t, err)
assert.Equal(t, 5.0, got)
})
t.Run("by zero", func(t *testing.T) {
_, err := divide(1, 0)
assert.Error(t, err)
})
}
Expected output:
--- PASS: TestDivide (0.00s)
--- PASS: TestDivide/normal (0.00s)
--- PASS: TestDivide/by_zero (0.00s)
Checkpoint: go test -v ./... passes. Note how testify’s failure messages are more descriptive than manual t.Errorf.
Lab 5: Benchmarks — BenchmarkWordFrequency
What you’ll practise: Writing a benchmark with b.ResetTimer and reading -benchmem output.
Task:
Write a wordFrequency(s string) map[string]int function and benchmark it against a 1 000-word string. Use b.ResetTimer to exclude setup time. Run with -benchmem and note the allocations per operation.
Steps:
- Implement
wordFrequencyusingstrings.Fields - Write
BenchmarkWordFrequencythat builds the test string once beforeb.ResetTimer() - Run:
go test -bench=BenchmarkWordFrequency -benchmem ./... - Try an alternative implementation (e.g. using
bufio.Scanner) and compare the ns/op figures
func BenchmarkWordFrequency(b *testing.B) {
text := strings.Repeat("the quick brown fox jumps over the lazy dog ", 100)
b.ResetTimer()
for i := 0; i < b.N; i++ {
wordFrequency(text)
}
}
Expected output:
BenchmarkWordFrequency-8 50000 24000 ns/op 8192 B/op 1 allocs/op
(exact numbers vary by machine)
Checkpoint: The benchmark runs without error and reports ns/op and B/op columns.
Lab 6: Test Coverage — Find the Gaps
What you’ll practise: Generating a coverage profile and reading the HTML report.
Task: Run coverage analysis on your day-13 package, generate an HTML report, and find at least one uncovered branch. Add a test to cover it.
Steps:
go test -coverprofile=coverage.out ./...go tool cover -func=coverage.outto see per-function percentagesgo tool cover -html=coverage.outto open the browser report (green = covered, red = not)- Add a test case for an uncovered branch and confirm coverage improves
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
# Look for lines showing < 100%
go tool cover -html=coverage.out
Expected output:
day-13/calc.go:5: add 100.0%
day-13/calc.go:9: divide 75.0%
total: (statements) 87.5%
Checkpoint: Total coverage reaches 90 %+ after adding the missing test case.
Lab 7: Fuzz Test — FuzzIsPalindrome
What you’ll practise: Writing a fuzz target with a seed corpus and running the fuzzer.
Task:
Implement isPalindrome(s string) bool. Write FuzzIsPalindrome with at least three seed inputs. Run the fuzzer for 10 seconds and observe whether it finds a panic.
Steps:
- Implement
isPalindrome(naive: comparestostrings.Reverse(s), or index-based) - Write
FuzzIsPalindromein a_test.gofile - Run:
go test -fuzz=FuzzIsPalindrome -fuzztime=10s ./... - If the fuzzer finds a failure, fix the bug and re-run
func FuzzIsPalindrome(f *testing.F) {
// Seed corpus
f.Add("racecar")
f.Add("hello")
f.Add("")
f.Add("A")
f.Fuzz(func(t *testing.T, s string) {
// Property: isPalindrome must not panic on any input
_ = isPalindrome(s)
})
}
Expected output:
fuzz: elapsed: 10s, gathering baseline coverage: 0/4 completed
fuzz: elapsed: 10s, execs: 38291 (3829/sec), new interesting: 8 (total: 12)
ok day-13 10.003s
Checkpoint: The fuzzer runs for the full 10 seconds without finding a crasher (or you fix any crash it finds).
Final Lab (Project): Full Test Suite
What you’ll practise: Combining all testing techniques — table-driven tests, subtests, helpers, testify, benchmarks, coverage, and fuzzing — into a production-quality test suite.
Task: Write comprehensive tests for the functions built across Days 02–12.
Steps:
- Table-driven tests for the CLI calculator (Day 02)
- Table-driven tests for the word frequency counter (Day 06)
- Subtests for the shape library (Day 08)
- Tests for generic Stack and Queue (Day 10)
- At least one benchmark with
-benchmem - Achieve > 85 % coverage (
go test -cover ./...) - Add a fuzz test for any string-processing function
// Example: shape subtests
func TestShapeArea(t *testing.T) {
shapes := []struct {
name string
shape Shape
want float64
}{
{"circle r=1", Circle{1}, math.Pi},
{"rect 3x4", Rect{3, 4}, 12},
}
for _, tc := range shapes {
t.Run(tc.name, func(t *testing.T) {
assert.InDelta(t, tc.want, tc.shape.Area(), 1e-9)
})
}
}
Expected output:
ok day-13 0.45s coverage: 87.3% of statements
Checkpoint: go test -v -race -cover ./... passes with coverage above 85 % and no race conditions.
Day Project: Full Test Suite
Write comprehensive tests for the functions built in Days 02–12:
- Table-driven tests for the CLI calculator from Day 02
- Table-driven tests for the word frequency counter from Day 06
- Subtests for the shape library from Day 08
- Tests for generic Stack and Queue from Day 10
- At least one benchmark
Extension ideas: add a fuzzing test with go test -fuzz; measure coverage with go test -cover.
Official Documentation
testing— T, B, M types; Run, Errorf, Fatalf, ResetTimer, and morestrings—strings.Repeatused in benchmarksos—os.Exitin TestMain- Go Blog: Table driven tests — subtests and table-driven patterns
- Go Blog: The cover story — test coverage tooling
- Go Blog: Fuzzing — fuzzing in Go 1.18+
- Effective Go: Testing — idiomatic test patterns