| name | go-testing-patterns |
| description | Reference knowledge base for the go-tests agent. Loaded by that agent on its first iteration when the host has not already injected it; not intended for direct invocation. |
Go Testing Patterns
A comprehensive guide to writing effective Go tests following community best practices and the Go testing philosophy. This document serves as the knowledge base for the go-tests pattern.
Table of Contents
- Testing Philosophy
- Table-Driven Tests
- Subtests
- Test Helpers
- Mocking Strategies
- Benchmarks
- Test Coverage
- Common Patterns
- What Not to Test
- Cobra CLI Testing
Testing Philosophy
Core Principles
| Principle | Description |
|---|
| Simple and readable | Tests should be easy to understand at a glance |
| Test behavior | Test what the code does, not how it does it |
| Avoid over-mocking | Only mock when truly necessary |
| Clarity over DRY | Prefer clarity to avoiding repetition in tests |
| One thing per test | Each test case should verify one behavior |
Simplicity Guidelines
DO:
- Write tests that are easy to understand at a glance
- Use inline test data rather than complex fixtures
- Keep test setup minimal
- Use simple assertions
- Test one thing per test case
DON'T:
- Create test helpers unless used in many places
- Build complex test hierarchies
- Over-parameterize tests
- Add unnecessary interfaces or abstractions
- Write tests for unlikely edge cases
- Use reflection or advanced features unnecessarily
Table-Driven Tests
Table-driven tests are idiomatic Go when you have multiple similar test cases.
Basic Structure
func TestParseURL(t *testing.T) {
tests := []struct {
name string
input string
want *URL
wantErr bool
}{
{
name: "valid http url",
input: "http://example.com",
want: &URL{Scheme: "http", Host: "example.com"},
},
{
name: "valid https url",
input: "https://example.com/path",
want: &URL{Scheme: "https", Host: "example.com", Path: "/path"},
},
{
name: "invalid url",
input: "://invalid",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseURL(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseURL() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParseURL() = %v, want %v", got, tt.want)
}
})
}
}
When to Use Table-Driven Tests
Good for:
- Functions with multiple input/output combinations
- Validation functions
- Parsing functions
- Mathematical operations
Not needed for:
- Single test case scenarios
- Complex setup/teardown requirements
- Tests with significantly different logic per case
Subtests
Basic Subtests
func TestUser(t *testing.T) {
t.Run("creation", func(t *testing.T) {
user := NewUser("test")
if user.Name != "test" {
t.Errorf("Name = %q, want %q", user.Name, "test")
}
})
t.Run("validation", func(t *testing.T) {
user := &User{}
if err := user.Validate(); err == nil {
t.Error("expected error for empty user")
}
})
}
Parallel Subtests
func TestParallel(t *testing.T) {
tests := []struct {
name string
input int
want int
}{
{"case1", 1, 2},
{"case2", 2, 4},
{"case3", 3, 6},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := Double(tt.input)
if got != tt.want {
t.Errorf("Double(%d) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
Test Helpers
When to Create Helpers
Only create helpers when:
- Used in multiple test files
- Significantly reduces test code complexity
- Makes tests more readable
Helper Pattern
func testHelper(t *testing.T) {
t.Helper()
}
func newTestUser(t *testing.T, name string) *User {
t.Helper()
user := &User{
ID: 1,
Name: name,
CreatedAt: time.Now(),
}
return user
}
Cleanup Pattern
func TestWithCleanup(t *testing.T) {
tempDir := t.TempDir()
file, err := os.CreateTemp("", "test")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
os.Remove(file.Name())
})
}
Mocking Strategies
Interface-Based Mocking
type UserRepository interface {
GetByID(ctx context.Context, id string) (*User, error)
}
type mockUserRepo struct {
users map[string]*User
err error
}
func (m *mockUserRepo) GetByID(ctx context.Context, id string) (*User, error) {
if m.err != nil {
return nil, m.err
}
return m.users[id], nil
}
func TestUserService(t *testing.T) {
repo := &mockUserRepo{
users: map[string]*User{"1": {ID: "1", Name: "Test"}},
}
service := NewUserService(repo)
user, err := service.GetUser(context.Background(), "1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != "Test" {
t.Errorf("Name = %q, want %q", user.Name, "Test")
}
}
When to Mock
Do mock:
- External services (databases, APIs)
- Time-dependent behavior
- Random/non-deterministic behavior
Don't mock:
- Simple value objects
- Standard library functions
- Internal implementation details
Benchmarks
Basic Benchmark
func BenchmarkParseURL(b *testing.B) {
input := "https://example.com/path?query=value"
for i := 0; i < b.N; i++ {
ParseURL(input)
}
}
Benchmark with Setup
func BenchmarkProcess(b *testing.B) {
data := generateTestData(1000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
Process(data)
}
}
Sub-Benchmarks
func BenchmarkCache(b *testing.B) {
sizes := []int{100, 1000, 10000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size-%d", size), func(b *testing.B) {
cache := NewCache(size)
b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Get("key")
}
})
}
}
Memory Benchmarks
func BenchmarkAlloc(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = make([]byte, 1024)
}
}
Test Coverage
Running Coverage
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
go test -cover ./...
Coverage Goals
| Type | Target |
|---|
| Unit tests | 70-80% |
| Critical paths | 90%+ |
| Edge cases | As needed |
What Coverage Doesn't Tell You
- Quality of assertions
- Whether edge cases are tested
- Whether tests are meaningful
- Whether error paths are properly tested
Common Patterns
Error Testing
func TestValidate_Errors(t *testing.T) {
tests := []struct {
name string
input *User
wantErr string
}{
{
name: "nil user",
input: nil,
wantErr: "user is nil",
},
{
name: "empty name",
input: &User{},
wantErr: "name is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Validate(tt.input)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error = %q, want to contain %q", err.Error(), tt.wantErr)
}
})
}
}
Context Testing
func TestWithContext(t *testing.T) {
t.Run("respects cancellation", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := DoWork(ctx)
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got %v", err)
}
})
t.Run("respects timeout", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
defer cancel()
err := SlowWork(ctx)
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("expected context.DeadlineExceeded, got %v", err)
}
})
}
HTTP Handler Testing
func TestHandler(t *testing.T) {
handler := NewHandler()
t.Run("GET returns user", func(t *testing.T) {
req := httptest.NewRequest("GET", "/users/1", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
var user User
if err := json.Unmarshal(w.Body.Bytes(), &user); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if user.ID != "1" {
t.Errorf("ID = %q, want %q", user.ID, "1")
}
})
}
What Not to Test
Skip Testing
- Trivial getters/setters without logic
- Third-party code (trust the library)
- Language features (Go's behavior)
- Unlikely scenarios that can't reasonably occur
Example: Don't Test This
func (u *User) Name() string {
return u.name
}
func (s *Service) GetUser(id string) (*User, error) {
return s.repo.GetByID(id)
}
Example: Do Test This
func (u *User) FullName() string {
if u.MiddleName != "" {
return fmt.Sprintf("%s %s %s", u.FirstName, u.MiddleName, u.LastName)
}
return fmt.Sprintf("%s %s", u.FirstName, u.LastName)
}
func (s *Service) CreateUser(u *User) error {
if err := u.Validate(); err != nil {
return fmt.Errorf("invalid user: %w", err)
}
return s.repo.Create(u)
}
Quick Reference
Test File Naming
foo.go → foo_test.go (strict 1:1 mapping)
foo_internal_test.go (package foo) for white-box tests needing
unexported access — the only meaningful suffix variant in Go
- Test functions:
TestXxx(t *testing.T)
- Benchmarks:
BenchmarkXxx(b *testing.B)
- Examples:
ExampleXxx()
To separate test types (unit, integration, coverage-boost), use:
- Build tags (
//go:build integration) to gate tests by category.
- Subtests (
t.Run("edge-case/empty-input", ...)) to group related
cases within a single _test.go file.
_internal_test.go (package foo) vs _test.go (package foo_test)
to distinguish white-box from black-box tests.
Common Assertions
if got != want {
t.Errorf("got %v, want %v", got, want)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if err == nil {
t.Fatal("expected error, got nil")
}
Cobra CLI Testing
Testing Cobra commands requires extra care because rootCmd is typically
a package-level singleton whose state persists across subtests.
Per-Subtest Isolation
func TestExecute(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
wantOut string
}{
{"help flag", []string{"--help"}, "", "Usage:"},
{"unknown flag", []string{"--bogus"}, "unknown flag", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs(tt.args)
t.Cleanup(func() {
rootCmd.SetOut(os.Stdout)
rootCmd.SetErr(os.Stderr)
rootCmd.SetArgs(nil)
})
err := Execute()
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) &&
!strings.Contains(buf.String(), tt.wantErr) {
t.Fatalf("error %q does not contain %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tt.wantOut != "" && !strings.Contains(buf.String(), tt.wantOut) {
t.Fatalf("output missing %q:\n%s", tt.wantOut, buf.String())
}
})
}
}
Key points:
t.Cleanup inside every subtest — restores SetOut, SetErr,
and SetArgs so the next subtest starts clean.
- Fresh
bytes.Buffer per subtest — never share a buffer.
- Assert on error content — check
err.Error() or stderr for the
expected substring, not just err != nil.
- Comment explaining no
t.Parallel() — the singleton command is
shared mutable state.
Variable Shadowing
Never declare a local variable that shadows a package-level name:
output := string(data)
got := string(data)
References
Last updated: 2026-01-10