Why Your Tests Are Probably Bad
Most Go tests I review are happy-path-only garbage. They test the basic case, pass, and give zero confidence that the code actually works in production.
Real testing means: testing edge cases, error conditions, concurrent access, and performance characteristics. Here's how to do it right.
Level 1: Unit Tests That Actually Matter
Here's a real example from a production service — a price calculator for an e-commerce platform:
package service
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCalculatePrice(t *testing.T) {
svc := NewPriceService()
tests := []struct {
name string
basePrice float64
quantity int
discount float64
tax float64
expected float64
}{
{"single item no discount", 100000, 1, 0, 11, 110000},
{"bulk with discount", 100000, 10, 10, 11, 990000},
{"zero quantity", 100000, 0, 0, 11, 0},
{"100% discount", 100000, 5, 100, 11, 0},
{"large order", 50000, 1000, 15, 11, 429250000},
{"fractional discount", 100000, 3, 7.5, 11, 321750},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := svc.CalculatePrice(tt.basePrice, tt.quantity, tt.discount, tt.tax)
assert.InDelta(t, tt.expected, result, 0.01, "price mismatch")
})
}
}
// Test edge cases that break things
func TestCalculatePrice_EdgeCases(t *testing.T) {
svc := NewPriceService()
t.Run("negative price", func(t *testing.T) {
result := svc.CalculatePrice(-100000, 1, 0, 11)
assert.Equal(t, 0.0, result, "negative price should return 0")
})
t.Run("very large quantity", func(t *testing.T) {
result := svc.CalculatePrice(100000, 1000000, 0, 11)
assert.True(t, result > 0, "should handle large quantities")
assert.True(t, result < 1e12, "should not overflow")
})
}
func TestCalculatePrice_ErrorConditions(t *testing.T) {
svc := NewPriceService()
t.Run("invalid tax rate", func(t *testing.T) {
assert.Panics(t, func() {
svc.CalculatePrice(100000, 1, 0, -5)
})
})
t.Run("discount over 100%", func(t *testing.T) {
assert.Panics(t, func() {
svc.CalculatePrice(100000, 1, 150, 11)
})
})
}
Level 2: Integration Tests
Test the real thing — database, external services, the whole stack:
func TestProductRepository_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
// Real database connection
db, err := sqlx.Connect("postgres", os.Getenv("TEST_DB_URL"))
if err != nil {
t.Fatalf("failed to connect to test database: %v", err)
}
defer db.Close()
// Clean state
db.MustExec("TRUNCATE products CASCADE")
repo := NewProductRepository(db)
ctx := context.Background()
// Create
product := &Product{Name: "Test Product", SKU: "TEST-001", Price: 100000}
err = repo.Create(ctx, product)
assert.NoError(t, err)
assert.NotEmpty(t, product.ID)
// Read
fetched, err := repo.GetByID(ctx, product.ID)
assert.NoError(t, err)
assert.Equal(t, product.Name, fetched.Name)
// List with filter
products, total, err := repo.List(ctx, ListParams{
TenantID: "test-tenant",
Search: "Test",
Limit: 10,
Offset: 0,
})
assert.NoError(t, err)
assert.Equal(t, 1, total)
assert.Len(t, products, 1)
// Update
fetched.Name = "Updated Product"
err = repo.Update(ctx, fetched)
assert.NoError(t, err)
updated, _ := repo.GetByID(ctx, product.ID)
assert.Equal(t, "Updated Product", updated.Name)
// Delete
err = repo.Delete(ctx, product.ID)
assert.NoError(t, err)
_, err = repo.GetByID(ctx, product.ID)
assert.Error(t, err)
}
Level 3: Benchmark Tests
Find performance bottlenecks before your users do:
func BenchmarkProductList(b *testing.B) {
db := setupBenchDB(b)
defer db.Close()
// Seed data
repo := NewProductRepository(db)
for i := 0; i < 1000; i++ {
repo.Create(context.Background(), &Product{
Name: fmt.Sprintf("Product %d", i),
SKU: fmt.Sprintf("SKU-%05d", i),
Price: float64(i * 1000),
})
}
b.ResetTimer()
b.Run("List-10", func(b *testing.B) {
for i := 0; i < b.N; i++ {
repo.List(context.Background(), ListParams{Limit: 10, Offset: 0})
}
})
b.Run("List-100", func(b *testing.B) {
for i := 0; i < b.N; i++ {
repo.List(context.Background(), ListParams{Limit: 100, Offset: 0})
}
})
b.Run("Search", func(b *testing.B) {
for i := 0; i < b.N; i++ {
repo.List(context.Background(), ListParams{Search: "Product 500", Limit: 10})
}
})
}
func BenchmarkCalculatePrice(b *testing.B) {
svc := NewPriceService()
b.ResetTimer()
for i := 0; i < b.N; i++ {
svc.CalculatePrice(100000, 10, 15, 11)
}
}
Running Tests Like a Pro
# Unit tests only (fast)
go test -short ./...
# All tests including integration
go test ./...
# With coverage report
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
# Benchmarks
go test -bench=. -benchmem ./...
# Race condition detection
go test -race ./...
# Verbose output
go test -v -count=1 ./...
The Test Pyramid for Go
- Unit tests (70%): Fast, isolated, test individual functions. Should run in < 1 second.
- Integration tests (20%): Test real database, API endpoints, external services. Need a test environment.
- E2E tests (10%): Full system tests. Slow but catch integration bugs. Run on CI only.
CI/CD Integration
Here's my GitHub Actions config for Go testing:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports: [5432:5432]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: '1.22' }
- run: go test -race -coverprofile=coverage.out ./...
- run: go test -bench=. -benchmem -run=^$ ./...
The Takeaway
Testing isn't overhead — it's insurance. Every hour you spend writing good tests saves you 5-10 hours of debugging production issues at 3 AM. Write tests that give you the confidence to deploy on Friday afternoon.