diff --git a/docs/guides/testing-strategy.md b/docs/guides/testing-strategy.md index 01ff943..fd3c3aa 100644 --- a/docs/guides/testing-strategy.md +++ b/docs/guides/testing-strategy.md @@ -1,65 +1,181 @@ # Testing Strategy -A layered approach: per-feature DI containers + colocated unit tests + Playwright e2e. +A layered approach: direct factory injection + colocated unit tests + Playwright e2e. For the *how* of TDD (red-green-refactor cycle, when to mock, what NOT to test), see [tdd-workflow.md](./tdd-workflow.md). This document covers test *placement* and infrastructure. +For the full R25 / R26 / R27 / R28 patterns with worked examples, see [tdd-workflow.md §4 (mock decision tree)](./tdd-workflow.md). + +Related ADRs: [ADR-012](../decisions/adr-012-lazar-conformance.md) — Clean Architecture conformance; [ADR-013](../decisions/adr-013-input-output-unification.md) — input/output unification + presenter + error middleware. + ## Test placement | Level | Location | Tool | Example | |---|---|---|---| -| **Unit (colocated)** | `packages//src/entities/article.test.ts` | Vitest | Schema validation, type guards | -| **Feature level** | `packages//tests/feature/.feature.test.ts` | Vitest + DI container | Use cases, controllers, integration | -| **E2E (app)** | `apps/web-next/e2e/blog.spec.ts` | Playwright | Full user flow across frontend + backend | +| **Unit (colocated)** | `packages//src/entities/models/.test.ts` | Vitest | Schema validation, type guards | +| **Use case** | `packages//src/application/use-cases/.use-case.test.ts` | Vitest | Direct factory injection + R25 output-validation | +| **Controller** | `packages//src/interface-adapters/controllers/.controller.test.ts` | Vitest | Direct factory injection + R10 input validation + R27/R28 view shape | +| **Repository contract** | `packages//src/infrastructure/repositories/.repository.test.ts` | Vitest + contract suite | Interface conformance | +| **Router (integration)** | `packages//src/integrations/api/router.test.ts` | Vitest + container | R26 domain → TRPCError mapping | +| **Feature level** | `packages//tests/.feature.test.ts` | Vitest | Cross-layer integration via direct injection | +| **E2E (app)** | `apps/web-next/e2e/.spec.ts` | Playwright | Full user flow across frontend + backend | -**Colocated vs feature-level:** Colocated tests (`*.test.ts` next to source) test isolated units. Feature-level tests (`tests/` folder) wire the DI container and test interactions between layers. +**Colocated vs feature-level:** Colocated tests (`*.test.ts` next to source) test isolated units. Feature-level tests (`tests/` folder) wire the full chain via direct injection and test interactions between layers. ## Per-feature DI in tests -Each feature owns its own container with mock implementations. Tests can rebind specific implementations without touching other features. +### Default: direct factory injection (use-case + controller tests) -Create `packages//tests/feature-test-setup.ts`: +Use cases and controllers are factory functions. Tests construct mock repositories directly and pass them in — no container, no rebinding helpers. ```typescript -import { container } from "@/di/container.js"; -import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface.js"; -import { ARTICLES_REPOSITORY } from "@/di/symbols.js"; +// packages/blog/src/application/use-cases/get-articles.use-case.test.ts +import { describe, it, expect } from "vitest"; +import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case"; +import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock"; +import { articleFactory } from "@/__factories__/article.factory"; -// Create a scoped test container -export function getTestContainer() { - return container; -} +describe("getArticlesUseCase", () => { + it("filters by status", async () => { + const repo = new MockArticlesRepository(); + articleFactory.reset(); + await repo.createArticle(articleFactory.build({ id: "1", status: "draft" })); + await repo.createArticle(articleFactory.build({ id: "2", status: "published" })); -// Rebind a specific implementation for a test -export function rebindRepository(impl: IArticlesRepository) { - container.rebind(ARTICLES_REPOSITORY).toConstantValue(impl); -} -``` - -Use in test: - -```typescript -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { getTestContainer, rebindRepository } from "./feature-test-setup.js"; -import { CreateArticleUseCase } from "@/application/use-cases/create-article.use-case.js"; -import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository.js"; - -describe("CreateArticleUseCase", () => { - let container: Container; - - beforeEach(() => { - container = getTestContainer(); - rebindRepository(new MockArticlesRepository()); // Fresh mock per test - }); - - it("creates an article", async () => { - const useCase = container.get(CreateArticleUseCase); - const result = await useCase.execute({ title: "Test" }); - expect(result.title).toBe("Test"); + const useCase = getArticlesUseCase(repo); // direct injection + const result = await useCase({ status: "published" }); + expect(result).toHaveLength(1); }); }); ``` +Controller tests follow the same pattern — construct the use case with mocks, then construct the controller with that use case: + +```typescript +// packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts +import { signInController } from "@/interface-adapters/controllers/sign-in.controller"; +import { signInUseCase } from "@/application/use-cases/sign-in.use-case"; +import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; +import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; + +describe("signInController", () => { + it("returns a cookie on successful sign-in", async () => { + const users = new MockUsersRepository([]); + const auth = new MockAuthenticationService(users); + const useCase = signInUseCase(users, auth); + const controller = signInController(useCase); // direct injection + + const result = await controller({ username: "alice", password: "testpassword" }); + expect(result.name).toBe("session"); // presenter view shape (R27) + expect(result.value).toBeTruthy(); + }); +}); +``` + +There is no `getTestContainer()` or `rebindRepository()` helper in use-case or controller tests. Those helpers are no longer needed. + +### Router-level tests: container rebind + +The tRPC router resolves controllers from the feature's DI container (a singleton). Router tests must reload the container state around each test: + +```typescript +// packages/blog/src/integrations/api/router.test.ts +import { beforeEach, afterEach, describe, it, expect } from "vitest"; +import { TRPCError } from "@trpc/server"; +import { blogContainer } from "@/di/container"; +import { BlogModule } from "@/di/module"; +import { blogRouter } from "@/integrations/api/router"; + +describe("blogRouter (R26 error mapping)", () => { + beforeEach(() => { + blogContainer.unbindAll(); + blogContainer.load(BlogModule); // loads default mock bindings + }); + + afterEach(() => { + blogContainer.unbindAll(); + }); + + it("translates ArticleNotFoundError → NOT_FOUND", async () => { + const caller = blogRouter.createCaller({}); + await expect(caller.articleBySlug({ slug: "missing" })) + .rejects.toSatisfy((e: unknown) => + e instanceof TRPCError && e.code === "NOT_FOUND" + ); + }); +}); +``` + +When a test needs a specific repo behaviour at the router level, bind it directly: + +```typescript +beforeEach(() => { + blogContainer.unbindAll(); + blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(new AlwaysEmptyRepo()); +}); +``` + +This is the **only** place container-level rebinding belongs. Use-case and controller tests never touch the container. + +## Mocking Payload in feature tests + +### Option 1: Direct factory injection of mock repos (default) + +Construct a `MockXRepository` and pass it directly to the factory: + +```typescript +const repo = new MockArticlesRepository(); +// seed data +await repo.createArticle(articleFactory.build({ status: "published" })); + +const useCase = getArticlesUseCase(repo); +const result = await useCase({}); +expect(result).toHaveLength(1); +``` + +The mock repository satisfies the repository interface without touching Payload at all. + +### Option 2: Router-level — use the feature's DI container + +When testing the tRPC router (R26 error-mapping, procedure wiring), bind a mock or stub repository through the feature container in `beforeEach`: + +```typescript +beforeEach(() => { + blogContainer.unbindAll(); + blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository) + .toConstantValue(new MockArticlesRepository()); +}); +``` + +For infrastructure tests that exercise the Payload repository implementation directly, mock the `payload` module: + +```typescript +import { vi } from "vitest"; +vi.mock("payload", () => ({ getPayload: vi.fn() })); +``` + +Then provide a stub via `stubPayloadConfig` from `@repo/core-testing/payload` (see [tdd-workflow.md §4](./tdd-workflow.md) for the full contract-suite pattern). + +## Test obligations per layer (Plan 9) + +| Layer | Test type | Required by spec | Example | +|---|---|---|---| +| Entity | Schema validation | — | `articleSchema.safeParse(...)` → accepts valid / rejects invalid | +| Use case (input) | Behavior | — | factory injection; assert result shape and filtering | +| Use case (output, R25) | Runtime guarantee | R25 | inject malformed mock output → `.rejects.toBeInstanceOf(ZodError)` | +| Controller (input, R10) | Validation | R10 | `controller({} as unknown)` → `.rejects.toBeInstanceOf(InputParseError)` | +| Controller (presenter, R27/R28) | View shape | R27/R28 (when reshaping) | `expect(result.name).toBe("session")` (not `result.session`) | +| Repository contract | Interface conformance | — | run `defineContractSuite` against mock + real impl | +| Router (R26) | Domain → TRPCError | R26 | `xRouter.createCaller({}).x(...)` → assert `TRPCError.code` | +| Feature-level (`tests/`) | Cross-layer integration | — | wire the chain via direct injection (no container) | +| E2E | Full user flow | — | Playwright | + +**R25** — Every non-void use case ends with `xOutputSchema.parse(result)`. The R25 test proves this: inject a mock that returns a structurally invalid object and assert `ZodError` propagates. + +**R26** — Every feature has `procedures.ts` with a `defineErrorMiddleware` error map. The R26 test calls the tRPC procedure through `router.createCaller({})` and asserts the correct `TRPCError.code` (e.g. `NOT_FOUND`, `BAD_REQUEST`, `UNAUTHORIZED`). + +**R27/R28** — Controllers that reshape the use-case output (e.g. auth controllers that return a cookie instead of the full session object) must have a test asserting the view shape — not the raw use-case output. + ## Vitest setup per package Each feature package has `vitest.config.ts`: @@ -83,7 +199,9 @@ export default defineConfig({ }); ``` -The `@/` alias resolves to `src/` — use it in tests to import from the feature: `import { Article } from "@/entities/index.js"`. +The `@/` alias resolves to `src/` — use it in **test files only** to import from the feature: `import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case"`. Source files (`src/`) must use relative imports (`../../repositories/...`), not the `@/` alias. + +Also set `"rootDir": "."` in the package's `tsconfig.json` so TypeScript finds both `src/` and test files that sit at the package root. ## Playwright setup (apps) @@ -134,53 +252,6 @@ test("home page renders site name + nav", async ({ page }) => { }); ``` -## Mocking Payload in feature tests - -**Option 1: Mock at DI level** (preferred) - -Create a test mock repository and rebind it: - -```typescript -class TestArticlesRepository extends MockArticlesRepository { - // Override behaviors as needed for the test - async getPublished() { - return [{ id: "1", title: "Published", status: "published", ... }]; - } -} - -beforeEach(() => { - rebindRepository(new TestArticlesRepository()); -}); -``` - -**Option 2: Mock the payload module** (for infrastructure tests) - -Edit `packages/blog/vitest.config.ts`: - -```typescript -export default defineConfig({ - test: { - // ... - globals: true, - // Mock payload module globally - mockReset: true, - }, -}); -``` - -In your test file: - -```typescript -import { vi } from "vitest"; -import { getPayload } from "payload"; - -vi.mock("payload", () => ({ - getPayload: vi.fn().mockResolvedValue({ - findByID: vi.fn().mockResolvedValue({ id: "1", title: "Article" }), - }), -})); -``` - ## Running tests ```bash @@ -233,7 +304,7 @@ Root `turbo.json`: ## Key principles 1. **Colocated unit tests** validate single functions/classes in isolation -2. **Feature-level tests** exercise the full feature with mocked repos +2. **Feature-level tests** exercise the full feature with mocked repos (direct injection, no container) 3. **E2E tests** prove the app works end-to-end (minimal smoke specs initially) -4. **Per-feature containers** allow tests to rebind without global state -5. **Mock repos** are the default; only use real Payload in dedicated integration tests +4. **Per-feature containers** are used at the **router level**; use-case + controller tests inject mocks directly into the factory (no container) +5. **Mock repos** are the default; only use real Payload in dedicated infrastructure tests