Direct factory injection is now the default mocking pattern (Plan 8). Container rebinding is reserved for router-level tests. rebindRepository helpers removed — use cases and controllers are factory functions; tests construct mocks and pass them in. New 'Test obligations per layer (Plan 9)' table maps each layer to its required test types: R10 (controller input), R25 (use-case output validation), R26 (router error mapping), R27/R28 (presenter view shape). Refactor log doc-update checklist: testing-strategy.md ticked.
311 lines
12 KiB
Markdown
311 lines
12 KiB
Markdown
# 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 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/<feature>/src/entities/models/<entity>.test.ts` | Vitest | Schema validation, type guards |
|
|
| **Use case** | `packages/<feature>/src/application/use-cases/<name>.use-case.test.ts` | Vitest | Direct factory injection + R25 output-validation |
|
|
| **Controller** | `packages/<feature>/src/interface-adapters/controllers/<name>.controller.test.ts` | Vitest | Direct factory injection + R10 input validation + R27/R28 view shape |
|
|
| **Repository contract** | `packages/<feature>/src/infrastructure/repositories/<impl>.repository.test.ts` | Vitest + contract suite | Interface conformance |
|
|
| **Router (integration)** | `packages/<feature>/src/integrations/api/router.test.ts` | Vitest + container | R26 domain → TRPCError mapping |
|
|
| **Feature level** | `packages/<feature>/tests/<name>.feature.test.ts` | Vitest | Cross-layer integration via direct injection |
|
|
| **E2E (app)** | `apps/web-next/e2e/<name>.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 (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`:
|
|
|
|
```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)
|
|
5. **Mock repos** are the default; only use real Payload in dedicated infrastructure tests
|