dual-testing · diff

v1.0.2 to v1.1.0

75 added, 182 removed. Audit A to A.

---
name: dual-testing
- version: 1.0.2
- description: "Go dual testing strategy: integration tests (testcontainers) verify full-chain wiring for happy paths, unit tests (testify/mock) verify error handling logic. Apply when designing test strategy for a Go project, creating a new handler or feature that needs tests, or deciding what type of test to write for a scenario. Triggers: 'dual testing', 'error path coverage', 'testcontainers vs mocks', 'what type of test', 'where should this test go', 'integration vs unit', creating Go handlers/features/workers that need tests. Does NOT trigger on: writing individual test assertions, renaming tests, test naming conventions (use test-namer for those)."
+ version: 1.1.0
+ description: "Language-agnostic strategy for testing code at the boundary with external infrastructure (databases, APIs, queues): integration tests with real infrastructure (e.g. Testcontainers) prove the full chain works for happy paths; unit/slice tests with mocks prove error-handling and mapping logic (domain error to status, input validation, infra failure). Works in any language/framework — Go, .NET/C#, Java, Python, TypeScript and more — with concrete references for Go, .NET (ASP.NET Core) and Java (Spring Boot) and an explicit path to adapt when no reference matches your language. Apply when designing a test strategy, creating a handler/feature/worker that needs tests, or deciding what type of test a scenario needs. Triggers: 'dual testing', 'integration vs unit', 'testcontainers vs mocks', 'what type of test', 'where should this test go', 'error path coverage'. Does NOT trigger on writing individual test assertions or test naming conventions (use test-namer for those)."
---
# Dual Testing
- Strategy for Go projects: **integration tests verify the full chain works, unit tests verify error handling logic.** Derived from real implementations across multiple Go microservices using Vertical Slice Architecture, Gin, pgx/pgxpool, MongoDB, and testcontainers-go.
-
- ## Core Rule
-
- Integration tests (testcontainers) and unit tests (mocks) have different responsibilities:
-
- - **Integration tests** prove that the **full chain** works end-to-end: HTTP request → middleware → handler → repository → database → response. They use real infrastructure via testcontainers.
- - **Unit tests** prove that the handler's **error handling logic** is correct: mapping domain errors to HTTP status codes, validating input, handling infrastructure failures gracefully. They use testify/mock.
-
- Some natural overlap on boundary scenarios (400, 404) is acceptable because the two test types exercise different concerns — integration proves the chain, unit proves the mapping. The anti-pattern to avoid is duplicating **happy paths** in both layers.
-
- ## Decision Table
-
- Use this to decide where a test scenario belongs:
-
- - **Happy path (successful CRUD)** — **Integration**: proves real wiring works
- - **Handler input validation → 400** — **Unit**: pure handler logic, no infra needed
- - **Middleware validation (e.g., idempotency key) → 400** — **Integration**: requires the real middleware chain
- - **Resource not found → 404** — **Both acceptable**: Integration proves the chain; Unit proves error mapping
- - **Database/infra error → 500** — **Unit**: cannot force reliably with real infra
- - **Circuit breaker open → 503** — **Unit**: resilience pattern (e.g., gobreaker)
- - **Context canceled → 499** — **Unit**: timeout handling
- - **Complex business logic (TTL defaults, OL)** — **Unit**: does not need real infra
- - **Idempotency / deduplication** — **Integration**: requires real state in DB
- - **Notifications (pg_notify, events)** — **Integration**: side effect of real infra
- - **Side effects in another DB (e.g., Mongo collections)** — **Integration**: verifies real effect
- - **Empty list serialization (`[]` vs `null`)** — **Integration**: contract verified with real DB
-
- For the expanded table with code examples, see [references/decision-table.md](references/decision-table.md).
-
- ## Testability Prerequisites
-
- For handlers to be testable with mocks, they need these patterns. The skill does not prescribe file layout (that is the job of a project architecture skill like `/vertical-slice-architecture`), only what is needed for dual testing to work.
-
- ### Interface per handler (ISP)
-
- Each handler defines a small interface for the data operations it needs — not a shared mega-interface:
-
- ```go
- type Repository interface {
- ListItems(ctx context.Context, ownerID string) ([]Item, error)
- }
-
- type handler struct {
- repo Repository
- }
- ```
-
- ### Pure constructor
-
- `NewRepository` wraps the pool without validation — no ping, no health check. This is important because some integration tests deliberately pass a closed pool to verify that errors surface at request time, not at setup time:
-
- ```go
- func NewRepository(db *pgxpool.Pool) Repository {
- return repository{db: db}
- }
- ```
-
- ### Setup with dependency injection
-
- A single `Setup` function that accepts the interface. The caller decides what to inject — real repo in production and integration tests, mock in unit tests:
-
- ```go
- func Setup(r *gin.RouterGroup, repo Repository) {
- h := handler{repo: repo}
- r.GET("/items/:ownerID", h.handle)
- }
- ```
-
- ### Hand-written mocks (testify/mock)
-
- One mock per interface in `mocks_test.go`. Add a nil check before type assertions only for return types that can be nil (slices, pointers, maps). String or primitive returns do not need the check:
-
- ```go
- // Slice return — needs nil check
- func (m *mockRepository) ListItems(ctx context.Context, ownerID string) ([]Item, error) {
- args := m.Called(ctx, ownerID)
- if args.Get(0) == nil {
- return nil, args.Error(1)
- }
- return args.Get(0).([]Item), args.Error(1)
- }
+ A language-agnostic strategy for code at the boundary between your application and external infrastructure: **integration tests prove the full chain works for happy paths; unit/slice tests prove the error-handling and mapping logic.** It aligns with the Testing Honeycomb / Test Diamond philosophy — boundary code is dominated by interaction complexity, so lean on integration for wiring and on fast mock-based tests for the branchy error logic.
- // String return — no nil check needed
- func (m *mockRepository) DeleteItem(ctx context.Context, id string) (string, error) {
- args := m.Called(ctx, id)
- return args.String(0), args.Error(1)
- }
- ```
+ The strategy is the same in every language. Only the tooling changes. Concrete per-language references are listed at the bottom; if none matches your language, the **Adapt When No Reference Exists** algorithm tells you how to apply it anyway.
- ### Transactional handlers
+ ## Definitions
- When a handler orchestrates a database transaction (Begin → queries → Commit), encapsulate the entire transaction in a single repository method. The handler only maps the result to an HTTP response. Use domain error sentinels to distinguish business errors (404) from infrastructure errors (500):
+ - **Integration test** — exercises the real application boundary plus a real external dependency (real database, broker, etc., typically via Testcontainers). Proves the wiring and the happy-path behavior end to end.
+ - **Unit/slice test** — the smallest test that still includes the mapping/adapter code (input validation, domain-error → outcome mapping), with the semantic dependencies mocked or faked. In some frameworks the mapping lives *outside* the handler class (an exception filter, `@ControllerAdvice`, error middleware), so the right test is a framework *slice* (in-process pipeline with the service mocked), not a pure class unit test.
+ - **The rule**: never duplicate happy paths in both layers. Integration proves the chain; unit/slice proves the mapping. Some overlap on boundary scenarios (e.g. not-found) is fine because they exercise different concerns.
- ```go
- var ErrNotFound = errors.New("resource not found")
+ ## Decision List
- // Repository method wraps the full transaction
- func (r repository) DeleteItem(ctx context.Context, id, idemKey string) (string, error) {
- tx, err := r.db.Begin(ctx)
- // ... query, enqueue, commit ...
- if errors.Is(err, pgx.ErrNoRows) {
- return "", ErrNotFound
- }
- return opID, nil
- }
+ Where a scenario belongs. The "outcome" is described generically — map it to your transport: HTTP status, gRPC status code, CLI exit code + stderr, or queue ack/nack/retry/dead-letter.
- // Handler just maps errors
- opID, err := h.repo.DeleteItem(ctx, id, idem)
- if err != nil {
- if errors.Is(err, ErrNotFound) {
- ctx.JSON(404, gin.H{"error": "not found"})
- return
- }
- ctx.JSON(500, gin.H{"error": "operation failed"})
- return
- }
- ```
+ - **Happy path (successful create/read/update/delete)** — **Integration**: proves real wiring. Outcome: success (e.g. HTTP 2xx).
+ - **Handler input validation (bad/missing field)** — **Unit/slice**: pure mapping logic, no infra. Outcome: client error (e.g. HTTP 400).
+ - **Framework/middleware/filter validation (idempotency key, body binding, content negotiation)** — **Integration or slice that includes that pipeline**: the check runs before your handler.
+ - **Auth/authz policy (middleware-owned)** — **Integration/slice including the middleware**.
+ - **Resource not found** — **Both acceptable**: integration proves the chain, unit/slice proves the mapping. Outcome: not-found (e.g. HTTP 404).
+ - **Infrastructure/DB error** — **Unit/slice**: cannot force reliably with real infra. Outcome: server error (e.g. HTTP 500, or queue nack→retry/DLQ).
+ - **Circuit breaker open / resilience pattern** — **Unit/slice**: deterministic only via a mock. Outcome: unavailable (e.g. HTTP 503).
+ - **Timeout / cancellation** — **Unit/slice**: drive via a cancelled context/token.
+ - **Idempotency / deduplication** — **Integration**: requires real state.
+ - **Notifications / events emitted** — **Integration**: side effect of real infra.
+ - **Side effects in another store** — **Integration**: verifies the real effect.
+ - **Empty-collection serialization (`[]` not `null`)** — **Integration**: contract verified with real data.
+ - **Pure business/domain logic (defaults, calculations, optimistic-lock version handling)** — **Ordinary unit tests with real objects**, no mocks or infra needed.
- ## Unit Tests with testify/suite
+ Do not bake specific status numbers into the strategy. HTTP `499` in particular is client-closed-request (nginx-specific), not a portable code — keep such codes in a transport-specific note, never in the portable list.
- Use `testify/suite` so that `SetupTest()` creates fresh mocks before each test:
+ ## Testability Prerequisites (Principles)
- ```go
- type HandlerSuite struct {
- suite.Suite
- repo *mockRepository
- gin *gin.Engine
- }
+ Make the boundary mockable without coupling tests to infrastructure:
- func TestHandlerSuite(t *testing.T) {
- suite.Run(t, new(HandlerSuite))
- }
+ - **Depend on a narrow semantic port.** Each handler needs a small abstraction for the data operations it uses (a use-case / port / repository interface), not the DB driver. If the project already has service interfaces, ports, mediators or handlers, mock *those* — do not add a redundant per-controller interface.
+ - **No external I/O at construction.** A factory/constructor that wraps a connection must not ping or validate at construction time — unless the framework explicitly owns startup validation (e.g. a DI-container health check). This lets a test inject a broken or mock dependency and exercise the error path at request time.
+ - **Inject the port at the wiring point.** The setup/registration code takes the abstraction; inject the real implementation in production and integration tests, a mock in unit/slice tests.
+ - **Mock the semantic port, not the driver.** Mock domain methods (`ListItems`, `DeleteItem`); never mock `Query`/`Rows`/`Tx`. Driver-level mocks test SQL strings, not behavior, and are fragile.
+ - **Encapsulate a transaction in one method.** When a handler orchestrates begin → queries → commit, put the whole transaction in one port method returning a domain result or a domain error. The handler only maps result → outcome.
+ - **Error model — two idioms, one decision.** Distinguish business errors (→ not-found / client error) from infrastructure errors (→ server error). Languages that *return* errors use sentinels/typed errors (Go `errors.Is(err, ErrNotFound)`, Rust `Result`); languages that *throw* use typed exceptions (`NotFoundException`) caught at the mapping layer (`@ControllerAdvice`, exception filter, error middleware). The mapping decision is identical; only the mechanism differs — and because the mapping layer is often a separate class, the test that covers it is usually a slice test.
- func (s *HandlerSuite) SetupTest() {
- s.repo = new(mockRepository)
- gin.SetMode(gin.TestMode)
- s.gin = gin.New()
- group := s.gin.Group("/v1")
- Setup(group, s.repo)
- }
+ ## Worker / Background-Job Variant
- func (s *HandlerSuite) Test_Returning_500_when_database_fails() {
- s.repo.On("ListItems", mock.Anything, "owner-1").
- Return(nil, errors.New("connection refused"))
+ The same split applies to consumers and workers:
- req, _ := http.NewRequest(http.MethodGet, "/v1/items/owner-1", nil)
- w := httptest.NewRecorder()
- s.gin.ServeHTTP(w, req)
+ - One **integration** test drives a real message/job through the real dispatcher and real infra (happy path).
+ - **Unit/slice** tests mock the dispatch protocol to prove failure handling (retry, dead-letter, status transitions).
+ - Create a **fresh dispatcher/engine per test** when registration is one-shot — some dispatchers reject or panic on duplicate handler registration.
+ - Mock the **full dispatch protocol** the worker depends on (claim/next, resolve target, set status, succeed/fail).
- s.Equal(http.StatusInternalServerError, w.Code)
- s.repo.AssertExpectations(s.T())
- }
- ```
+ ## Recommendations
- ## Worker Variant
+ Trade-offs, not hard rules:
- Background workers that process operations via an engine dispatch loop (ClaimNext → handler → Succeed/Fail) need special handling:
+ - **Fixed error strings on server errors** for contract-stable endpoints — do not leak internal error text. This keeps integration tests from coupling to internal messages.
+ - **Empty collections serialize as `[]`, not `null`** — verify in integration. (The language-specific mechanics, if any, live in the per-language reference.)
+ - **Update the dependency manifest/lockfile after adding a mocking library** (and any tidy/restore step), or CI may silently skip the new tests.
- - `SetupTest()` must create a **fresh engine** on every test — `Engine.Handle()` panics if you register the same handler type twice
- - Mocks need expectations for the full dispatch protocol: `ClaimNext`, `ResolveTarget`, `SetKVSStatus`, `Succeed` or `Fail`
- - For applier-factory failure tests, create a separate engine with a factory that returns an error
+ ## When This Strategy Does NOT Apply
- ```go
- func (s *WorkerSuite) SetupTest() {
- s.repo = new(MockOperationRepository)
- s.applier = new(MockApplier)
- s.engine = worker.New(s.repo, 30*time.Second) // fresh engine
- feature.Setup(s.engine, s.repo, func(ctx context.Context, uri string) (feature.ApplierInterface, error) {
- return s.applier, nil
- })
- }
- ```
+ - **Pure functions** (no external dependencies) — output-based tests directly.
+ - **Domain logic** without infrastructure — unit tests with real objects, no mocks.
+ - **Utility code** — simple tests, no strategy needed.
- See [references/patterns.md](references/patterns.md) for complete worker test templates.
+ The dual strategy is specifically for code that sits at the boundary between your application and external infrastructure (databases, APIs, message queues).
- ## Recommendations
+ ## Language References
- These are trade-offs, not hard rules:
+ **If a reference matches your language or framework, read only that reference before writing tests.** The core above tells you *what* belongs in each layer; the reference gives the idiomatic tooling, test shape, and language-specific gotchas — the core alone is not enough to get the idioms right.
- - **Fixed error messages in 500 responses**: For mutation endpoints where the API contract matters, consider returning a fixed error string instead of exposing `err.Error()` from the repository. This prevents coupling integration tests to internal error messages. Example: `ctx.JSON(500, gin.H{"error": "operation failed"})` instead of `ctx.JSON(500, gin.H{"error": err.Error()})`.
- - **Empty slices**: Use `make([]T, 0)` instead of `var out []T` in repository list methods. This serializes as `[]` in JSON, not `null`. Integration tests should verify this contract.
- - **go.mod**: The first time you add `testify/mock` to a project that only used `testify/suite` or `testify/require`, run `go mod tidy` — `testify/mock` brings `github.com/stretchr/objx` as an indirect dependency.
+ - **Go** (Gin, pgx, testify, testcontainers-go) — [references/go.md](references/go.md)
+ - **.NET / C#** (ASP.NET Core; xUnit, Testcontainers for .NET, `WebApplicationFactory`, Moq) — [references/dotnet.md](references/dotnet.md)
+ - **Java** (Spring Boot; JUnit 5, Testcontainers `@ServiceConnection`, MockMvc, Mockito) — [references/java.md](references/java.md)
- ## When This Strategy Does NOT Apply
+ ### Adapt When No Reference Exists
- Not every test scenario needs the dual approach:
+ No reference for your language/framework? **Do not skip the strategy — adapt it.** Follow this algorithm:
- - **Pure functions** (no external dependencies): Use output-based tests directly. See `/test-namer` for guidance on testing styles.
- - **Domain logic** without infrastructure dependencies: Unit tests with real objects, no mocks needed.
- - **Utility code**: Simple tests, no strategy needed.
+ 1. Identify the **boundary adapter** (handler/controller/consumer) and its **transport contract** (HTTP/gRPC/CLI/queue).
+ 2. Identify the **real external dependencies** (DB, cache, broker, external API).
+ 3. Write **one happy-path test** through the real wiring and real infrastructure. Testcontainers has libraries for most languages (Java, .NET, Go, Node, Python, Rust, and more); otherwise use an equivalent ephemeral real dependency and state the confidence gap.
+ 4. Put **each error-mapping branch** in the smallest unit/slice test that still includes the mapping code.
+ 5. **Mock the semantic ports/use-cases** — never the DB driver or framework internals.
+ 6. Keep the **Decision List** above. Only the tooling changes, not the strategy.
- The dual testing strategy is specifically for code that sits at the boundary between your application and external infrastructure (databases, APIs, message queues).
+ ## Composing with Other Skills (Optional)
- ## Composing with Other Skills
+ This skill is self-contained. If you also use them, these compose cleanly:
- This skill decides **where** and **what type** of test to write. Other skills handle the rest:
+ - `/test-namer` — how to name the test (this skill decides where it goes; test-namer decides what to call it).
+ - `/vertical-slice-architecture` — how to structure the feature directory.
+ - `/low-complexity` — keeps test functions readable.
- - `/test-namer`: Decides **how to name** the test. Example: dual-testing says "this error path goes in handler_test.go as a unit test"; test-namer says "name it `Test_Returning_500_when_database_fails`".
- - `/vertical-slice-architecture`: Decides **how to structure** the feature directory. Dual-testing decides which test files to create within that structure.
- - `/low-complexity`: Applies to all code including tests — keeps test functions readable.
+ None are required to apply dual testing.
## Checklist
- For each new handler or feature, verify:
+ For each new handler, feature, or worker:
- - [ ] Handler has an interface for each external dependency (ISP)
- - [ ] Repository with concrete implementation and `NewRepository()` constructor
- - [ ] `Setup()` receives the interface, not a concrete type
- - [ ] `mocks_test.go` with hand-written mock (testify/mock)
- - [ ] `handler_test.go` with unit tests (testify/suite) for each `if err != nil` branch in the handler
- - [ ] `integration_test.go` with happy paths using testcontainers
- - [ ] Happy paths are NOT duplicated in unit tests
+ - [ ] Each handler depends on a narrow semantic port (or reuses an existing one), not the DB driver
+ - [ ] The wiring point injects the port — real in prod/integration, mock in unit/slice
+ - [ ] Constructors/factories do no external I/O at construction (unless the framework owns startup validation)
+ - [ ] One integration test per happy path, through real wiring and real infrastructure
+ - [ ] One unit/slice test per error-mapping branch (validation, not-found, infra error, resilience), with the port mocked
+ - [ ] Happy paths are NOT duplicated in the unit/slice layer
+ - [ ] The matching language reference was read — or, if none exists, the 6-step adaptation was applied