Testing | Go - Wyatt's Notes
Testing Fundamentals
Section titled “Testing Fundamentals”Go has a built-in testing framework. Test files are named *_test.go and the build system excludes them from production binaries. Test functions have the signature func TestXxx(t *testing.T).
package math
import "testing"
func TestAdd(t *testing.T) { result := Add(2, 3) if result != 5 { t.Errorf("Add(2, 3) = %d; want 5", result) }}Run tests:
go test ./... # all tests in all packagesgo test -v ./math # verbose outputgo test -run TestAdd # specific test by name (regex)go test -count=1 ./... # disable test cachinggo test -short ./... # skip long-running testsSubtests
Section titled “Subtests”t.Run creates named subtests within a test function. Each subtest gets its own t and can be run independently with -run:
func TestStack(t *testing.T) { t.Run("empty stack", func(t *testing.T) { s := NewStack() if !s.IsEmpty() { t.Error("new stack should be empty") } })
t.Run("push and pop", func(t *testing.T) { s := NewStack() s.Push(1) got := s.Pop() if got != 1 { t.Errorf("Pop() = %d; want 1", got) } })}Test Helpers
Section titled “Test Helpers”t.Helper() marks a function as a test helper. Failures report the calling line, not the helper:
func assertEqual[T comparable](../../../../../alevel/src/content/docs/computer-science/software-engineering/02-testing) { t.Helper() if got != want { t.Errorf("got %v, want %v", got, want) }}Table-Driven Tests
Section titled “Table-Driven Tests”The idiomatic Go testing pattern. Define a slice of test cases and iterate:
func TestIsPalindrome(t *testing.T) { tests := []struct { name string input string want bool }{ {"empty", "", true}, {"single char", "a", true}, {"even length", "abba", true}, {"odd length", "racecar", true}, {"not palindrome", "hello", false}, {"with spaces", "taco cat", true}, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := IsPalindrome(tt.input) if got != tt.want { t.Errorf("IsPalindrome(%q) = %v; want %v", tt.input, got, tt.want) } }) }}Assertions and Errors
Section titled “Assertions and Errors”t.Error vs t.Fatal
Section titled “t.Error vs t.Fatal”t.Error/t.Errorf: logs the failure and continues running the test.t.Fatal/t.Fatalf: logs the failure and stops the current test immediately.
Use Fatal when subsequent code would panic (e.g., a nil pointer). Use Error to collect all failures in a single run.
Custom Error Types
Section titled “Custom Error Types”type ValidationError struct { Field string Message string}
func (e *ValidationError) Error() string { return fmt.Sprintf("validation failed on %q: %s", e.Field, e.Message)}Test with errors.As:
func TestValidate(t *testing.T) { _, err := Validate(User{}) var ve *ValidationError if !errors.As(err, &ve) { t.Fatalf("expected ValidationError, got %T", err) } if ve.Field != "Name" { t.Errorf("Field = %q; want %q", ve.Field, "Name") }}Mocking and Interfaces
Section titled “Mocking and Interfaces”Interface-Based Testing
Section titled “Interface-Based Testing”Design code around interfaces to enable test doubles:
type Repository interface { GetByID(ctx context.Context, id string) (*User, error) Save(ctx context.Context, u *User) error}Manual Mocks
Section titled “Manual Mocks”The simplest approach — implement the interface inline:
type MockStore struct { GetFunc func(ctx context.Context, id string) (*User, error) SaveFunc func(ctx context.Context, u *User) error}
func (m *MockStore) GetByID(ctx context.Context, id string) (*User, error) { return m.GetFunc(ctx, id)}
func (m *MockStore) Save(ctx context.Context, u *User) error { return m.SaveFunc(ctx, u)}gomock (Code Generation)
Section titled “gomock (Code Generation)”ctrl := gomock.NewController(t)defer ctrl.Finish()
mockRepo := mock.NewMockRepository(ctrl)mockRepo.EXPECT().GetByID(gomock.Any(), "123").Return(&User{Name: "Alice"}, nil)Use go.uber.org/mock (the actively maintained successor to gomock).
testify/mock
Section titled “testify/mock”mockRepo := new(MockRepository)mockRepo.On("GetByID", mock.Anything, "123").Return(&User{Name: "Alice"}, nil)mockRepo.AssertExpectations(t)Benchmarking
Section titled “Benchmarking”Benchmark functions have the signature func BenchmarkXxx(b *testing.B):
func BenchmarkAdd(b *testing.B) { for i := 0; i < b.N; i++ { Add(2, 3) }}go test -bench=. -benchmemOutput:
BenchmarkAdd-8 1000000000 0.25 ns/op 0 B/op 0 allocs/opTimer Controls
Section titled “Timer Controls”func BenchmarkExpensiveSetup(b *testing.B) { b.StopTimer() expensiveSetup() b.StartTimer()
for i := 0; i < b.N; i++ { operation() }}Parallel Benchmarks
Section titled “Parallel Benchmarks”func BenchmarkParallel(b *testing.B) { b.RunParallel(func(pb *testing.PB) { for pb.Next() { process() } })}RunParallel distributes work across GOMAXPROCS goroutines.
Fuzzing
Section titled “Fuzzing”Go 1.18+ includes built-in fuzzing. Fuzz tests find inputs that trigger panics, assertion failures, or other violations:
func FuzzReverse(f *testing.F) { f.Add("hello") f.Add("racecar")
f.Fuzz(func(t *testing.T, s string) { reversed := Reverse(s) doubleReversed := Reverse(reversed) if s != doubleReversed { t.Errorf("Reverse(Reverse(%q)) = %q, want %q", s, doubleReversed, s) } })}go test -fuzz=FuzzReverse -fuzztime=30sWhen a crash is found, the failing input is saved to testdata/fuzz/FuzzReverse/ and will be replayed on subsequent test runs. The corpus grows over time, providing better coverage.
Supported fuzz types: string, []byte, int, int8, int16, int32, int64, uint, float32, float64, bool.
Integration Testing
Section titled “Integration Testing”TestMain
Section titled “TestMain”Use TestMain for one-time setup and teardown:
func TestMain(m *testing.M) { db := setupTestDB() defer db.Close() code := m.Run() os.Exit(code)}HTTP Handler Testing
Section titled “HTTP Handler Testing”func TestHealthHandler(t *testing.T) { req := httptest.NewRequest("GET", "/health", nil) w := httptest.NewRecorder()
HealthHandler(w, req)
if w.Code != http.StatusOK { t.Errorf("status = %d; want %d", w.Code, http.StatusOK) }}Test Containers
Section titled “Test Containers”For database integration tests using testcontainers-go:
func TestWithDatabase(t *testing.T) { ctx := context.Background() container, err := postgres.RunContainer(ctx, testcontainer.WithImage("postgres:16"), ) if err != nil { t.Fatal(err) } defer container.Terminate(ctx)
connStr, _ := container.ConnectionString(ctx) db, _ := sql.Open("postgres", connStr) defer db.Close()
// run tests against db}Coverage
Section titled “Coverage”go test -cover ./... # summarygo test -coverprofile=coverage.out ./... # generate profilego tool cover -func=coverage.out # per-function summarygo tool cover -html=coverage.out # HTML report in browserCI Coverage Enforcement
Section titled “CI Coverage Enforcement”go test -coverprofile=coverage.out ./...coverage=$(go tool cover -func=coverage.out | grep total | awk "{print $3}' | sed 's/%//')if [ "$coverage" -lt 80 ]; then echo "coverage below 80%: $coverage%" exit 1fiCommon Pitfalls
Section titled “Common Pitfalls”Testing implementation details. Test behaviour, not internals. If you use
reflectto inspect unexported fields in tests, reconsider the design.Not using table-driven tests. Copy-pasting test functions creates maintenance burden. Use table-driven tests for any function with multiple input/output cases.
Flaky tests from time or concurrency. Use
testing.Short()to skip slow tests. Use deterministic test doubles instead of real time or network in unit tests.Ignoring benchmark allocations. Always run benchmarks with
-benchmem. A single allocation in a hot path can dominate performance.Low coverage on error paths. Test both happy path and error paths. Use
go test -coverprofileto identify uncovered code.Modifying global state in tests. Tests that modify package-level variables can interfere with each other. Use
t.Parallel()where possible and isolate state per test.Over-mocking. Over-reliance on mocks produces tests that pass but code that fails in production. Prefer thin wrappers around real dependencies and integration tests for critical paths.
flowchart TD
A[Testing] --> B[Key Concepts]
A --> C[Core Principles]
A --> D[Practical Applications]
B --> E[Fundamental definitions]
C --> F[Design patterns]
D --> G[Real-world usage]Summary
Section titled “Summary”go test ./...runs all tests;*_test.gofiles are excluded from production builds.- Table-driven tests with
t.Runsubtests are the idiomatic Go pattern. t.Helper()marks helper functions for proper failure line reporting.t.Errorcontinues the test;t.Fatalstops it immediately.- Interface-based design enables manual mocks,
go.uber.org/mock, andtestify/mock. go test -bench=.with-benchmemreveals allocation hotspots.- Built-in fuzzing (
go test -fuzz) finds edge cases with coverage-guided input generation. httptestenables handler tests without starting a real server.go test -coverprofilegenerates profiles for HTML reports and CI enforcement.
Worked Examples
Section titled “Worked Examples”Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.
Intuition
Section titled “Intuition”Go’s testing framework is built into the toolchain — test files are just regular Go files with a _test.go suffix that the compiler excludes from production builds. Table-driven tests are the idiomatic pattern: define a slice of test cases and iterate with t.Run for named subtests. Interfaces enable mocking — design code around interfaces so tests can swap real implementations for fakes. Benchmarks measure performance by running code repeatedly until timing stabilizes. Fuzzing automatically generates random inputs to find edge cases that cause panics. The key principle is test behavior, not implementation details, so tests remain useful even when you refactor internal code.
Cross-References
Section titled “Cross-References”- Functions — first-class functions and closures
- Modules and Packages — dependency management
- Goroutines and Synchronization — testing concurrent code