Go Testing
Run and write tests for Go projects following best practices.
When This Skill MUST Be Used
ALWAYS invoke this skill when the user's request involves ANY of these:
- •Running
go testor asking to run tests - •Writing or modifying
*_test.gofiles - •Setting up integration tests with databases
- •Test coverage analysis
- •Benchmarking Go code
- •Mocking or test fixtures
- •Table-driven tests
- •Race condition detection
- •Test structure (GIVEN-WHEN-THEN)
If you're about to run or write Go tests, STOP and use this skill first.
Critical Safety Rules
NEVER:
- •Run integration tests without checking for required env vars
- •Skip the
-raceflag in CI/CD pipelines - •Leave test containers running after tests
- •Commit code with failing tests
- •Use
require/Fatalinside goroutines (causes panic) - •Share mocks, loggers, or DB connections between parallel tests
ALWAYS:
- •Use
t.Parallel()for independent tests AND subtests - •Clean up test resources with
t.Cleanup() - •Use build tags to separate unit and integration tests
- •Check test coverage for new code
- •Mark helper functions with
t.Helper()
Quick Reference
| Task | Command |
|---|---|
| Run all tests | go test ./... |
| Verbose output | go test -v ./... |
| With coverage | go test -cover ./... |
| With race detection | go test -race ./... |
| Run specific test | go test -run TestName ./... |
| Run benchmarks | go test -bench=. ./... |
| Force re-run | go test -count=1 ./... |
Test Structure: GIVEN-WHEN-THEN
- •GIVEN: Setup and preconditions (use
require- stop if setup fails) - •WHEN: Single action being tested (use
require- stop if action fails) - •THEN: Assertions on results (use
assert- see all failures)
Critical: Only ONE WHEN section per test. Multiple behaviors = multiple tests.
require vs assert
| Phase | Use | Rationale |
|---|---|---|
| GIVEN | require | Stop if setup fails |
| WHEN | require | Stop if action fails |
| THEN | assert | See all failures |
| Goroutines | assert | NEVER use require (causes panic) |
Goroutine Warning: Never use require/Fatal inside goroutines, HTTP handlers, or callbacks - causes "FailNow called from non-test goroutine" panic.
Parallel Execution
- •Always use
t.Parallel()in tests AND subtests - •Each test creates its own fixture - no shared state
- •Subtests must NOT access parent test variables
Forbidden Sharing:
- •Loggers, database connections, singletons
- •File handles, network ports
- •Mock expectations defined in parent scope
Acceptable Sharing:
- •Read-only table data in table-driven tests
- •Global constants, types, and functions
File Naming
| Type | File Suffix | Package Declaration | Access |
|---|---|---|---|
| Public tests | *_test.go | package foo_test | Exported only (preferred) |
| Private tests | *_internal_test.go | package foo | All members |
| Integration | *_integration_test.go | //go:build integration | Real deps |
Default: Use public tests. Add private tests only for internal invariants or combinatorial explosion.
Test Naming
- •Functions:
Test<Type>_<Method>(e.g.,TestUserService_CreateUser) - •Subtests: Short, descriptive (e.g.,
"with valid input","returns error")
Table-Driven Tests
When to Use
- •Simple data variations (primitives only)
- •Boundary conditions and validation
- •3-4 fields maximum in table struct
The Simplicity Test
"Can I understand what each test case does by looking at one line in the table?"
If NO, use separate tests instead.
Anti-patterns (Never Put in Tables)
| Anti-pattern | Why Bad |
|---|---|
| Mock expectations | Behavior, not data |
| Boolean flags | Creates different test logic |
| Setup/assertion functions | Hides complexity |
| Auto-generated templates | Generic, not thoughtful |
Fixture Pattern
type fixture struct {
ctx context.Context
mockRepo *MockRepository
sut *Service
}
func newFixture(t *testing.T) *fixture {
t.Helper()
ctrl := gomock.NewController(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
t.Cleanup(cancel)
mockRepo := NewMockRepository(ctrl)
return &fixture{
ctx: ctx,
mockRepo: mockRepo,
sut: NewService(mockRepo),
}
}
Mocking
- •Define interfaces in
interfaces.go - •Add
//go:generate mockgendirective - •Store mocks in
mock_test.gowithpackage foo_test - •
gomock.Any()when values don't matter - •
gomock.Eq()for exact matching in happy path - •Create new mock controller per test/subtest
Must Constructor Pattern
For test helpers that panic on error (never in production):
// Production: returns error
func NewCategoryID(value string) (CategoryID, error)
// Tests only: panics on error
func MustCategoryID(value string) CategoryID {
id, err := NewCategoryID(value)
if err != nil {
panic(err)
}
return id
}
Standard library examples: regexp.MustCompile, template.Must
Deterministic Tests
Inject all nondeterminism:
| Source | Solution |
|---|---|
time.Now() | Clock interface |
uuid.New() | UUIDGenerator interface |
| Random numbers | Injectable generator |
Contract Testing (Marshal/Unmarshal)
- •Use raw JSON strings as the "contract"
- •Don't use internal types for both encode and decode
- •Use
testify.JSONEq()for comparison - •Store JSON contracts in
testdata/directory
Testing Conversion Layers
What to test in HTTP/gRPC handlers:
- •Request unmarshaling and argument passing
- •Error translation (domain errors -> protocol errors)
- •Response marshaling
Pattern: Mock domain layer, verify exact arguments, verify error codes.
Coverage
| Type | Goal | Build Tag |
|---|---|---|
| Unit tests | 100% (all deps mocked) | None |
| Integration tests | As appropriate | integration |
Running Tests
Basic Commands
# Unit tests only go test ./... # Unit tests with verbose output go test -v ./... # Unit tests with coverage go test -cover ./... # Unit tests with coverage report go test -coverprofile=coverage.out ./... go tool cover -html=coverage.out
Running Single Tests
# Run specific test by name go test -v -run TestOrderRepository ./internal/infra/mysqlrepo/... # Run tests matching pattern go test -v -run "TestOrder.*" ./... # Run specific subtest go test -v -run "TestOrderRepository/creates_order" ./...
Integration Tests
Integration tests typically use build tags to separate from unit tests:
# Run integration tests (requires database) go test -tags=integration -v ./... # Run integration tests for specific package go test -tags=integration -v ./internal/infra/mysqlrepo/... # With database DSN TEST_MYSQL_DSN="root:pass@tcp(localhost:3306)/testdb?parseTime=true" \ go test -tags=integration -v ./...
Docker-based Database for Tests
# Start MySQL container docker run -d --name test-mysql \ -e MYSQL_ROOT_PASSWORD=pass \ -e MYSQL_DATABASE=testdb \ -p 3306:3306 \ mysql:8 # Wait for MySQL to be ready until docker exec test-mysql mysqladmin ping -h localhost --silent; do sleep 1 done # Run integration tests TEST_MYSQL_DSN="root:pass@tcp(localhost:3306)/testdb?parseTime=true" \ go test -tags=integration -v ./... # Clean up docker rm -f test-mysql
Integration Test Setup
//go:build integration
package mysqlrepo_test
import (
"os"
"testing"
)
func TestMain(m *testing.M) {
dsn := os.Getenv("TEST_MYSQL_DSN")
if dsn == "" {
// Skip integration tests if DSN not set
os.Exit(0)
}
// Setup database connection
// Run migrations if needed
code := m.Run()
// Cleanup
os.Exit(code)
}
Coverage
# Generate coverage profile go test -coverprofile=coverage.out ./... # View coverage in terminal go tool cover -func=coverage.out # View coverage in browser go tool cover -html=coverage.out # Coverage for specific packages go test -coverprofile=coverage.out -coverpkg=./internal/... ./...
Race Detection
# Run tests with race detector go test -race ./... # Race detection with specific test go test -race -run TestConcurrentAccess ./...
Benchmarks
# Run all benchmarks go test -bench=. ./... # Run specific benchmark go test -bench=BenchmarkOrderCreation ./internal/domain/order/... # Benchmarks with memory allocation stats go test -bench=. -benchmem ./... # Run benchmark multiple times go test -bench=. -count=5 ./...
Test Caching
# Force re-run (disable cache) go test -count=1 ./... # Clean test cache go clean -testcache
Makefile Targets
.PHONY: test test-unit test-integration test-coverage test: test-unit test-unit: go test -v -race ./... test-integration: go test -tags=integration -v ./... test-coverage: go test -coverprofile=coverage.out ./... go tool cover -html=coverage.out -o coverage.html
Troubleshooting
| Issue | Cause | Fix |
|---|---|---|
| Tests skipped | Missing env vars | Set TEST_MYSQL_DSN etc. |
| Connection refused | DB not running | Start container, wait for ready |
| Race detected | Concurrent access | Add mutex or redesign |
| Flaky tests | Shared state / timing | Use t.Parallel(), fix timing |
| Slow tests | No parallelization | Add t.Parallel() |
| Cache issues | Stale results | Use -count=1 or clean cache |
| "FailNow from non-test goroutine" | require in goroutine | Use assert instead |
Example Requests
| User Request | Action |
|---|---|
| "Run the tests" | go test -v ./... |
| "Run tests with coverage" | go test -cover ./... then go tool cover -html |
| "Run only integration tests" | go test -tags=integration -v ./... |
| "Check for race conditions" | go test -race ./... |
| "Run a specific test" | go test -v -run TestName ./path/... |
| "Benchmark this function" | go test -bench=BenchmarkName -benchmem ./... |
| "Why are tests slow?" | Check for missing t.Parallel(), use -p 1 for isolation |
| "Write a test for this function" | Use GIVEN-WHEN-THEN structure with fixture pattern |
| "Should I use table-driven tests?" | Only if simple data variations, 3-4 fields max |