1. The testing Package Basics
A test is a function named TestXxx, taking a *testing.T,
living in a file named xxx_test.go — go test discovers and
runs it with zero configuration.
// math.go
package mathutil
func Add(a, b int) int { return a + b }
package mathutil
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)
}
}
go test ./...
t.Errorf records a failure and continues the test function;
t.Fatalf records a failure and stops immediately — use the latter once
a failed check makes the rest of the test meaningless to continue (a nil pointer
you're about to dereference, for instance).
2. Table-Driven Tests
The idiomatic Go pattern for testing several cases of the same behavior is a slice
of structs — a "table" — looped over with t.Run for named subtests,
rather than a separate TestXxx function per case.
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"two positives", 2, 3, 5},
{"negative and positive", -1, 1, 0},
{"both zero", 0, 0, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
Adding a new case is now a one-line addition to the table, not a new function — and
go test -run TestAdd/negative can target a single named subtest
directly, which becomes genuinely useful once a table grows to dozens of cases.
3. Mocking with Interfaces & Coverage
Because Go interfaces are satisfied implicitly (Week 3), testing code that depends on an interface is naturally mockable — write a fake implementation for tests, with no mocking framework required.
type UserStore interface {
GetUser(id int) (*User, error)
}
type fakeStore struct {
users map[int]*User
}
func (f *fakeStore) GetUser(id int) (*User, error) {
u, ok := f.users[id]
if !ok {
return nil, errors.New("not found")
}
return u, nil
}
func TestGreetUser(t *testing.T) {
store := &fakeStore{users: map[int]*User{1: {Name: "Asha"}}}
got := GreetUser(store, 1) // GreetUser accepts a UserStore, not a concrete type
want := "Hi, Asha"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
go test -cover ./...
go tool cover -html=coverage.out # after: go test -coverprofile=coverage.out
This only works because GreetUser was written to accept the
UserStore interface rather than a concrete
*PostgresStore — a direct payoff of Week 3's advice to depend on small
interfaces rather than concrete types wherever a dependency crosses a boundary.
4. Benchmarks with testing.B
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
go test -bench=. -benchmem
BenchmarkAdd-8 1000000000 0.25 ns/op 0 B/op 0 allocs/op
b.N is chosen automatically by the testing framework, run repeatedly
until timing stabilizes — you never set it yourself. The output above reads as: on
an 8-core machine, each call took about 0.25 nanoseconds and made zero heap
allocations. That last figure — allocations per operation — is often the more
actionable number in practice, and is exactly what Week 15's profiling work builds
on.
5. Hands-on Exercise
Add real test coverage to last week's restructured module
Bring table-driven tests, a mock, and a benchmark to the project from Week 8's exercise.
Requirements:
- A table-driven test covering at least 4 cases (including at least one edge case) for a function in your
internal/package. - An interface for at least one dependency your code has (a data store, a clock, anything not trivial to call directly in a test) and a fake implementation of it used only in tests.
- Run
go test -cover ./...and note the coverage percentage in a comment; add one more test case specifically to raise coverage on a branch you find untested. - A benchmark for the function you find most likely to matter for performance, run with
go test -bench=. -benchmem. - A short comment interpreting the benchmark's ns/op and allocs/op output in plain language.
If go tool cover -html shows a branch as uncovered that you're sure your tests exercise, double check you actually generated a fresh coverage.out with -coverprofile before opening it — it's easy to view a stale coverage file left over from an earlier run and wrongly conclude a since-fixed gap still exists.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does the table-driven pattern scale better than writing a separate TestXxx function for each test case?
Why does the table-driven pattern scale better than writing a separate TestXxx function for each test case?
Adding a new case becomes a one-line addition to the table rather than a new function with duplicated setup and assertion logic — the actual test logic lives in exactly one place, shared by every case, which both reduces duplication and makes it much cheaper to add coverage for a newly-discovered edge case.
Q2
What specifically makes GreetUser testable with a fake store, without any mocking library?
What specifically makes GreetUser testable with a fake store, without any mocking library?
GreetUser was written to accept the UserStore interface rather than a concrete database type — and because Go interfaces are satisfied implicitly, any type with a matching GetUser method (including a trivial in-memory fakeStore built just for tests) can stand in for the real dependency with zero special tooling.
Q3
Why is allocs/op often considered a more actionable benchmark number than raw ns/op?
Why is allocs/op often considered a more actionable benchmark number than raw ns/op?
Nanoseconds per operation can be affected by machine-specific noise and is a single aggregate number, while allocations per operation points directly at a specific, fixable cause of slowness — unnecessary heap allocation — that's usually traceable to a specific line of code and directly connects to the escape-analysis and GC concepts from Week 5, which is exactly what Week 15's profiling work targets.
Q4
Why does go test require zero configuration to discover and run a test function, unlike many other languages' testing setups?
Why does go test require zero configuration to discover and run a test function, unlike many other languages' testing setups?
Discovery is built directly into the Go toolchain by a simple naming convention — any function named TestXxx taking a *testing.T, in a file ending in _test.go, is automatically found and run by go test. There's no separate test runner to install or configuration file to write; the convention alone is the entire mechanism.