Testing
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 1fiIntuition
Section titled “Intuition”Tests are contracts you write for yourself: Think of a test as a tiny specification: “given this input, I expect this output.” Table-driven tests are like a checklist — you lay out every scenario in one place and run through them systematically. Fuzzing is like shaking a vending machine at random angles to find the combination that makes it jam. Benchmarks are a stopwatch for your code — you don’t need them until someone complains about speed, but then they’re indispensable.
Why it matters: Go’s built-in testing framework removes all friction from writing tests. No external dependencies, no configuration files — just *_test.go files and go test. This means there’s no excuse not to test.
The key insight: Good tests are behavioral contracts — they test what the code does, not how it does it, so you can refactor freely without breaking the test suite.
Common 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.
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.
Cross-References
Section titled “Cross-References”- Interfaces: Interface-based mock design for test doubles.
- Error Handling: Error wrapping and inspection patterns used in test assertions.
- net/http: HTTP handler testing with httptest and middleware chains.