Theory: Go's Testing Philosophy
Why Go Testing Matters
The Problem: Code without tests is a guess. Refactoring without tests is a gamble.
The Solution: Go ships testing in the standard library: `go test`, table-driven tests, benchmarks, fuzzing, and race detection — no external framework needed.
Real Impact: Table-driven tests + `go test -race` catch a huge fraction of bugs that would otherwise surface only in production.
Real-World Analogy
Think of go test as the official inspector for every release:
- testing.T = the inspector's clipboard — pass/fail goes here
- Table-driven = running the same checklist against many sample units
- Benchmark = timed lap around the test track
- Race detector = the inspector listening for two technicians using the same wrench
- Fuzz test = throwing random parts at the machine to see what breaks
Go has a built-in testing framework that emphasizes simplicity and convention over configuration. Tests are first-class citizens in Go, with tooling integrated into the standard library.
Testing Principles in Go
Go's testing philosophy follows these core principles:
- Simplicity: Tests are just Go code in files ending with _test.go
- Convention: Test functions start with Test, benchmarks with Benchmark
- Integration: Testing is built into the go command
- Fast Feedback: Tests should run quickly and frequently
- Table-Driven: Preferred pattern for testing multiple cases
Test Pyramid
Testing Conventions
| Convention | Pattern | Example | Purpose |
|---|---|---|---|
| Test Files | *_test.go | math_test.go | Contains test functions |
| Test Functions | Test*(t *testing.T) | TestAdd(t *testing.T) | Unit tests |
| Benchmarks | Benchmark*(b *testing.B) | BenchmarkSort(b *testing.B) | Performance tests |
| Examples | Example*() | ExampleAdd() | Documentation tests |
| Test Package | package_test | math_test | Black-box testing |
Unit Testing Fundamentals
Unit tests verify individual functions and methods in isolation. Go's testing package provides everything needed for comprehensive unit testing.
Basic Test Structure
// math.go
package math
import "errors"
func Add(a, b int) int {
return a + b
}
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
// math_test.go
package math
import (
"testing"
"math"
)
func TestAdd(t *testing.T) {
result := Add(2, 3)
expected := 5
if result != expected {
t.Errorf("Add(2, 3) = %d; want %d", result, expected)
}
}
func TestDivide(t *testing.T) {
// Test successful division
result, err := Divide(10, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if math.Abs(result-5.0) > 0.0001 {
t.Errorf("Divide(10, 2) = %.2f; want 5.0", result)
}
// Test division by zero
_, err = Divide(10, 0)
if err == nil {
t.Error("expected error for division by zero")
}
}
Testing Helper Methods
| Method | Purpose | Continues? | Example |
|---|---|---|---|
t.Error() |
Report error | Yes | t.Error("failed") |
t.Errorf() |
Report formatted error | Yes | t.Errorf("got %d", v) |
t.Fatal() |
Report and stop | No | t.Fatal("critical") |
t.Fatalf() |
Report formatted and stop | No | t.Fatalf("got %d", v) |
t.Log() |
Log information | Yes | t.Log("debug info") |
t.Skip() |
Skip test | No | t.Skip("not ready") |
Table-Driven Tests
Table-driven tests are Go's idiomatic way to test multiple scenarios efficiently. They reduce code duplication and make tests more maintainable.
func TestCalculateDiscount(t *testing.T) {
tests := []struct {
name string
amount float64
customerType string
expected float64
wantErr bool
}{
{
name: "regular customer small amount",
amount: 100,
customerType: "regular",
expected: 95,
wantErr: false,
},
{
name: "premium customer large amount",
amount: 1000,
customerType: "premium",
expected: 850,
wantErr: false,
},
{
name: "invalid customer type",
amount: 100,
customerType: "invalid",
expected: 0,
wantErr: true,
},
{
name: "negative amount",
amount: -100,
customerType: "regular",
expected: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := CalculateDiscount(tt.amount, tt.customerType)
if (err != nil) != tt.wantErr {
t.Errorf("CalculateDiscount() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && math.Abs(result-tt.expected) > 0.01 {
t.Errorf("CalculateDiscount() = %v, want %v", result, tt.expected)
}
})
}
}
// Using map for named test cases
func TestParseURL(t *testing.T) {
tests := map[string]struct {
input string
wantHost string
wantPath string
wantErr bool
}{
"valid http URL": {
input: "http://example.com/path",
wantHost: "example.com",
wantPath: "/path",
wantErr: false,
},
"valid https URL": {
input: "https://api.example.com/v1/users",
wantHost: "api.example.com",
wantPath: "/v1/users",
wantErr: false,
},
"invalid URL": {
input: "not a url",
wantHost: "",
wantPath: "",
wantErr: true,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
host, path, err := ParseURL(tc.input)
if tc.wantErr {
if err == nil {
t.Fatal("expected error but got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != tc.wantHost || path != tc.wantPath {
t.Errorf("ParseURL(%q) = (%q, %q), want (%q, %q)",
tc.input, host, path, tc.wantHost, tc.wantPath)
}
})
}
}
Benchmarking
Benchmarks measure code performance and help identify bottlenecks. Go's benchmarking framework provides accurate, reproducible performance measurements.
Benchmark Basics
- Function name starts with Benchmark
- Takes *testing.B parameter
- Runs b.N iterations
- Reports ns/op, allocs, B/op
- Use b.ResetTimer() after setup
Running Benchmarks
go test -bench=.-benchmemfor memory stats-benchtime=10sfor duration-cpuprofilefor profiling-count=5for multiple runs
func BenchmarkStringConcat(b *testing.B) {
strings := []string{"Hello", " ", "World", "!"}
b.ResetTimer() // Reset after setup
for i := 0; i < b.N; i++ {
result := ""
for _, s := range strings {
result += s
}
}
}
func BenchmarkStringBuilder(b *testing.B) {
strings := []string{"Hello", " ", "World", "!"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
var builder strings.Builder
for _, s := range strings {
builder.WriteString(s)
}
_ = builder.String()
}
}
// Sub-benchmarks with different inputs
func BenchmarkSort(b *testing.B) {
sizes := []int{10, 100, 1000, 10000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size-%d", size), func(b *testing.B) {
// Create test data
data := make([]int, size)
for i := range data {
data[i] = rand.Intn(1000)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
tmp := make([]int, len(data))
copy(tmp, data)
sort.Ints(tmp)
}
})
}
}
// Parallel benchmark
func BenchmarkConcurrentMap(b *testing.B) {
m := &sync.Map{}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
key := rand.Intn(100)
m.Store(key, key*2)
m.Load(key)
}
})
}
Benchmark Output
BenchmarkStringConcat-8 5000000 234 ns/op 16 B/op 4 allocs/op BenchmarkStringBuilder-8 20000000 67 ns/op 24 B/op 1 allocs/op BenchmarkSort/size-10-8 10000000 142 ns/op 0 B/op 0 allocs/op BenchmarkSort/size-100-8 1000000 2145 ns/op 0 B/op 0 allocs/op BenchmarkSort/size-1000-8 50000 31254 ns/op 0 B/op 0 allocs/op BenchmarkSort/size-10000-8 3000 428936 ns/op 0 B/op 0 allocs/op
Mocking and Test Doubles
Mocking allows testing components in isolation by replacing dependencies with test doubles. Go's interface-based design makes mocking straightforward.
// Define interface for external dependency
type EmailSender interface {
Send(to, subject, body string) error
}
// Real implementation
type SMTPEmailSender struct {
host string
port int
auth smtp.Auth
}
func (s *SMTPEmailSender) Send(to, subject, body string) error {
// Real SMTP implementation
return smtp.SendMail(
fmt.Sprintf("%s:%d", s.host, s.port),
s.auth,
"noreply@example.com",
[]string{to},
[]byte(fmt.Sprintf("Subject: %s\n\n%s", subject, body)),
)
}
// Mock implementation for testing
type MockEmailSender struct {
SentEmails []Email
SendFunc func(to, subject, body string) error
}
type Email struct {
To string
Subject string
Body string
}
func (m *MockEmailSender) Send(to, subject, body string) error {
m.SentEmails = append(m.SentEmails, Email{
To: to,
Subject: subject,
Body: body,
})
if m.SendFunc != nil {
return m.SendFunc(to, subject, body)
}
return nil
}
// Service that uses the interface
type UserService struct {
db Database
emailSender EmailSender
logger Logger
}
func (s *UserService) RegisterUser(email, password string) error {
// Validate input
if !isValidEmail(email) {
return errors.New("invalid email")
}
// Create user in database
user := &User{
Email: email,
Password: hashPassword(password),
}
if err := s.db.CreateUser(user); err != nil {
s.logger.Error("failed to create user", err)
return err
}
// Send welcome email
if err := s.emailSender.Send(
email,
"Welcome!",
"Thanks for registering",
); err != nil {
s.logger.Warn("failed to send welcome email", err)
// Don't fail registration if email fails
}
return nil
}
// Test using mocks
func TestUserRegistration(t *testing.T) {
// Setup mocks
mockEmail := &MockEmailSender{}
mockDB := &MockDatabase{}
mockLogger := &MockLogger{}
service := &UserService{
db: mockDB,
emailSender: mockEmail,
logger: mockLogger,
}
// Test successful registration
err := service.RegisterUser("test@example.com", "password123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify email was sent
if len(mockEmail.SentEmails) != 1 {
t.Errorf("expected 1 email, got %d", len(mockEmail.SentEmails))
}
if mockEmail.SentEmails[0].To != "test@example.com" {
t.Errorf("email sent to wrong address: %s", mockEmail.SentEmails[0].To)
}
// Test with email failure
mockEmail.SendFunc = func(to, subject, body string) error {
return errors.New("SMTP error")
}
err = service.RegisterUser("test2@example.com", "password123")
if err != nil {
t.Error("registration should succeed even if email fails")
}
}
Test Coverage
Test coverage measures how much of your code is executed during tests. While 100% coverage doesn't guarantee bug-free code, it helps identify untested paths.
# Run tests with coverage $ go test -cover PASS coverage: 78.5% of statements # Generate coverage profile $ go test -coverprofile=coverage.out # View coverage in terminal $ go tool cover -func=coverage.out math.go:5: Add 100.0% math.go:9: Subtract 100.0% math.go:13: Multiply 100.0% math.go:17: Divide 85.7% total: (statements) 92.9% # Generate HTML report $ go tool cover -html=coverage.out -o coverage.html # Coverage with specific packages $ go test -coverpkg=./... ./... # Coverage modes $ go test -covermode=count # Shows execution count $ go test -covermode=set # Shows covered/not covered $ go test -covermode=atomic # Thread-safe count
Example Tests for Documentation
// Example tests serve as documentation and are verified
func ExampleAdd() {
result := Add(2, 3)
fmt.Println(result)
// Output: 5
}
func ExampleCalculator_Calculate() {
calc := NewCalculator()
result, err := calc.Calculate("2 + 3 * 4")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Result: %.2f\n", result)
// Output: Result: 14.00
}
// Example with unordered output
func ExamplePermutations() {
perms := Permutations([]int{1, 2})
for _, p := range perms {
fmt.Println(p)
}
// Unordered output:
// [1 2]
// [2 1]
}
️ Test Organization and Patterns
Test Helpers and Utilities
// Test helper functions
func assertEqual(t testing.TB, got, want interface{}) {
t.Helper() // Mark as helper for better error reporting
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}
func assertNoError(t testing.TB, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func assertError(t testing.TB, err error) {
t.Helper()
if err == nil {
t.Fatal("expected error but got nil")
}
}
// Test fixtures
func setupTestDB(t testing.TB) (*sql.DB, func()) {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
}
// Run migrations
if err := migrate(db); err != nil {
t.Fatal(err)
}
// Return cleanup function
cleanup := func() {
db.Close()
}
return db, cleanup
}
// Parallel tests
func TestParallelOperations(t *testing.T) {
t.Parallel() // Run in parallel with other parallel tests
tests := []struct{
name string
input int
}{
{"test1", 1},
{"test2", 2},
{"test3", 3},
}
for _, tc := range tests {
tc := tc // Capture range variable
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Test logic here
})
}
}
// Integration tests with build tags
//go:build integration
// +build integration
package myapp_test
func TestDatabaseIntegration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
// Integration test logic
}
Testing Best Practices
Testing Guidelines
- Test behavior, not implementation: Focus on what, not how
- Use table-driven tests: Reduce duplication, improve readability
- Keep tests independent: Tests shouldn't depend on each other
- Use descriptive names: Test names should explain what they test
- Test edge cases: Empty, nil, negative, overflow conditions
- Mock external dependencies: Keep tests fast and deterministic
- Aim for high coverage: But don't obsess over 100%
- Run tests frequently: Integrate into CI/CD pipeline
Common Testing Pitfalls
- Testing implementation details: Makes refactoring difficult
- Ignoring error cases: Most bugs hide in error paths
- Global state in tests: Causes flaky, order-dependent tests
- Not using t.Helper(): Makes error messages less useful
- Overly complex mocks: Keep mocks simple and focused
- Ignoring race conditions: Use
go test -race - Not cleaning up resources: Use defer for cleanup
Testing Commands
| Command | Purpose |
|---|---|
go test |
Run tests in current package |
go test ./... |
Run all tests recursively |
go test -v |
Verbose output |
go test -run TestName |
Run specific test |
go test -short |
Skip long tests |
go test -race |
Detect race conditions |
go test -timeout 30s |
Set test timeout |
go test -count=1 |
Disable test caching |
️ Practice Exercises
Challenge 1: Calculator Test Suite
Create comprehensive tests for a calculator package:
- Unit tests for basic operations
- Table-driven tests for multiple scenarios
- Error handling for invalid inputs
- Benchmark different algorithms
Challenge 2: HTTP API Testing
Test a REST API with:
- httptest for server mocking
- Test different HTTP methods
- Validate response codes and bodies
- Mock external service calls
Challenge 3: Database Testing
Implement database tests with:
- In-memory test database
- Transaction rollback for isolation
- Test data fixtures
- Integration test suite
Challenge 4: Performance Optimization
Use benchmarks to optimize code:
- Write benchmarks for critical paths
- Compare algorithm implementations
- Profile CPU and memory usage
- Optimize based on results