Go Style Guide
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
Extends Google Go Style Guide. See also Google Go Decisions and Google Go Best Practices.
Quick Reference
| Task | Tool | Command |
|---|---|---|
| Lint | golangci-lint1 | golangci-lint run |
| Format | gofmt2 | gofmt -w . |
| Format | goimports3 | goimports -w . |
| Type check | built-in | go build ./... |
| Semantic | Staticcheck4 | via golangci-lint |
| Dead code | deadcode5 | deadcode ./... |
| Coverage | go test6 | go test -cover ./... |
| Complexity | gocyclo7 | via golangci-lint |
| Fuzz | native | go test -fuzz=Fuzz |
| Test perf | go test6 | go test -parallel=4 |
Linting: golangci-lint
Projects MUST use golangci-lint1 for linting Go code.
Projects MUST enable misspell with locale: US for US English spelling in code and comments.
Why: a fixed locale catches both typos and British spellings before they reach review.
Why golangci-lint?
- Performance: 5x faster than running linters separately through parallel execution and shared caching
- Comprehensiveness: Aggregates 120+ linters in a single tool
- Industry standard: De facto standard in the Go ecosystem with widespread adoption
- Configuration: Single YAML file for all linter settings
- CI integration: Official GitHub Actions support with automatic caching
# Install
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.0
# Run
golangci-lint run
# Run with auto-fix
golangci-lint run --fix
Configuration (.golangci.yml)
run:
timeout: 5m
linters:
enable:
- staticcheck # Comprehensive static analysis
- gosimple # Simplifications
- govet # Suspicious constructs
- errcheck # Unchecked errors
- gosec # Security issues
- revive # Opinionated linter
- gocyclo # Cyclomatic complexity
- misspell # Spelling
- unconvert # Unnecessary conversions
- unparam # Unused parameters
- gocritic # Highly extensible linter
- prealloc # Slice preallocation
- exportloopref # Loop variable capture
linters-settings:
misspell:
locale: US
gocyclo:
min-complexity: 15
govet:
enable-all: true
revive:
rules:
- name: blank-imports
- name: context-as-argument
- name: error-return
- name: error-strings
- name: exported
Why gofmt/goimports?
- Zero configuration: Eliminates formatting debates and bike-shedding
- Consistency: Single canonical format across the entire Go ecosystem
- Tooling: Built into the language toolchain, guaranteed compatibility
- Automatic: goimports adds/removes imports automatically, reducing manual work
# Format all files
gofmt -w .
# Format and organize imports
goimports -w .
There are no configuration options. This is intentional.
Context Patterns
Projects MUST propagate context through the call stack. Context carries deadlines, cancellation signals, and request-scoped values.
Why Context Matters
- Cancellation: Allows graceful shutdown when requests are canceled
- Timeouts: Prevents runaway operations from consuming resources
- Tracing: Carries request IDs and tracing information across service boundaries
Context Rules
// Context MUST be the first parameter, named ctx
func ProcessOrder(ctx context.Context, orderID string) error {
// Check for cancellation before expensive operations
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Pass context to downstream calls
user, err := fetchUser(ctx, orderID)
if err != nil {
return err
}
return chargePayment(ctx, user)
}
Anti-patterns
// BAD: Storing context in a struct
type Service struct {
ctx context.Context // Never do this
}
// BAD: Using context.Background() deep in the call stack
func deepFunction() {
doSomething(context.Background()) // Loses cancellation signal
}
// GOOD: Accept context as parameter
func deepFunction(ctx context.Context) {
doSomething(ctx)
}
Error Wrapping
Projects SHOULD wrap errors with context using fmt.Errorf and %w. This
preserves the error chain for debugging while adding context.
Why Wrap Errors
- Debugging: Stack of context shows where errors originated
- Programmatic handling:
errors.Is()anderrors.As()work through wrapped errors - User messages: Can extract user-friendly messages at API boundaries
Error Wrapping Patterns
import (
"errors"
"fmt"
)
// Wrap with context using %w
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("load config %s: %w", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
return &cfg, nil
}
// Check wrapped errors with errors.Is()
if errors.Is(err, os.ErrNotExist) {
// Handle missing file
}
// Extract typed errors with errors.As()
var pathErr *os.PathError
if errors.As(err, &pathErr) {
log.Printf("path error on %s: %v", pathErr.Path, pathErr.Err)
}
Sentinel Errors
Define sentinel errors for conditions callers need to check programmatically:
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
)
func GetUser(id string) (*User, error) {
user := db.Find(id)
if user == nil {
return nil, fmt.Errorf("user %s: %w", id, ErrNotFound)
}
return user, nil
}
// Caller can check
if errors.Is(err, ErrNotFound) {
return http.StatusNotFound
}
Handle Errors Once
MUST NOT both log and return an error. Choose one:
// BAD: Logs AND returns - error gets logged multiple times
func process() error {
if err := doThing(); err != nil {
log.Printf("failed: %v", err) // Logged here
return err // And logged by caller
}
return nil
}
// GOOD: Return with context, let caller decide
func process() error {
if err := doThing(); err != nil {
return fmt.Errorf("process: %w", err)
}
return nil
}
// GOOD: Log at the top level only
func main() {
if err := process(); err != nil {
log.Fatalf("fatal: %v", err)
}
}
Dead Code Detection
Projects SHOULD use deadcode5 to identify unused functions and methods.
# Install
go install golang.org/x/tools/cmd/deadcode@v0.52.0
# Run
deadcode ./...
Code Coverage
Projects MUST track code coverage using Go’s built-in coverage tools. Projects SHOULD enforce minimum coverage thresholds in CI.
# Run tests with coverage
go test -cover ./...
# Generate coverage profile
go test -coverprofile=coverage.out ./...
# View in browser
go tool cover -html=coverage.out
# Check coverage threshold (via script)
go test -coverprofile=coverage.out ./...
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "Coverage $COVERAGE% is below 80%"
exit 1
fi
Cyclomatic Complexity
Projects SHOULD monitor cyclomatic complexity using gocyclo7 (via golangci-lint). Functions with complexity over 15 SHOULD be refactored.
Via golangci-lint with gocyclo enabled:
linters:
enable:
- gocyclo
- misspell
linters-settings:
misspell:
locale: US
gocyclo:
min-complexity: 15 # Report functions with complexity > 15
Or standalone:
go install github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0
gocyclo -over 15 .
Fuzzing: Native Go Fuzzing (1.18+)
Projects SHOULD use Go’s native fuzzing for testing functions that parse untrusted input or have complex edge cases.
Why Native Go Fuzzing?
- Built-in: No external dependencies, part of the standard toolchain since Go 1.18
- Integrated: Works seamlessly with existing
go testinfrastructure - Coverage-guided: Uses code coverage to discover new execution paths
- Corpus management: Automatically manages and minimizes test corpus
func FuzzReverse(f *testing.F) {
// Seed corpus
f.Add("hello")
f.Add("world")
f.Fuzz(func(t *testing.T, s string) {
rev := Reverse(s)
doubleRev := Reverse(rev)
if s != doubleRev {
t.Errorf("double reverse mismatch: %q != %q", s, doubleRev)
}
})
}
Active fuzzing MUST name exactly one package and one fuzz target. go test
rejects -fuzz with a multi-package pattern such as ./..., failing with
cannot use -fuzz flag with multiple packages.
# Fuzz the target in the current package
go test -fuzz=FuzzReverse -fuzztime=30s .
# Fuzz a target in another package: name that package, never ./...
go test -fuzz=FuzzReverse -fuzztime=1m ./internal/strutil
# Replay the seed and failure corpus in every package without fuzzing
go test ./...
Why One Package at a Time
- Tool constraint:
-fuzzmutates inputs for a single target, so the toolchain refuses ambiguous package patterns - Corpus locality: seed inputs live in
testdata/fuzz/<FuzzTarget>/beside the test; failing inputs are written back there for replay - Regression replay: a plain
go test ./...still runs every stored corpus entry as an ordinary test case across all packages
Benchmarking
Projects SHOULD write benchmarks for performance-critical code.
Why Benchmarking Matters
- Optimization validation: Prove that optimizations actually improve performance
- Regression detection: Catch performance regressions in CI
- Memory profiling: Identify allocation-heavy code paths
- Comparison: Compare implementations objectively
Writing Benchmarks
func BenchmarkFibonacci(b *testing.B) {
for i := 0; i < b.N; i++ {
Fibonacci(20)
}
}
// Benchmark with different input sizes
func BenchmarkSort(b *testing.B) {
sizes := []int{100, 1000, 10000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
data := generateRandomSlice(size)
b.ResetTimer() // Exclude setup time
for i := 0; i < b.N; i++ {
sort.Ints(data)
}
})
}
}
// Benchmark with memory allocation reporting
func BenchmarkConcat(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
concat("hello", "world")
}
}
Running Benchmarks
# Run all benchmarks
go test -bench=. ./...
# Run specific benchmark with memory stats
go test -bench=BenchmarkSort -benchmem ./...
# Run benchmarks for 10 seconds each
go test -bench=. -benchtime=10s ./...
# Run benchmarks multiple times for statistical validity
go test -bench=. -count=5 ./...
# Compare benchmarks with benchstat
go install golang.org/x/perf/cmd/benchstat@v0.0.0-20261009192801-be2c69fb417e
go test -bench=. -count=10 > old.txt
# Make changes...
go test -bench=. -count=10 > new.txt
benchstat old.txt new.txt
Benchmark Best Practices
// GOOD: Reset timer after expensive setup
func BenchmarkProcessFile(b *testing.B) {
data, err := os.ReadFile("testdata/large.json")
if err != nil {
b.Fatal(err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
processJSON(data)
}
}
// GOOD: Use b.RunParallel for concurrent benchmarks
func BenchmarkConcurrentMap(b *testing.B) {
m := &sync.Map{}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
m.Store("key", "value")
m.Load("key")
}
})
}
// BAD: Setup cost included in benchmark
func BenchmarkBad(b *testing.B) {
for i := 0; i < b.N; i++ {
data, _ := os.ReadFile("large.json") // Setup in loop!
processJSON(data)
}
}
Test Performance
Projects SHOULD run tests in parallel mode. Projects MUST run tests with the race detector in CI.
# Parallel tests (default: GOMAXPROCS)
go test -parallel=8 ./...
# Run specific benchmarks
go test -bench=. -benchmem ./...
# Short mode for CI
go test -short ./...
# Race detector (slower but catches data races)
go test -race ./...
# Compile tests once, run many times
go test -c -o test.exe ./mypackage
./test.exe -test.v
Caching
Go caches test results by default. Force re-run:
go clean -testcache
go test ./...
Pre-commit Configuration
Projects SHOULD use pre-commit hooks to enforce formatting and linting before commits.
repos:
- repo: https://github.com/golangci/golangci-lint
rev: v1.62.0
hooks:
- id: golangci-lint
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-imports
- id: go-vet
CI Pipeline
Projects MUST run linting and tests in CI. Projects MUST include race detection in CI test runs.
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.23'
- uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6.5.2
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.23'
- run: go test -race -coverprofile=coverage.out ./...
- run: go tool cover -func=coverage.out
Dependencies & Package Management
Projects MUST use Go modules (go mod) for dependency management.
Projects MUST commit both go.mod and go.sum to version control.
Why Go Modules?
- Official: Standard dependency management system since Go 1.11
- Reproducible: MVS algorithm ensures consistent builds across environments
- Cryptographic verification:
go.sumchecksums prevent supply chain attacks - Version selection: Automatic minimum version selection prevents dependency conflicts
- No external tools: Built into the Go toolchain
github.com/pkg/errors is in upstream
maintenance mode, with v0.9.1 its final
published release; new code SHOULD use
standard-library error wrapping instead.
Why: fmt.Errorf with %w, errors.Is, and errors.As need no legacy dependency.
# Initialize a new module
go mod init github.com/user/repo
# Add dependencies (automatically updates go.mod)
go get github.com/pkg/errors@v0.9.1
# Update dependencies
# crefft: allow-floating -- Deliberately refresh dependencies for review, not a repeatable install.
go get -u ./...
# Tidy up (remove unused, add missing)
go mod tidy
# Verify dependencies
go mod verify
# Download dependencies to local cache
go mod download
go.mod and go.sum
Both files MUST be committed to version control.
// go.mod
module github.com/user/repo
go 1.23
require (
github.com/pkg/errors v0.9.1
golang.org/x/sync v0.8.0
)
go.sum contains cryptographic checksums for dependency verification.
Minimum Version Selection (MVS)
Go uses MVS algorithm: always selects the minimum required version that satisfies all constraints. This ensures reproducible builds.
# View dependency graph
go mod graph
# See why a dependency is needed
go mod why github.com/pkg/errors
Vulnerability Scanning
Projects SHOULD use govulncheck8 to scan for known vulnerabilities. Projects SHOULD run vulnerability scans in CI.
# Install govulncheck
go install golang.org/x/vuln/cmd/govulncheck@v1.9.0
# Scan for vulnerabilities
govulncheck ./...
# JSON output for CI
govulncheck -json ./...
Dependabot Configuration
Projects SHOULD use Dependabot9 or similar tools for automated dependency updates.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
groups:
minor-updates:
update-types:
- "minor"
- "patch"
httptest for API Testing
func TestUserHandler(t *testing.T) {
req := httptest.NewRequest("GET", "/users/123", nil)
w := httptest.NewRecorder()
handler := NewUserHandler()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("got %d, want %d", w.Code, http.StatusOK)
}
var user User
json.NewDecoder(w.Body).Decode(&user)
if user.ID != 123 {
t.Errorf("got ID %d, want 123", user.ID)
}
}
chromedp for Browser Testing
chromedp11 drives a real Chrome instance over the DevTools Protocol.
import "github.com/chromedp/chromedp"
func TestLoginFlow(t *testing.T) {
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
var title string
err := chromedp.Run(ctx,
chromedp.Navigate("http://localhost:8080/login"),
chromedp.SendKeys(`input[name="email"]`, "user@example.com"),
chromedp.SendKeys(`input[name="password"]`, "secret"),
chromedp.Click(`button[type="submit"]`),
chromedp.WaitVisible(`#dashboard`),
chromedp.Title(&title),
)
if err != nil {
t.Fatal(err)
}
if title != "Dashboard" {
t.Errorf("got title %q, want Dashboard", title)
}
}
godog for BDD/Cucumber
godog12 executes Gherkin feature files against Go step definitions.
// features/login.feature
// Feature: User Login
// Scenario: Valid credentials
// Given I am on the login page
// When I enter valid credentials
// Then I should see the dashboard
import "github.com/cucumber/godog"
func InitializeScenario(ctx *godog.ScenarioContext) {
ctx.Step(`^I am on the login page$`, iAmOnLoginPage)
ctx.Step(`^I enter valid credentials$`, iEnterValidCredentials)
ctx.Step(`^I should see the dashboard$`, iShouldSeeDashboard)
}
func TestFeatures(t *testing.T) {
suite := godog.TestSuite{
ScenarioInitializer: InitializeScenario,
Options: &godog.Options{
Format: "pretty",
Paths: []string{"features"},
},
}
if suite.Run() != 0 {
t.Fatal("non-zero status returned")
}
}
Mocking with gomock
Projects SHOULD use gomock13 for generating mock implementations of interfaces in unit tests.
Why gomock?
- Type-safe: Generated mocks are type-checked at compile time
- Interface-based: Works with Go’s interface system, promoting testable design
- Flexible expectations: Support for exact, any, and custom matchers
- Ordered verification: Can verify call order when needed
- Official: Maintained by the Go team (uber-go/mock fork is also popular)
Installation and Code Generation
# Install mockgen
go install go.uber.org/mock/mockgen@v0.6.0
# Generate mocks from interface (source mode)
mockgen -source=repository.go -destination=mocks/repository_mock.go -package=mocks
# Generate mocks from package (reflect mode)
mockgen -destination=mocks/client_mock.go -package=mocks github.com/user/app Client
Interface Design for Testability
// Define interfaces for dependencies
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
Save(ctx context.Context, user *User) error
}
type EmailSender interface {
Send(ctx context.Context, to, subject, body string) error
}
// Service depends on interfaces, not implementations
type UserService struct {
repo UserRepository
mailer EmailSender
}
func NewUserService(repo UserRepository, mailer EmailSender) *UserService {
return &UserService{repo: repo, mailer: mailer}
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.FindByID(ctx, id)
}
func (s *UserService) SaveUser(ctx context.Context, user *User) error {
return s.repo.Save(ctx, user)
}
// Rename loads the user, persists the new name, then reloads it so that
// callers observe the stored record rather than the in-memory copy.
func (s *UserService) Rename(ctx context.Context, id, name string) (*User, error) {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
user.Name = name
if err := s.repo.Save(ctx, user); err != nil {
return nil, err
}
return s.repo.FindByID(ctx, id)
}
func (s *UserService) SendWelcomeEmail(ctx context.Context, id string) error {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
return err
}
return s.mailer.Send(ctx, user.Email, "Welcome!", "Welcome aboard, "+user.Name)
}
Using Generated Mocks
import (
"testing"
"go.uber.org/mock/gomock"
"github.com/user/app/mocks"
)
func TestUserService_GetUser(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Create mock
mockRepo := mocks.NewMockUserRepository(ctrl)
// Set expectations
mockRepo.EXPECT().
FindByID(gomock.Any(), "user-123").
Return(&User{ID: "user-123", Name: "Alice"}, nil)
// Create service with mock
svc := NewUserService(mockRepo, nil)
// Test
user, err := svc.GetUser(context.Background(), "user-123")
if err != nil {
t.Fatal(err)
}
if user.Name != "Alice" {
t.Errorf("got name %q, want Alice", user.Name)
}
}
func TestUserService_SendWelcomeEmail(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mocks.NewMockUserRepository(ctrl)
mockMailer := mocks.NewMockEmailSender(ctrl)
// Expect repo lookup
mockRepo.EXPECT().
FindByID(gomock.Any(), "user-123").
Return(&User{ID: "user-123", Email: "alice@example.com"}, nil)
// Expect email to be sent with specific arguments
mockMailer.EXPECT().
Send(gomock.Any(), "alice@example.com", "Welcome!", gomock.Any()).
Return(nil)
svc := NewUserService(mockRepo, mockMailer)
err := svc.SendWelcomeEmail(context.Background(), "user-123")
if err != nil {
t.Fatal(err)
}
}
Advanced Matchers
Every expectation registered without AnyTimes() is required, so each test
MUST call the system under test until the declared counts are satisfied.
Independent matcher demonstrations MUST live in separate subtests with
their own controller: a broad matcher such as gomock.Any() otherwise
consumes a call intended for a narrower expectation. gomock.NewController(t)
registers the final verification through t.Cleanup, so ctrl.Finish() is
not called explicitly.
func TestAdvancedMatchers(t *testing.T) {
ctx := context.Background()
t.Run("any argument", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockRepo := mocks.NewMockUserRepository(ctrl)
// gomock.Any() matches any value
mockRepo.EXPECT().
FindByID(gomock.Any(), gomock.Any()).
Return(&User{ID: "user-123"}, nil)
svc := NewUserService(mockRepo, nil)
if _, err := svc.GetUser(ctx, "user-123"); err != nil {
t.Fatalf("GetUser: %v", err)
}
})
t.Run("custom matcher", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockRepo := mocks.NewMockUserRepository(ctrl)
// gomock.Cond is generic: the function only receives values of the
// declared type, so no unchecked type assertion is needed.
mockRepo.EXPECT().
Save(gomock.Any(), gomock.Cond(func(u *User) bool {
return u.Email != "" && u.Name != ""
})).
Return(nil)
svc := NewUserService(mockRepo, nil)
user := &User{ID: "user-123", Name: "Alice", Email: "alice@example.com"}
if err := svc.SaveUser(ctx, user); err != nil {
t.Fatalf("SaveUser: %v", err)
}
})
t.Run("exact call count", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockRepo := mocks.NewMockUserRepository(ctrl)
// Times() for call count expectations
mockRepo.EXPECT().
FindByID(gomock.Any(), "user-456").
Return(&User{ID: "user-456"}, nil).
Times(3)
svc := NewUserService(mockRepo, nil)
for i := 0; i < 3; i++ {
if _, err := svc.GetUser(ctx, "user-456"); err != nil {
t.Fatalf("GetUser call %d: %v", i, err)
}
}
})
t.Run("optional calls", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockRepo := mocks.NewMockUserRepository(ctrl)
// AnyTimes() for optional calls: zero calls also satisfy it
mockRepo.EXPECT().
FindByID(gomock.Any(), "cached-user").
Return(&User{ID: "cached-user"}, nil).
AnyTimes()
svc := NewUserService(mockRepo, nil)
for i := 0; i < 2; i++ {
if _, err := svc.GetUser(ctx, "cached-user"); err != nil {
t.Fatalf("GetUser call %d: %v", i, err)
}
}
})
}
Call Order Verification
func TestCallOrder(t *testing.T) {
ctrl := gomock.NewController(t)
mockRepo := mocks.NewMockUserRepository(ctrl)
// InOrder ensures calls happen in sequence
gomock.InOrder(
mockRepo.EXPECT().FindByID(gomock.Any(), "1").Return(&User{ID: "1"}, nil),
mockRepo.EXPECT().Save(gomock.Any(), gomock.Any()).Return(nil),
mockRepo.EXPECT().
FindByID(gomock.Any(), "1").
Return(&User{ID: "1", Name: "Alice"}, nil),
)
// Rename issues FindByID, Save, then FindByID, satisfying the sequence.
svc := NewUserService(mockRepo, nil)
user, err := svc.Rename(context.Background(), "1", "Alice")
if err != nil {
t.Fatalf("Rename: %v", err)
}
if user.Name != "Alice" {
t.Errorf("got name %q, want Alice", user.Name)
}
}
go:generate for Mock Generation
//go:generate mockgen -source=repository.go -destination=mocks/repository_mock.go -package=mocks
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
Save(ctx context.Context, user *User) error
}
# Regenerate all mocks
go generate ./...
Thread Safety Testing
Race Detector
Projects MUST use the race detector when testing concurrent code. The race detector MUST be run in CI for all projects with goroutines.
# Run tests with race detector
go test -race ./...
# Build binary with race detector
go build -race
# Run benchmarks with race detection
go test -race -bench=. ./...
Testing Goroutines and Channels
func TestConcurrentWrites(t *testing.T) {
cache := NewCache()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
cache.Set(fmt.Sprintf("key%d", n), n)
}(i)
}
wg.Wait()
if cache.Len() != 100 {
t.Errorf("got %d items, want 100", cache.Len())
}
}
func TestChannelClose(t *testing.T) {
ch := make(chan int)
go func() {
defer close(ch)
for i := 0; i < 10; i++ {
ch <- i
}
}()
sum := 0
for v := range ch {
sum += v
}
if sum != 45 {
t.Errorf("got sum %d, want 45", sum)
}
}
sync Package Testing Patterns
func TestMutexProtection(t *testing.T) {
var mu sync.Mutex
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
if counter != 1000 {
t.Errorf("race condition: got %d, want 1000", counter)
}
}
func TestOnce(t *testing.T) {
var once sync.Once
count := 0
increment := func() { count++ }
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
once.Do(increment)
}()
}
wg.Wait()
if count != 1 {
t.Errorf("Once failed: got %d executions, want 1", count)
}
}
Idempotence Testing
Testing Retry-Safe Handlers
func TestIdempotentCreate(t *testing.T) {
handler := NewUserHandler()
user := User{ID: "123", Email: "test@example.com"}
// First request
req1 := newCreateRequest(user, "request-id-1")
w1 := httptest.NewRecorder()
handler.ServeHTTP(w1, req1)
if w1.Code != http.StatusCreated {
t.Fatalf("first request failed: %d", w1.Code)
}
// Retry with same idempotency key
req2 := newCreateRequest(user, "request-id-1")
w2 := httptest.NewRecorder()
handler.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Errorf("retry should return 200, got %d", w2.Code)
}
// Verify only one user created
count := countUsers(t)
if count != 1 {
t.Errorf("expected 1 user, got %d", count)
}
}
Database Operation Idempotence
func TestIdempotentUpdate(t *testing.T) {
db := setupTestDB(t)
repo := NewRepository(db)
initial := &User{ID: 1, Version: 1, Name: "Alice"}
repo.Create(initial)
// Multiple identical updates
update := &User{ID: 1, Version: 1, Name: "Bob"}
for i := 0; i < 5; i++ {
err := repo.Update(update)
if err != nil {
t.Fatalf("update %d failed: %v", i, err)
}
}
// Verify final state
result := repo.FindByID(1)
if result.Name != "Bob" {
t.Errorf("got name %q, want Bob", result.Name)
}
if result.Version != 2 {
t.Errorf("got version %d, want 2", result.Version)
}
}
Reliability & Resilience Testing
Chaos Testing Patterns
func TestFailureRecovery(t *testing.T) {
failureRate := 0.3
service := &UnreliableService{
FailureRate: failureRate,
Random: rand.New(rand.NewSource(42)),
}
client := NewRetryClient(service, 3)
successes := 0
attempts := 100
for i := 0; i < attempts; i++ {
err := client.Call(context.Background())
if err == nil {
successes++
}
}
// With retries, success rate should be high
successRate := float64(successes) / float64(attempts)
if successRate < 0.95 {
t.Errorf("success rate %.2f too low", successRate)
}
}
Testing with Context Cancellation
func TestGracefulCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
var processed int32
go func() {
defer close(done)
worker(ctx, &processed)
}()
time.Sleep(100 * time.Millisecond)
cancel()
select {
case <-done:
// Worker stopped gracefully
case <-time.After(time.Second):
t.Fatal("worker did not stop after context cancellation")
}
if atomic.LoadInt32(&processed) == 0 {
t.Error("worker should have processed some items")
}
}
func TestTimeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
err := slowOperation(ctx)
if err != context.DeadlineExceeded {
t.Errorf("got error %v, want DeadlineExceeded", err)
}
}
Circuit Breaker Testing
gobreaker14 v2 (github.com/sony/gobreaker/v2 v2.4.0) is generic over the
request result. Tests MUST configure ReadyToTrip for the threshold they
assert: MaxRequests bounds half-open admission, not closed-state failures,
and the default ReadyToTrip needs more than five consecutive failures. Calls
up to the threshold return the service error; only the call after the breaker
opens returns ErrOpenState.
import (
"errors"
"testing"
"time"
"github.com/sony/gobreaker/v2"
)
var errUnavailable = errors.New("service unavailable")
func TestCircuitBreaker(t *testing.T) {
const threshold = 3
cb := gobreaker.NewCircuitBreaker[string](gobreaker.Settings{
Name: "downstream",
MaxRequests: 1,
Interval: time.Second,
Timeout: time.Second,
ReadyToTrip: func(counts gobreaker.Counts) bool {
return counts.ConsecutiveFailures >= threshold
},
})
failingService := func() (string, error) {
return "", errUnavailable
}
// The breaker stays closed until ReadyToTrip fires, so every call up to
// the threshold returns the service error, not ErrOpenState.
for i := 1; i <= threshold; i++ {
if _, err := cb.Execute(failingService); !errors.Is(err, errUnavailable) {
t.Fatalf("call %d: got error %v, want %v", i, err, errUnavailable)
}
}
if state := cb.State(); state != gobreaker.StateOpen {
t.Fatalf("got state %v, want %v", state, gobreaker.StateOpen)
}
// Only the call after the breaker opened is rejected without dialing out.
if _, err := cb.Execute(failingService); !errors.Is(err, gobreaker.ErrOpenState) {
t.Errorf("got error %v, want ErrOpenState", err)
}
}
Compatibility Testing
Multi-Version Testing with CI Matrix
# .github/workflows/test.yml
jobs:
test:
strategy:
matrix:
go-version: ['1.22', '1.23', '1.24']
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ matrix.go-version }}
- run: go test ./...
GOOS/GOARCH Cross-Compilation Testing
# Test builds for different platforms
GOOS=linux GOARCH=amd64 go build -o build/app-linux-amd64
GOOS=darwin GOARCH=arm64 go build -o build/app-darwin-arm64
GOOS=windows GOARCH=amd64 go build -o build/app-windows-amd64.exe
# Test all supported platforms
for GOOS in darwin linux windows; do
for GOARCH in amd64 arm64; do
echo "Building $GOOS/$GOARCH"
GOOS=$GOOS GOARCH=$GOARCH go build -o /dev/null
done
done
// Platform-specific tests
//go:build linux
// +build linux
func TestLinuxSpecific(t *testing.T) {
// Linux-only test
}
//go:build windows
// +build windows
func TestWindowsSpecific(t *testing.T) {
// Windows-only test
}
cgo: C Interoperability
Projects MAY use cgo15 to call C code from Go when necessary, but SHOULD prefer pure Go alternatives when available.
Why cgo?
- Legacy integration: Interface with existing C libraries and system APIs
- Performance: Call highly optimized C code for compute-intensive operations
- System access: Access platform-specific functionality not exposed in Go
When to Avoid cgo
- Cross-compilation: cgo requires a C compiler for the target platform
- Complexity: Debugging across Go/C boundary is harder
- Performance cost: Each cgo call has ~150ns overhead vs pure Go calls
- CGO_ENABLED=0: Many deployments (scratch containers) disable cgo
Basic cgo Usage
package main
/*
#cgo linux LDFLAGS: -lm
#include <math.h>
#include <stdlib.h>
// C function definition
static int add(int a, int b) {
return a + b;
}
*/
import "C"
import "fmt"
func main() {
// Call C function
result := C.add(40, 2)
fmt.Println("40 + 2 =", result)
// Call standard library
sqrt := C.sqrt(16.0)
fmt.Println("sqrt(16) =", sqrt)
}
Preamble helpers MUST be declared static. cgo compiles the preamble once
per Go file, so a package with two cgo files that both define int add(...)
fails to link with duplicate symbol '_add'; static gives the helper
internal linkage. Platform linker flags MUST be declared with #cgo:
glibc needs -lm for math.h symbols, whereas macOS resolves them from
libSystem.
Memory Management
Every C symbol a fence calls MUST be declared in that fence’s preamble;
C.some_c_function and C.process_buffer below stand in for a real library.
package clib
/*
#include <ctype.h>
#include <stdlib.h>
#include <string.h>
// Returns a newly allocated upper-cased copy that the caller owns.
static char *some_c_function(const char *in) {
size_t n = strlen(in);
char *out = malloc(n + 1);
if (out == NULL) {
return NULL;
}
for (size_t i = 0; i < n; i++) {
out[i] = (char)toupper((unsigned char)in[i]);
}
out[n] = '\0';
return out;
}
// Transforms a caller-owned buffer in place.
static void process_buffer(char *data, int len) {
for (int i = 0; i < len; i++) {
data[i] = (char)toupper((unsigned char)data[i]);
}
}
*/
import "C"
import "unsafe"
// String conversion - MUST free C strings
func goStringToC(s string) *C.char {
return C.CString(s) // Caller must free!
}
func cStringToGo(cs *C.char) string {
return C.GoString(cs)
}
func processString(input string) string {
// Convert Go string to C string
cInput := C.CString(input)
defer C.free(unsafe.Pointer(cInput)) // MUST free
// Call C function that returns new string
cOutput := C.some_c_function(cInput)
if cOutput == nil {
return ""
}
defer C.free(unsafe.Pointer(cOutput))
// Convert back to Go string
return C.GoString(cOutput)
}
// Passing slices to C
func processBytes(data []byte) {
if len(data) == 0 {
return
}
// Get pointer to first element
cData := (*C.char)(unsafe.Pointer(&data[0]))
cLen := C.int(len(data))
C.process_buffer(cData, cLen)
}
Linking External Libraries
Each snippet below is a complete file that compiles only where the named
library and headers are installed. A Go file MUST contain exactly one
preamble, and that comment block MUST sit immediately above its own
import "C": cgo treats every comment line adjacent to import "C" as C
source, so descriptive // labels belong in prose, not directly above the
preamble.
Absolute include and library search paths:
package clib
/*
#cgo CFLAGS: -I/usr/local/include
#cgo LDFLAGS: -L/usr/local/lib -lmylibrary
#include <mylibrary.h>
*/
import "C"
Platform-specific flags, applied per GOOS:
package clib
/*
#cgo linux LDFLAGS: -lm -lpthread
#cgo darwin LDFLAGS: -framework CoreFoundation
#cgo windows LDFLAGS: -lws2_32
*/
import "C"
pkg-config resolving flags at build time:
package clib
/*
#cgo pkg-config: libpng openssl
#include <png.h>
#include <openssl/ssl.h>
*/
import "C"
Error Handling
A cgo call’s second result carries errno, which is only meaningful once the
C status has reported failure. Code MUST test the status first, then the
errno value.
package cerr
/*
#include <errno.h>
#include <stdlib.h>
static int divide(int a, int b, int *result) {
if (b == 0) {
errno = EINVAL;
return -1;
}
*result = a / b;
return 0;
}
*/
import "C"
import (
"errors"
"fmt"
)
func Divide(a, b int) (int, error) {
var result C.int
status, errno := C.divide(C.int(a), C.int(b), &result)
if status != 0 {
if errno != nil {
return 0, fmt.Errorf("divide: %w", errno) // wraps syscall.EINVAL
}
return 0, errors.New("divide: failed without setting errno")
}
return int(result), nil
}
cgo Best Practices
// GOOD: Minimize cgo calls by batching work
func ProcessBatch(items []Item) {
cItems := convertToCArray(items)
defer freeCArray(cItems)
C.process_batch(cItems, C.int(len(items))) // One cgo call
}
// BAD: Many small cgo calls
func ProcessBatchSlow(items []Item) {
for _, item := range items {
C.process_single(convertToC(item)) // N cgo calls
}
}
// GOOD: Keep cgo isolated to specific packages
// internal/clib/wrapper.go
package clib
/*
#include <mylib.h>
*/
import "C"
func DoThing() { C.do_thing() }
// GOOD: Provide pure Go fallback with build tags
//go:build !cgo
package mylib
func DoThing() {
// Pure Go implementation
}
Build and Test with cgo
# Enable cgo (default on most platforms)
CGO_ENABLED=1 go build
# Disable cgo for static binary
CGO_ENABLED=0 go build
# Cross-compile with cgo requires C cross-compiler
CC=x86_64-linux-musl-gcc CGO_ENABLED=1 GOOS=linux go build
# Test with race detector (requires cgo on some platforms)
CGO_ENABLED=1 go test -race ./...
Internationalization Testing
UTF-8 String Handling (Runes)
func TestUnicodeHandling(t *testing.T) {
tests := []struct {
input string
want int
}{
{"hello", 5},
{"世界", 2}, // Chinese characters
{"🚀🌟", 2}, // Emoji
{"café", 4}, // Accented characters
}
for _, tt := range tests {
got := len([]rune(tt.input))
if got != tt.want {
t.Errorf("len(%q) = %d, want %d", tt.input, got, tt.want)
}
}
}
func TestRuneIteration(t *testing.T) {
s := "Go语言"
// Wrong: iterates over bytes
byteCount := 0
for i := 0; i < len(s); i++ {
byteCount++
}
// Correct: iterates over runes
runeCount := 0
for range s {
runeCount++
}
if runeCount != 4 {
t.Errorf("got %d runes, want 4", runeCount)
}
}
golang.org/x/text for i18n
golang.org/x/text16 (v0.41.0) supplies locale-aware formatting.
message.Printer localizes number formatting and looks translations up in a
catalog; it does not infer plural forms from a source string such as
"%d item(s)". Plural selection MUST be registered explicitly with
plural.Selectf, otherwise the printer falls back to the untranslated format
string and returns "1 item(s)" for every count.
import (
"golang.org/x/text/feature/plural"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
func TestMessageFormatting(t *testing.T) {
p := message.NewPrinter(language.English)
msg := p.Sprintf("You have %d new messages", 42)
want := "You have 42 new messages"
if msg != want {
t.Errorf("got %q, want %q", msg, want)
}
}
// registerMessages installs plural-aware translations for the "%d item(s)"
// key. The printer then selects the CLDR plural form for its locale.
func registerMessages() error {
err := message.Set(language.English, "%d item(s)",
plural.Selectf(1, "%d",
plural.One, "%d item",
plural.Other, "%d items",
))
if err != nil {
return err
}
return message.Set(language.French, "%d item(s)",
plural.Selectf(1, "%d",
plural.One, "%d article",
plural.Other, "%d articles",
))
}
func TestPluralization(t *testing.T) {
if err := registerMessages(); err != nil {
t.Fatalf("register messages: %v", err)
}
tests := []struct {
lang language.Tag
count int
want string
}{
{language.English, 0, "0 items"},
{language.English, 1, "1 item"},
{language.English, 5, "5 items"},
// French uses the "one" form for both 0 and 1.
{language.French, 0, "0 article"},
{language.French, 1, "1 article"},
{language.French, 5, "5 articles"},
}
for _, tt := range tests {
p := message.NewPrinter(tt.lang)
got := p.Sprintf("%d item(s)", tt.count)
if got != tt.want {
t.Errorf("%v/%d: got %q, want %q", tt.lang, tt.count, got, tt.want)
}
}
}
Unicode Normalization Tests
golang.org/x/text/unicode/norm16 converts between the composed and
decomposed forms.
import "golang.org/x/text/unicode/norm"
func TestNormalization(t *testing.T) {
// "é" can be represented two ways
composed := "\u00e9" // Single codepoint
decomposed := "e\u0301" // e + combining accent
if composed == decomposed {
t.Error("strings should not be equal before normalization")
}
// Normalize to NFC (composed form)
nfc1 := norm.NFC.String(composed)
nfc2 := norm.NFC.String(decomposed)
if nfc1 != nfc2 {
t.Error("normalized strings should be equal")
}
}
Data Integrity Testing
Database Transaction Testing
func TestTransactionRollback(t *testing.T) {
db := setupTestDB(t)
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
// Create user in transaction
_, err = tx.Exec("INSERT INTO users (name) VALUES (?)", "Alice")
if err != nil {
t.Fatal(err)
}
// Rollback
tx.Rollback()
// Verify user was not created
var count int
db.QueryRow("SELECT COUNT(*) FROM users WHERE name = ?", "Alice").Scan(&count)
if count != 0 {
t.Error("user should not exist after rollback")
}
}
func TestTransactionCommit(t *testing.T) {
db := setupTestDB(t)
err := withTransaction(db, func(tx *sql.Tx) error {
_, err := tx.Exec("INSERT INTO users (name) VALUES (?)", "Bob")
if err != nil {
return err
}
_, err = tx.Exec("INSERT INTO posts (user_id, title) VALUES (?, ?)", 1, "Hello")
return err
})
if err != nil {
t.Fatal(err)
}
// Verify both inserts succeeded
var userCount, postCount int
db.QueryRow("SELECT COUNT(*) FROM users").Scan(&userCount)
db.QueryRow("SELECT COUNT(*) FROM posts").Scan(&postCount)
if userCount != 1 || postCount != 1 {
t.Error("transaction did not commit both operations")
}
}
Migration Testing
golang-migrate17 (v4.19.1) resolves file:// and postgres:// URLs through
a driver registry. Source and database drivers MUST therefore be imported
for their side effects, or migrate.New fails with
unknown driver 'file' (forgotten import?). The database driver is imported by
name below because TestMigrationIdempotence calls postgres.WithInstance;
a package that only uses URL construction MUST import it blank
(_ "github.com/golang-migrate/migrate/v4/database/postgres").
Migration tests MUST run against a database of their own, since Up and
Down rewrite the whole schema, and MUST check every Up, Down,
Version, and Close result. Only the first Up may legitimately return
migrate.ErrNoChange. The database/sql driver lib/pq18 (v1.12.3) is
imported explicitly rather than relied on transitively through
database/postgres.
import (
"database/sql"
"errors"
"fmt"
"net/url"
"os"
"testing"
"time"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
_ "github.com/lib/pq"
)
// newTestDB creates a throwaway database for a single test and drops it
// afterwards, so migrations never run against a shared schema.
func newTestDB(t *testing.T) (*sql.DB, string) {
t.Helper()
adminURL := os.Getenv("TEST_DATABASE_URL")
if adminURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
admin, err := sql.Open("postgres", adminURL)
if err != nil {
t.Fatalf("open admin connection: %v", err)
}
t.Cleanup(func() { _ = admin.Close() })
// The name is generated here rather than taken from input; PostgreSQL
// does not accept placeholders in CREATE DATABASE.
name := fmt.Sprintf("migrate_test_%d", time.Now().UnixNano())
if _, err := admin.Exec(`CREATE DATABASE "` + name + `"`); err != nil {
t.Fatalf("create database %s: %v", name, err)
}
dsn, err := url.Parse(adminURL)
if err != nil {
t.Fatalf("parse TEST_DATABASE_URL: %v", err)
}
dsn.Path = "/" + name
db, err := sql.Open("postgres", dsn.String())
if err != nil {
t.Fatalf("open %s: %v", name, err)
}
t.Cleanup(func() {
_ = db.Close()
if _, err := admin.Exec(`DROP DATABASE "` + name + `"`); err != nil {
t.Errorf("drop database %s: %v", name, err)
}
})
return db, dsn.String()
}
func TestMigrations(t *testing.T) {
db, dsn := newTestDB(t)
m, err := migrate.New("file://migrations", dsn)
if err != nil {
t.Fatalf("new migrator: %v", err)
}
t.Cleanup(func() {
if srcErr, dbErr := m.Close(); srcErr != nil || dbErr != nil {
t.Errorf("close migrator: source=%v database=%v", srcErr, dbErr)
}
})
// Migrate up
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
t.Fatalf("migrate up: %v", err)
}
// Verify schema
var tableCount int
err = db.QueryRow(
`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'`,
).Scan(&tableCount)
if err != nil {
t.Fatalf("count tables: %v", err)
}
if tableCount == 0 {
t.Error("no tables created")
}
// Test rollback
if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
t.Fatalf("migrate down: %v", err)
}
}
func TestMigrationIdempotence(t *testing.T) {
db, _ := newTestDB(t)
// NewWithDatabaseInstance reuses the test's *sql.DB instead of opening a
// second connection pool from a URL.
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
t.Fatalf("postgres driver: %v", err)
}
m, err := migrate.NewWithDatabaseInstance("file://migrations", "postgres", driver)
if err != nil {
t.Fatalf("new migrator: %v", err)
}
t.Cleanup(func() {
if srcErr, dbErr := m.Close(); srcErr != nil || dbErr != nil {
t.Errorf("close migrator: source=%v database=%v", srcErr, dbErr)
}
})
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
t.Fatalf("first up: %v", err)
}
version1, dirty, err := m.Version()
if err != nil {
t.Fatalf("first version: %v", err)
}
if dirty {
t.Fatal("schema is dirty after the first migration")
}
// A second run must report ErrNoChange rather than advancing the version.
if err := m.Up(); !errors.Is(err, migrate.ErrNoChange) {
t.Fatalf("second up: got %v, want ErrNoChange", err)
}
version2, dirty, err := m.Version()
if err != nil {
t.Fatalf("second version: %v", err)
}
if dirty {
t.Fatal("schema is dirty after the second migration")
}
if version1 != version2 {
t.Errorf("version moved from %d to %d; migrations must be idempotent",
version1, version2)
}
}
A/B Testing & Feature Flags
Feature Flag Patterns
type FeatureFlags struct {
mu sync.RWMutex
flags map[string]bool
}
func (f *FeatureFlags) IsEnabled(flag string, userID string) bool {
f.mu.RLock()
defer f.mu.RUnlock()
return f.flags[flag]
}
func TestFeatureFlag(t *testing.T) {
flags := &FeatureFlags{
flags: map[string]bool{
"new_checkout": true,
"beta_ui": false,
},
}
if !flags.IsEnabled("new_checkout", "user123") {
t.Error("new_checkout should be enabled")
}
if flags.IsEnabled("beta_ui", "user123") {
t.Error("beta_ui should be disabled")
}
}
// Percentage-based rollout
func TestGradualRollout(t *testing.T) {
rollout := NewPercentageRollout("feature_x", 20) // 20% of users
enabled := 0
total := 1000
for i := 0; i < total; i++ {
userID := fmt.Sprintf("user%d", i)
if rollout.IsEnabled(userID) {
enabled++
}
}
percentage := float64(enabled) / float64(total) * 100
if percentage < 15 || percentage > 25 {
t.Errorf("got %.1f%% enabled, want ~20%%", percentage)
}
}
Testing with Build Tags
//go:build feature_x
// +build feature_x
func NewHandler() http.Handler {
return &NewHandlerV2{} // Feature flag enabled
}
//go:build !feature_x
// +build !feature_x
func NewHandler() http.Handler {
return &OldHandler{} // Feature flag disabled
}
# Build with feature enabled
go build -tags=feature_x
# Test with feature enabled
go test -tags=feature_x ./...
# Test both configurations in CI
go test ./...
go test -tags=feature_x ./...
func TestFeatureBehavior(t *testing.T) {
handler := NewHandler()
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
// Behavior differs based on build tag
// Assert based on expected variant
}
References
See Also
- Testing Guide - General testing principles and practices
- CI Guide - Continuous Integration setup and best practices
Footnotes
-
golangci-lint - Fast Go linters aggregator with parallel execution and shared caching ↩ ↩2
-
gofmt - Official Go code formatter, part of the Go toolchain ↩ ↩2
-
goimports - Go formatter that also manages imports automatically ↩ ↩2
-
Staticcheck - Advanced Go static analysis tool for finding bugs and improving code quality ↩
-
deadcode - Tool to detect unused functions and methods in Go code ↩ ↩2
-
go test - Built-in Go testing command with coverage, benchmarking, and fuzzing support ↩ ↩2
-
govulncheck - Go vulnerability scanner from the Go security team ↩
-
Dependabot - GitHub’s automated dependency update tool ↩
-
chromedp - Chrome DevTools Protocol driver for browser automation in Go ↩ ↩2
-
godog - Cucumber/BDD framework for Go with Gherkin support ↩ ↩2
-
gomock - Mock framework for Go interfaces (uber-go fork of official golang/mock) ↩
-
gobreaker - Circuit breaker implementation for Go; current major is
github.com/sony/gobreaker/v2↩ -
golang.org/x/text - Go supplementary text processing packages for internationalization ↩ ↩2
-
golang-migrate - Database migration tool with support for multiple databases ↩