Reference: See reference.md for comprehensive testutils patterns and DSL examples.
Ready after tests? Run linter: task lintwithfix
No mocks — and a struct that only satisfies a production interface in a test IS a mock
- A "fake" is a real implementation with fake data (embedded DB,
httptestserver, fake binary, temp dir) — NOT a struct written to satisfy a dependency interface. - Terminology: the banned "mock" is an interface-injected struct double. The "in-memory mock servers" elsewhere in this skill (testutils DSL,
httptestwrappers) are fakes in this sense — real servers speaking the real protocol with configurable fake data — and remain the recommended stand-in for external APIs you don't control (wired via URL/config, never via a production interface). - Use in-memory implementations (fastest, no external deps), HTTP test servers (httptest), temp files/directories, or the real dependency.
- Orchestrators are tested by wiring their real collaborators (real Store/Evaluator over embedded DB +
httptestexternal services), never by injecting doubles. - If you are tempted to add an interface so a test can inject a fake, stop — that interface is a test-only smell. Depend on the concrete type instead (see @code-designing and
../../rules/R6-test-only-interfaces.md).
Coverage targets
- Rung 0 (leaf types): 100% unit test coverage
- Higher rungs (orchestrating types): cover the delta each rung adds — its seams and emergent behaviors
- Critical workflows: top-rung (system) tests
Assertions: testify is the default, but project convention wins (e.g. goweka uses stdlib assertions) — match the codebase you're in.
Rung 0 — pure leaf types. No I/O, no goroutines, no production dependencies. Tests are plain constructions plus assertions: slice literals, value tables. 100% coverage is expected here — leaf types own most of the logic.
Each rung above adds exactly one real production layer — the real implementation, never a mock. In-memory/in-process infrastructure counts as the real layer: httptest server, bufconn gRPC, in-memory NATS, temp files, embedded VictoriaMetrics.
Fake only the true external boundary — the thing you genuinely cannot run in-process (a third-party SaaS API, a hardware device). Everything inside the boundary composes real.
Placement rule: test each behavior at the lowest rung that contains it. A behavior expressible at rung 0 never gets tested through a rung-2 harness.
Each rung tests its delta plus emergent behaviors: the wiring/seams that rung
adds and behaviors that only exist through composition — not a re-test of
lower-rung logic (some overlap with leaf coverage is acceptable for orchestrators,
per ../../rules/R7-test-placement.md).
The top rung is the whole system composed: black-box tests from tests/ via
CLI/API, only the external boundary faked.
Obligation table — a template; adapt the rows per project and keep the adapted table in the project docs:
| Kind of change | Owes a test at |
|---|---|
| New leaf type, or new behavior on one | Rung 0 |
| New seam between components X and Y | Rung 1 — the first rung containing the seam |
| New wiring through an infrastructure layer (queue, DB, RPC) | The rung that adds that layer |
| New externally observable behavior | Top rung |
The ladder is defined here; the placement review contract (falsifying questions)
lives in ../../rules/R7-test-placement.md.
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.
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