Testing
Go has first-class testing support built into the standard library and toolchain. No third-party frameworks required. The table-driven test pattern, benchmarks, fuzz testing, and the race detector make Go's testing ecosystem world-class.
π― Learning Objectives
- Write unit tests using the
testingpackage - Use the table-driven test pattern
- Write benchmark tests and interpret results
- Run fuzz tests to discover edge cases automatically
- Measure and enforce test coverage
- Use subtests for parameterised, isolated tests
- Mock dependencies using interfaces
π Unit Tests
Test files must be named *_test.go. Test functions must be named Test* and accept *testing.T.
// math/math.go
package math
func Add(a, b int) int { return a + b }
func Divide(a, b float64) (float64, error) {
if b == 0 { return 0, fmt.Errorf("division by zero") }
return a / b, nil
}// math/math_test.go
package math // same package = white-box testing
import (
"testing"
"errors"
)
// Basic test
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 test β idiomatic Go testing pattern
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr bool
}{
{"normal division", 10, 2, 5, false},
{"divide by negative", 9, -3, -3, false},
{"divide zero", 0, 5, 0, false},
{"divide by zero", 10, 0, 0, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { // subtest β isolated, parallel-capable
got, err := Divide(tc.a, tc.b)
if (err != nil) != tc.wantErr {
t.Errorf("Divide(%v, %v) error = %v, wantErr %v", tc.a, tc.b, err, tc.wantErr)
return
}
if !tc.wantErr && got != tc.want {
t.Errorf("Divide(%v, %v) = %v, want %v", tc.a, tc.b, got, tc.want)
}
})
}
}
// t.Fatal stops the current test immediately (unlike t.Error which continues)
func TestAddFatal(t *testing.T) {
result := Add(2, 3)
if result == 0 {
t.Fatal("Add returned 0 β something very wrong, stopping test")
}
// further assertions that depend on result
t.Logf("Add returned %d as expected", result)
}# Run all tests
go test ./...
# Run with verbose output
go test -v ./...
# Run specific test
go test -run TestDivide ./math/
# Run specific subtest
go test -run TestDivide/divide_by_zero ./math/
# Run tests in parallel
go test -parallel 4 ./...β‘ Benchmarks
Benchmark functions are named Benchmark* and accept *testing.B. Run with go test -bench=..
// string_bench_test.go
package main
import (
"strings"
"testing"
)
// Benchmark string concatenation (slow β allocates on every +)
func BenchmarkStringConcat(b *testing.B) {
for i := 0; i < b.N; i++ { // b.N is tuned by the framework
s := ""
for j := 0; j < 100; j++ {
s += "x"
}
_ = s
}
}
// Benchmark strings.Builder (fast β single allocation)
func BenchmarkStringBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
for j := 0; j < 100; j++ {
sb.WriteByte('x')
}
_ = sb.String()
}
}
// Benchmark with setup outside the loop
func BenchmarkLargeSliceSum(b *testing.B) {
// Setup β not counted in benchmark time
data := make([]int, 1_000_000)
for i := range data { data[i] = i }
b.ResetTimer() // exclude setup from timing
for i := 0; i < b.N; i++ {
sum := 0
for _, v := range data {
sum += v
}
_ = sum
}
b.SetBytes(int64(len(data) * 8)) // report MB/s
}π² Fuzz Testing (Go 1.18+)
Fuzz tests automatically generate random inputs to discover edge cases and panics. Functions are named Fuzz* and accept *testing.F.
// fuzz_test.go
package main
import (
"strings"
"testing"
"unicode/utf8"
)
func reverseString(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
// FuzzReverseString: automatically generates inputs to find bugs
func FuzzReverseString(f *testing.F) {
// Seed corpus β known interesting inputs
f.Add("hello")
f.Add("")
f.Add("a")
f.Add("Hello, δΈη") // multi-byte UTF-8
f.Fuzz(func(t *testing.T, s string) {
// Property: reversing twice gives back the original
reversed := reverseString(s)
doubleReversed := reverseString(reversed)
if s != doubleReversed {
t.Errorf("double reverse of %q = %q, want %q", s, doubleReversed, s)
}
// Property: result should be valid UTF-8
if !utf8.ValidString(reversed) {
t.Errorf("reverse of %q is invalid UTF-8", s)
}
// Property: length in runes should be preserved
if utf8.RuneCountInString(s) != utf8.RuneCountInString(reversed) {
t.Errorf("rune count changed after reverse of %q", s)
}
_ = strings.Contains(s, "") // prevent compiler elimination
})
}
// Run: go test -fuzz=FuzzReverseString -fuzztime=30sπ Mocking with Interfaces
// production code
package service
import "fmt"
type EmailSender interface {
Send(to, subject, body string) error
}
type UserService struct {
email EmailSender
db UserRepository
}
type UserRepository interface {
GetUser(id int) (*User, error)
}
type User struct{ ID int; Email string }
func (s *UserService) NotifyUser(id int) error {
user, err := s.db.GetUser(id)
if err != nil {
return fmt.Errorf("NotifyUser: %w", err)
}
return s.email.Send(user.Email, "Hello!", "Welcome to our service")
}
// --- test file ---
// package service_test
// type mockEmailSender struct {
// sent []string
// err error
// }
//
// func (m *mockEmailSender) Send(to, subject, body string) error {
// m.sent = append(m.sent, to)
// return m.err
// }
//
// type mockUserRepo struct {
// user *User
// err error
// }
//
// func (m *mockUserRepo) GetUser(id int) (*User, error) {
// return m.user, m.err
// }
//
// func TestNotifyUser(t *testing.T) {
// mockEmail := &mockEmailSender{}
// mockDB := &mockUserRepo{user: &User{ID: 1, Email: "a@b.com"}}
//
// svc := &UserService{email: mockEmail, db: mockDB}
// err := svc.NotifyUser(1)
//
// if err != nil { t.Fatal(err) }
// if len(mockEmail.sent) != 1 { t.Errorf("expected 1 email, got %d", len(mockEmail.sent)) }
// if mockEmail.sent[0] != "a@b.com" { t.Errorf("wrong email recipient") }
// }π Test Coverage
# Show coverage percentage
go test -cover ./...
# Generate HTML coverage report
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
# Enforce minimum coverage in CI
go test -cover ./... | grep -E "coverage: [0-9.]+"
# Fail if coverage < 80%
go test -coverprofile=c.out ./... && go tool cover -func=c.out | \
awk '/^total:/{if ($3+0 < 80) { print "Coverage "$3" is below 80%"; exit 1 }}'π€ Interview Questions
Table-driven tests define test cases as a slice of structs, each containing inputs, expected outputs, and a description. A loop iterates over the cases and runs each as a subtest with t.Run(tc.name, ...).
Benefits: (1) DRY β shared test logic written once; (2) Easy to add new cases β just add a row to the table; (3) Subtests produce clear, per-case output; (4) Failed subtests are individually identifiable; (5) Can be run in parallel with t.Parallel() inside the subtest.
This is the officially recommended testing pattern by the Go team. Nearly every standard library test uses it.
In Go, you define an interface for the dependency and use that interface in your production code. In tests, you create a struct that implements the interface with controlled behaviour (a "mock" or "stub").
This works because Go uses implicit interface satisfaction β you don't need to declare that your mock implements the interface; it just needs the right methods.
The key principle is "Accept interfaces, return concrete types." When your functions accept interfaces instead of concrete types, testing becomes trivial β swap the real implementation with a test double.
For more complex mocking, libraries like github.com/stretchr/testify/mock and github.com/golang/mock/gomock can generate mocks from interfaces.
π§ͺ Quiz
t.Error() and t.Fatal()?π Cheat Sheet
file_test.gofunc TestXxx(t *testing.T)func BenchmarkXxx(b *testing.B)func FuzzXxx(f *testing.F)t.Run("name", func(t *testing.T){})t.Errorf("got %v want %v", got, want)t.Fatalf("...")t.Skip("reason")go test ./...go test -v -run TestXxxgo test -cover ./...go test -race ./...