Reference: See reference.md for comprehensive testutils patterns and DSL examples.
Ready after tests? Run linter: task lintwithfix
Prefer real implementations over mocks
- Use in-memory implementations (fastest, no external deps)
- Use HTTP test servers (httptest)
- Use temp files/directories
- Test with actual dependencies when beneficial
Coverage targets
- Leaf types: 100% unit test coverage
- Orchestrating types: Integration tests
- Critical workflows: System tests
Dependency Priority (choose appropriate level):
- In-memory (fastest): Pure Go, httptest, in-memory DB - use when testing your code's logic
- Binary (isolated): Standalone executable via exec.Command - use when testing against real service
- Test-containers (realistic): Programmatic Docker from Go - use when you need real external services
- Docker-compose (full stack): For complex multi-service scenarios
Choose based on what you're testing, not dogmatically. In-memory is fastest but sometimes you need real services.
See reference.md for comprehensive testutils patterns and DSL examples.
- Identify leaf types - Self-contained types with logic
- Choose structure - Table-driven (simple) or testify suites (complex setup)
- Write in pkg_test package - Test public API only
- Use in-memory implementations - From testutils or local implementations
- Avoid pitfalls - No time.Sleep, no conditionals in cases, no private method tests
Test structure:
- Table-driven: Separate success/error test functions (complexity = 1)
- Testify suites: Only for complex infrastructure setup (HTTP servers, DBs)
- Always use named struct fields (linter reorders fields)
See reference.md for detailed patterns and examples.
- Identify integration points - Where packages/components interact
- Choose dependencies - Prefer: in-memory > binary > test-containers
- Write tests - In
pkg_testorintegration_test.gowith build tags - Test workflows - Cover happy path and error scenarios across boundaries
- Use real or testutils implementations - Avoid heavy mocking
File organization:
//go:build integration
package user_test
// Test Service + Repository + real/mock dependencies
See reference.md for integration test patterns with dependencies.
- Place in tests/ folder - At project root, separate from packages
- Test via CLI/API - exec.Command for CLI, HTTP client for APIs
- Choose dependency level based on what you're testing:
- In-memory: Fastest, use when testing your code's behavior
- Binary: exec.Command to run real executables in separate process
- Test-containers: When you need real external services (DB, message queue)
- Test critical workflows - User journeys, not every edge case
Example with in-memory mock:
// tests/cli_test.go - Testing CLI against mock API
func TestCLI_UserWorkflow(t *testing.T) {
mockAPI := testutils.NewMockServer().
OnGET("/users/1").RespondJSON(200, user).
Build() // In-memory httptest.Server
defer mockAPI.Close()
cmd := exec.Command("./myapp", "get-user", "1",
"--api-url", mockAPI.URL())
output, err := cmd.CombinedOutput()
// Assert on output
}
Example with binary executable:
// tests/integration_test.go - Testing against real service binary
func TestSystem_WithRealService(t *testing.T) {
// Start service binary in background
svc := exec.Command("./myservice", "--port", "8080")
svc.Start()
defer svc.Process.Kill()
// Wait for service to be ready
waitForHealthy(t, "http://localhost:8080/health")
// Run tests against real service
resp, err := http.Get("http://localhost:8080/api/users")
// Assert on response
}
See reference.md for comprehensive system test patterns including test-containers.
// BAD - wantErr adds conditional, complexity > 1
tests := []struct {
input string
want string
wantErr bool // NEVER DO THIS
}{...}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Parse(tt.input)
if tt.wantErr { // <- Conditional! Complexity > 1
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.want, got)
}
})
}
// GOOD - Separate functions, complexity = 1
func TestParse_Success(t *testing.T) {
tests := []struct {
name string
input string
want string
}{...}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Parse(tt.input)
require.NoError(t, err) // No conditionals
require.Equal(t, tt.want, got)
})
}
}
func TestParse_Error(t *testing.T) {
tests := []struct {
name string
input string
}{...}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Parse(tt.input)
require.Error(t, err) // No conditionals
})
}
}
Testify Suites:
- Only for complex infrastructure (HTTP servers, DBs, OpenTelemetry)
- SetupSuite/TearDownSuite for expensive shared setup
- SetupTest/TearDownTest for per-test isolation
Synchronization:
- Never use time.Sleep (flaky, slow)
- Use channels with select/timeout for async operations
- Use sync.WaitGroup for concurrent operations
See reference.md for complete patterns with code examples.
TESTING COMPLETE
Unit Tests:
- user/user_id_test.go: 100% (4 test cases)
- user/email_test.go: 100% (6 test cases)
- user/service_test.go: 100% (8 test cases)
Integration Tests:
- user/integration_test.go: 3 workflows tested
- Dependencies: In-memory DB, httptest mock server
System Tests:
- tests/cli_test.go: 2 end-to-end workflows (in-memory mocks)
- tests/api_test.go: 1 full API workflow (binary executable)
- tests/db_test.go: 1 database workflow (test-containers)
Test Infrastructure:
- internal/testutils/httpserver: In-memory mock API with DSL
- internal/testutils/mockdb: In-memory database mock
- internal/testutils/containers: Test-container helpers
Test Execution:
$ go test ./... # All tests (in-memory only)
$ go test -tags=integration ./... # Include integration tests
$ go test ./tests/... # System tests (may need containers)
All tests pass
100% coverage on leaf types
Next Steps:
1. Run linter: task lintwithfix
2. If linter fails → use @refactoring skill
3. If linter passes → use @pre-commit-review skill
See reference.md for complete testing guidelines and examples.
- All unit tests in pkg_test package testing public API only
- Table-driven tests use named struct fields
- No wantErr bool - success and error cases in separate test functions
- Cyclomatic complexity = 1 inside t.Run() (no if/else, no switch)
- Leaf types have 100% coverage
- Integration tests cover component seams
- System tests in tests/ folder with appropriate dependency level
- No time.Sleep (using channels/waitgroups)
- Tests pass and linter approves