# Testing Strategy 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 output-validation, error-mapping, and view-shape patterns with worked examples, see [tdd-workflow.md §4 (mock decision tree)](./tdd-workflow.md). Related ADRs: [ADR-012](../decisions/adr-012-feature-conventions.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/models/.test.ts` | Vitest | Schema validation, type guards | | **Use case** | `packages//src/application/use-cases/.use-case.test.ts` | Vitest | Direct factory injection + output-validation | | **Controller** | `packages//src/interface-adapters/controllers/.controller.test.ts` | Vitest | Direct factory injection + input validation + 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 | 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 full chain via direct injection and test interactions between layers. ## Per-feature DI in tests ### Default: direct factory injection (use-case + controller tests) Use cases and controllers are factory functions. Tests construct mock repositories directly and pass them in — no container, no rebinding helpers. ```typescript // 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"; 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" }), ); 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 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 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 (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 | 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) | Runtime guarantee | — | inject malformed mock output → `.rejects.toBeInstanceOf(ZodError)` | | Controller (input) | Validation | — | `controller({} as unknown)` → `.rejects.toBeInstanceOf(InputParseError)` | | Controller (presenter) | View shape | when reshaping | `expect(result.name).toBe("session")` (not `result.session`) | | Repository contract | Interface conformance | — | run `defineContractSuite` against mock + real impl | | Router | Domain → TRPCError | — | `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 | **Output validation** — Every non-void use case ends with `xOutputSchema.parse(result)`. The test proves this: inject a mock that returns a structurally invalid object and assert `ZodError` propagates. **Error mapping** — Every feature has `procedures.ts` with a `defineErrorMiddleware` error map. The router test calls the tRPC procedure through `router.createCaller({})` and asserts the correct `TRPCError.code` (e.g. `NOT_FOUND`, `BAD_REQUEST`, `UNAUTHORIZED`). **View shape** — 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`: ```typescript import { defineConfig } from "vitest/config"; import path from "path"; export default defineConfig({ test: { environment: "node", globals: true, include: ["src/**/*.test.ts", "tests/**/*.test.ts"], setupFiles: [], }, resolve: { alias: { "@": path.resolve(__dirname, "./src"), }, }, }); ``` 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) Each app has `playwright.config.ts`: ```typescript import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./e2e", fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: "list", use: { baseURL: "http://localhost:3000", trace: "on-first-retry", }, projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"] }, }, ], webServer: { command: "pnpm dev", url: "http://localhost:3000", reuseExistingServer: !process.env.CI, timeout: 60_000, }, }); ``` The `webServer` block auto-starts the dev server before tests. Set `reuseExistingServer: true` locally to reuse a running dev server; CI always starts fresh. ## Smoke spec example `apps/web-next/e2e/home.spec.ts`: ```typescript import { test, expect } from "@playwright/test"; test("home page renders site name + nav", async ({ page }) => { await page.goto("/"); await expect(page.locator("h1").first()).toBeVisible(); await expect(page.locator("nav a").first()).toBeVisible(); }); ``` ## Running tests ```bash # All tests (unit + feature + e2e) pnpm test # Just unit tests pnpm test --filter "@repo/blog" -- src/ # Just feature tests pnpm test --filter "@repo/blog" -- tests/ # Just e2e pnpm test:e2e # E2E with UI pnpm test:e2e -- --ui ``` ## CI integration Root `package.json`: ```json { "scripts": { "test": "turbo run test", "test:e2e": "turbo run test:e2e" } } ``` Root `turbo.json`: ```json { "tasks": { "test": { "outputs": ["coverage/**"], "cache": false }, "test:e2e": { "dependsOn": ["^build"], "cache": false } } } ``` ## Key principles 1. **Colocated unit tests** validate single functions/classes in isolation 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** are used at the **router level**; use-case + controller tests inject mocks directly into the factory (no container) ## Instrumentation testing **No real Sentry in tests.** The `core-testing/setup/no-sentry.ts` guard mocks `@sentry/nextjs`, `@sentry/node`, and `@sentry/react` at the module level, so any code that imports them gets a no-op surface during vitest runs. Tests that want to assert specific Sentry SDK calls add their own `vi.mock(...)` per file. **Repository contracts assert span shape.** Every `__contracts__/-repository.contract.ts` includes a `span emission` describe block enumerating one assertion per public method. Suites run against both mock and real (Payload-backed) implementations, ensuring span emission stays in sync. Wire the recording tracer at the call site: ```ts const tracer = new RecordingTracer(); articlesRepositoryContract.run(() => new MockArticlesRepository(tracer), { tracer: () => tracer, }); ``` **Capture vs span assertions:** - `RecordingTracer.spans` — every span emitted with `{ name, op, attributes, status, durationMs }`. - `RecordingLogger.captures` — every `captureException` / `captureMessage` call. - `RecordingLogger.breadcrumbs` — every breadcrumb added. - `RecordingLogger.users` — every `setUser` call (history). - `RecordingAuditLog.entries` — every `record(entry)` call. Use to assert audit emissions in feature-package tests **without** importing `@repo/core-audit` (the recording double lives in `@repo/core-testing/instrumentation` and mirrors the `AuditEntry` shape inline to avoid the tooling→core boundary). Also exposes `RecordingAuditLog.erasures` for `eraseSubject` history. See ADR-018 and `docs/guides/audit-and-compliance.md` for what to assert. **Test cleanup:** call `tracer.reset()`, `logger.reset()`, and `auditLog.reset()` in `beforeEach` if the test creates one shared instance across multiple cases. 5. **Mock repos** are the default; only use real Payload in dedicated infrastructure tests