Go Testing
Quick reference for writing effective Go tests. Each section summarizes the key rules — reference files provide full examples and edge cases.
Table-Driven Tests
The standard Go testing pattern. Use it for any function with multiple input/output combinations.
Basic Pattern
func TestParseAge(t *testing.T) {
tests := []struct {
name string
input string
want int
wantErr bool
}{
{name: "valid age", input: "25", want: 25},
{name: "zero", input: "0", want: 0},
{name: "negative", input: "-1", wantErr: true},
{name: "not a number", input: "abc", wantErr: true},
{name: "empty string", input: "", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseAge(tt.input)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("ParseAge(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
Rules
- •Name every test case — Use the
namefield andt.Run. Makes failures easy to identify. - •Cover edge cases — Empty inputs, zero values, boundary values, error conditions.
- •Parallel when safe — Add
t.Parallel()to subtests when they don't share mutable state. - •One concept per table — Don't mix unrelated test scenarios in the same table.
See references/table-driven-tests.md for parallel subtests, cleanup, golden files, and advanced patterns.
Test Organization
File Structure
Place tests in a _test.go file in the same package:
internal/service/ ├── user.go ├── user_test.go # Same package (white-box) └── user_export_test.go # _test package (black-box, optional)
Naming Conventions
- •Test files —
<source>_test.goin the same directory. - •Test functions —
Test<FunctionName>orTest<Type>_<Method>. - •Subtests — Descriptive names:
"valid email","empty input","duplicate key". - •Test helpers — Prefix with
testor put in atestutilpackage. Callt.Helper()in every test helper.
func newTestUser(t *testing.T, name string) *User {
t.Helper()
u, err := NewUser(name, "test@example.com")
if err != nil {
t.Fatalf("creating test user: %v", err)
}
return u
}
TestMain
Use TestMain for package-level setup/teardown (database connections, test servers):
func TestMain(m *testing.M) {
// Setup
db := setupTestDB()
defer db.Close()
// Run tests
os.Exit(m.Run())
}
Assertions
Standard Library (Preferred)
Go's testing package uses explicit comparisons. This keeps tests readable and avoids assertion library dependencies.
if got != want {
t.Errorf("Add(%d, %d) = %d, want %d", a, b, got, want)
}
When to Use testify
Use testify when it significantly improves readability, especially for:
- •Deep struct comparison:
assert.Equal(t, want, got) - •Slice/map comparison:
assert.ElementsMatch(t, want, got) - •Error checking:
require.NoError(t, err)(stops test on failure)
require vs assert
- •
require— Stops the test immediately on failure. Use for preconditions and setup steps. - •
assert— Records failure but continues. Use for the actual assertions when you want to see all failures.
func TestCreateUser(t *testing.T) {
// Preconditions — stop if these fail
db, err := setupTestDB(t)
require.NoError(t, err)
user, err := CreateUser(db, "alice")
require.NoError(t, err)
// Assertions — check all properties
assert.Equal(t, "alice", user.Name)
assert.NotEmpty(t, user.ID)
assert.WithinDuration(t, time.Now(), user.CreatedAt, time.Second)
}
Mocking
Interface-Based Mocks
Define small interfaces at the consumer site, then create test implementations:
// In production code
type UserStore interface {
FindByID(ctx context.Context, id string) (*User, error)
}
// In test code
type mockUserStore struct {
findByIDFn func(ctx context.Context, id string) (*User, error)
}
func (m *mockUserStore) FindByID(ctx context.Context, id string) (*User, error) {
return m.findByIDFn(ctx, id)
}
httptest
Use httptest.NewServer for HTTP client testing and httptest.NewRecorder for handler testing:
func TestGetUser(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/users/123", nil)
handler := NewHandler(mockStore)
handler.GetUser(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
}
Database Testing
- •Use a real test database when possible — mocking SQL gives false confidence.
- •Use transactions that roll back: start a transaction in setup, rollback in cleanup.
- •Use
testcontainers-gofor disposable Postgres instances in CI.
See references/mocking.md for mock generation, test doubles taxonomy, and database testing patterns.
Coverage Analysis
Running Coverage
# Basic coverage go test -cover ./... # Generate HTML report go test -coverprofile=coverage.out ./... go tool cover -html=coverage.out -o coverage.html # Coverage for specific package go test -cover ./internal/service/... # Show uncovered lines go tool cover -func=coverage.out
Coverage Targets
| Level | Range | Meaning |
|---|---|---|
| Good | 70–80% | Solid coverage for most projects |
| Excellent | 80–90% | Strong confidence in code correctness |
| Diminishing returns | 90%+ | Only pursue for critical paths |
What NOT to Test
- •Generated code (protobuf, gqlgen resolvers)
- •Trivial getters/setters
- •
main.gowiring code - •Third-party library internals
See references/coverage.md for CI integration, coverage gates, and per-package analysis.
Benchmarking
Basic Pattern
func BenchmarkParseAge(b *testing.B) {
for b.Loop() {
ParseAge("25")
}
}
Running Benchmarks
# Run all benchmarks go test -bench=. ./... # With memory allocation stats go test -bench=. -benchmem ./... # Specific benchmark go test -bench=BenchmarkParseAge -benchmem ./internal/parser/ # Compare results with benchstat go test -bench=. -count=10 ./... > old.txt # ... make changes ... go test -bench=. -count=10 ./... > new.txt benchstat old.txt new.txt
Rules
- •Always use
-benchmem— Allocation counts matter as much as speed. - •Run multiple times — Use
-count=10for reliable results. Single runs are noisy. - •Use
benchstat— Compare before/after with statistical confidence. - •Benchmark hot paths — Focus on code that runs frequently, not cold paths.
- •Reset timer for setup — Use
b.ResetTimer()after expensive setup that shouldn't be measured.
See references/benchmarking.md for memory benchmarks, sub-benchmarks, and benchstat workflow.
Integration Tests
Naming Convention
Use build tags or _integration_test.go suffix to separate from unit tests:
//go:build integration
package service_test
func TestUserService_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
// ...
}
Database Integration Tests
func TestUserRepository_Create(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
db := testutil.NewTestDB(t) // starts container, runs migrations
repo := NewUserRepository(db)
user, err := repo.Create(ctx, "alice", "alice@example.com")
require.NoError(t, err)
assert.NotEmpty(t, user.ID)
// Verify persisted
found, err := repo.FindByID(ctx, user.ID)
require.NoError(t, err)
assert.Equal(t, "alice", found.Name)
}
API Integration Tests
Test the full HTTP stack with httptest.NewServer:
func TestAPI_CreateUser(t *testing.T) {
srv := httptest.NewServer(setupRouter())
defer srv.Close()
resp, err := http.Post(srv.URL+"/users", "application/json",
strings.NewReader(`{"name":"alice"}`))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
}
Race Detection
Usage
# Always run tests with race detector go test -race ./... # Build with race detector for manual testing go build -race ./cmd/server
Common Race Conditions
- •Unsynchronized map access — Maps are not safe for concurrent use. Use
sync.RWMutexorsync.Map. - •Shared slice append — Multiple goroutines appending to the same slice. Pre-allocate or use a mutex.
- •Check-then-act — Reading a value, making a decision, then acting — the value may have changed.
- •Closure over loop variable — Pre Go 1.22: goroutines capturing loop variables. Fixed in Go 1.22+ but be aware when supporting older versions.
Rules
- •Run
-racein CI — Always. It's a hard requirement, not optional. - •Fix all race conditions — Zero tolerance. A race is a bug, even if tests pass without it.
- •Test concurrent code explicitly — Launch multiple goroutines in tests to stress concurrent paths.
Test Quality
FIRST Principles
- •Fast — Unit tests run in milliseconds. Full suite in under a minute.
- •Independent — Tests don't depend on execution order or shared mutable state.
- •Repeatable — Same result every time. No randomness, no external dependencies in unit tests.
- •Self-validating — Clear pass/fail. No manual checking of output.
- •Timely — Write tests alongside code, not as an afterthought.
Anti-Patterns
- •Testing implementation details — Assert on behavior, not internal method calls.
- •Flaky tests — Tests that pass/fail randomly. Fix immediately — they erode trust.
- •Slow tests — Unit tests taking seconds. Mock external dependencies or use
testing.Short(). - •Test interdependence — Tests that fail when run in a different order.
- •Excessive mocking — Mocking everything means you're testing mocks, not code.
- •No error path tests — Only testing the happy path. Error handling is where bugs hide.
Post-Change Verification
After writing or modifying tests, always run the full verification protocol from the go-writing-code skill:
make fmt && make lint && make vet && make build && make test
All 5 steps must pass. See go-writing-code skill for details.
Reference Files
| File | Description |
|---|---|
| references/table-driven-tests.md | Parallel subtests, cleanup, golden files, advanced patterns |
| references/mocking.md | Interface mocks, httptest, test doubles, database testing |
| references/coverage.md | Coverage commands, targets, CI integration, per-package analysis |
| references/benchmarking.md | Benchmark patterns, benchstat, memory benchmarks, sub-benchmarks |