docs(guide): tdd-workflow.md — Plan 9 mocking + R25/R26/R27/R28 patterns

§1 worked example rewritten to direct factory injection (no container
rebinding). §4 mocking decision tree updated. New §sections for R25
output-validation tests, R26 router error-mapping tests, R27/R28
presenter-shape tests. §9 contract-suite paths use the post-Plan-8
.mock.ts suffix.

Refactor log doc-update checklist: tdd-workflow.md ticked.
This commit is contained in:
2026-05-06 16:48:39 +02:00
parent edc98f8f9a
commit ce294b3041
2 changed files with 1062 additions and 749 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -10,43 +10,37 @@ TDD in this monorepo is not dogma — it is a feedback mechanism. Writing a test
**Step 1 — Write the failing test (RED)**
`packages/blog/src/application/repositories/articles-repository.interface.ts` defines the `getArticleBySlug(slug: string): Promise<Article | undefined>` method. Before implementing anything, write a test that calls it through the use case or controller.
`packages/blog/src/application/repositories/articles.repository.interface.ts` defines `getArticleBySlug(slug: string): Promise<Article | undefined>`. Before implementing anything, write a controller test that constructs the dependencies directly — no container rebinding.
```typescript
// packages/blog/src/interface-adapters/controllers/articles.controller.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "../../di/container";
import { BLOG_SYMBOLS } from "../../di/symbols";
import { MockArticlesRepository } from "../../infrastructure/repositories/mock-articles.repository";
import type { IArticlesRepository } from "../../application/repositories/articles-repository.interface";
import { articleFactory } from "../../__factories__/article.factory";
import { getArticleBySlugController } from "./articles.controller";
// packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.test.ts
import { describe, expect, it } from "vitest";
import { getArticleBySlugController } from "@/interface-adapters/controllers/get-article-by-slug.controller";
import { getArticleBySlugUseCase } from "@/application/use-cases/get-article-by-slug.use-case";
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
import { articleFactory } from "@/__factories__/article.factory";
describe("getArticleBySlugController", () => {
let repo: MockArticlesRepository;
beforeEach(() => {
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
}
repo = new MockArticlesRepository();
blogContainer
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
.toConstantValue(repo);
articleFactory.reset();
});
it("returns the article when the slug exists", async () => {
await repo.createArticle(
articleFactory.build({ slug: "hello-world", authorId: "u1" }),
);
const result = await getArticleBySlugController({ slug: "hello-world" });
const repo = new MockArticlesRepository();
articleFactory.reset();
await repo.createArticle(articleFactory.build({ slug: "hello-world", authorId: "u1" }));
const useCase = getArticleBySlugUseCase(repo);
const controller = getArticleBySlugController(useCase);
const result = await controller({ slug: "hello-world" });
expect(result?.slug).toBe("hello-world");
});
it("returns undefined for a missing slug", async () => {
const result = await getArticleBySlugController({ slug: "no-such-slug" });
expect(result).toBeUndefined();
it("throws ArticleNotFoundError for a missing slug", async () => {
const repo = new MockArticlesRepository();
const useCase = getArticleBySlugUseCase(repo);
const controller = getArticleBySlugController(useCase);
await expect(
controller({ slug: "no-such-slug" }),
).rejects.toBeInstanceOf(ArticleNotFoundError);
});
});
```
@@ -54,9 +48,9 @@ describe("getArticleBySlugController", () => {
**Run it — confirm RED:**
```
pnpm test --filter @repo/blog -- articles.controller.test.ts
pnpm test --filter @repo/blog -- get-article-by-slug.controller.test.ts
FAIL src/interface-adapters/controllers/articles.controller.test.ts
FAIL src/interface-adapters/controllers/get-article-by-slug.controller.test.ts
getArticleBySlugController
× returns the article when the slug exists
AssertionError: expected undefined to equal "hello-world"
@@ -65,37 +59,40 @@ pnpm test --filter @repo/blog -- articles.controller.test.ts
**Step 2 — Write the minimal implementation (GREEN)**
```typescript
// packages/blog/src/interface-adapters/controllers/articles.controller.ts
export async function getArticleBySlugController(input: {
slug: string;
}): Promise<Article | undefined> {
const parsed = getBySlugInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid get-article-by-slug input", {
cause: parsed.error,
});
}
const repo = blogContainer.get<IArticlesRepository>(
BLOG_SYMBOLS.IArticlesRepository,
);
return repo.getArticleBySlug(parsed.data.slug);
// packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts
import type { IGetArticleBySlugUseCase, GetArticleBySlugOutput } from
"../application/use-cases/get-article-by-slug.use-case";
import { getArticleBySlugInputSchema } from "../application/use-cases/get-article-by-slug.use-case";
import { InputParseError } from "../entities/errors/common";
function presenter(value: GetArticleBySlugOutput) { return value; }
export function getArticleBySlugController(useCase: IGetArticleBySlugUseCase) {
return async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = getArticleBySlugInputSchema.safeParse(input);
if (!parsed.success)
throw new InputParseError("Invalid get-article-by-slug input", { cause: parsed.error });
return presenter(await useCase(parsed.data));
};
}
export type IGetArticleBySlugController = ReturnType<typeof getArticleBySlugController>;
```
**Run again — confirm GREEN:**
```
pnpm test --filter @repo/blog -- articles.controller.test.ts
pnpm test --filter @repo/blog -- get-article-by-slug.controller.test.ts
PASS src/interface-adapters/controllers/articles.controller.test.ts
PASS src/interface-adapters/controllers/get-article-by-slug.controller.test.ts
getArticleBySlugController
✓ returns the article when the slug exists
returns undefined for a missing slug
throws ArticleNotFoundError for a missing slug
```
**Step 3 — Refactor**
Extract the schema parse + error throw into a helper if the same pattern appears in three controllers:
If the same `safeParse` + `InputParseError` throw pattern appears in multiple controllers, extract a shared `parseOrThrow` helper:
```typescript
function parseOrThrow<T>(schema: z.ZodSchema<T>, raw: unknown, msg: string): T {
@@ -135,11 +132,10 @@ describe("getArticlesUseCase", () => {
});
// Controller
describe("articles controller", () => {
describe("createArticleController", () => {
it("creates an article on valid input", async () => { ... });
it("throws InputParseError on missing title", async () => { ... });
});
describe("getArticleBySlugController", () => {
it("returns the article when the slug exists", async () => { ... });
it("throws ArticleNotFoundError for a missing slug", async () => { ... });
it("throws InputParseError on invalid input shape", async () => { ... });
});
```
@@ -160,11 +156,14 @@ Every test body has three clearly separated sections. No logic between Act and A
```typescript
it("filters by status when status is provided", async () => {
// Arrange
const repo = new MockArticlesRepository();
articleFactory.reset();
await repo.createArticle(articleFactory.build({ status: "draft" }));
await repo.createArticle(articleFactory.build({ status: "published" }));
const useCase = getArticlesUseCase(repo);
// Act
const result = await getArticlesUseCase({ status: "published" });
const result = await useCase({ status: "published" });
// Assert
expect(result).toHaveLength(1);
@@ -199,8 +198,18 @@ Is it a pure function (entity validation, slug generation)?
→ No mock. Pass inputs, assert output.
Is it a use case test?
Rebind the repository at DI level using blogContainer.unbind / .bind.
→ Use MockArticlesRepository, not a vi.fn() spy.
Construct new MockXRepository() and inject directly into the factory:
const repo = new MockXRepository();
const useCase = xUseCase(repo);
await useCase(input);
→ No container unbind/rebind.
Is it a controller test?
→ Construct the mock repo, build the use case, inject into the controller factory:
const repo = new MockXRepository();
const useCase = xUseCase(repo);
const controller = xController(useCase);
await controller(input);
Is it a repository test (Payload implementation)?
→ vi.mock('payload') at the top of the file.
@@ -212,8 +221,9 @@ Is it a React component that fetches data?
→ Do not mock fetch or XMLHttpRequest directly.
Is it a route handler / tRPC procedure?
Mock at the boundary only (the repository). Call the procedure through
blogRouter.createCaller({}) — do not mock the router internals.
Use blogContainer / xContainer with unbindAll + load(XModule) in
beforeEach/afterEach. Call the procedure through xRouter.createCaller({})
— do not mock the router internals.
```
The rule: mock the thing your layer depends on, never the thing under test.
@@ -224,7 +234,7 @@ The rule: mock the thing your layer depends on, never the thing under test.
| Layer | Tool | Target ratio | Location pattern |
|---|---|---|---|
| Entity (schema, type guards) | Vitest | Highest — every entity | `src/entities/*.test.ts` |
| Entity (schema, type guards) | Vitest | Highest — every entity | `src/entities/models/*.test.ts` |
| Use case (business logic) | Vitest + mock repo | High — every use case | `src/application/use-cases/*.test.ts` |
| Controller (input parsing) | Vitest + mock repo | High — every controller | `src/interface-adapters/controllers/*.test.ts` |
| Repository contract | Vitest + contract suite | One per impl | `src/infrastructure/repositories/*.test.ts` |
@@ -299,7 +309,7 @@ articleFactory.build({ slug: "my-slug", status: "published" })
}
```
Always call `factory.reset()` in `beforeEach` to restart the sequence counter.
Always call `factory.reset()` in `beforeEach` (or inline before use) to restart the sequence counter.
**When to hand-craft**
@@ -311,7 +321,7 @@ Hand-craft objects only when testing boundary values (empty string, max-length t
```typescript
import { defineFactory } from "@repo/core-testing/factory";
import type { Comment } from "../entities/comment.js";
import type { Comment } from "../entities/models/comment";
export const commentFactory = defineFactory<Comment>(({ sequence }) => ({
id: `comment-${sequence}`,
@@ -323,13 +333,181 @@ export const commentFactory = defineFactory<Comment>(({ sequence }) => ({
```
2. Re-export from `packages/<feature>/src/__factories__/index.ts`.
3. Call `commentFactory.reset()` in every `beforeEach` that uses it.
3. Call `commentFactory.reset()` in every `beforeEach` (or inline) that uses it.
The `defineFactory` function lives in `packages/core-testing/src/factory/define-factory.ts`.
---
## 9. Contract Suite Usage
## 9. Output Validation Tests (R25)
Every **non-void** use case must have an R25 test that injects a malformed mock response and asserts the use case throws `ZodError`. This verifies that the `xOutputSchema.parse(result)` at the end of each use case actually guards against misbehaving repositories.
**Void use cases are exempt:** `signOutUseCase`, `deleteMediaUseCase`, and any future use case returning `Promise<void>` skip R25 — they have no output schema.
**Pattern A — reach into `_articles` (or equivalent backing array) when the typed API prevents you from inserting bad data:**
```typescript
// packages/blog/src/application/use-cases/get-articles.use-case.test.ts
import { ZodError } from "zod";
import { getArticlesUseCase, getArticlesOutputSchema } from "@/application/use-cases/get-articles.use-case";
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
describe("getArticlesUseCase output validation (R25)", () => {
it("throws when the repository returns a malformed article", async () => {
const repo = new MockArticlesRepository();
// bypass typed createArticle by reaching into the backing array directly
(repo as unknown as { _articles: unknown[] })._articles.push({ id: 123 });
const useCase = getArticlesUseCase(repo);
await expect(useCase({})).rejects.toBeInstanceOf(ZodError);
});
it("exports an output schema that mirrors Article[]", () => {
expect(getArticlesOutputSchema).toBeDefined();
expect(getArticlesOutputSchema.safeParse([]).success).toBe(true);
});
});
```
**Pattern B — inline stub when pattern A is impractical (service layer or complex dependency):**
```typescript
// packages/auth/src/application/use-cases/sign-in.use-case.test.ts
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
describe("signInUseCase output validation (R25)", () => {
it("throws when authenticationService returns a malformed session", async () => {
const users = new MockUsersRepository([]);
await users.createUser(userFactory.build({ username: "alice" }));
const auth = {
verifyPassword: async () => true,
createSession: async () => ({ session: { id: 123 }, cookie: null }),
} as unknown as IAuthenticationService;
const useCase = signInUseCase(users, auth);
await expect(useCase({ username: "alice", password: "x" })).rejects.toBeInstanceOf(ZodError);
});
});
```
Group R25 tests in a separate `describe` block labelled `"<useCase> output validation (R25)"` so they are easy to grep.
---
## 10. Router Error-Mapping Tests (R26)
Each feature's `router.test.ts` must assert that thrown domain errors become `TRPCError` with the correct code. The router test uses `xRouter.createCaller({})` and calls real procedures backed by the default mock bindings.
The `beforeEach` / `afterEach` blocks reload the DI module so each test gets a fresh mock repository:
```typescript
// packages/blog/src/integrations/api/router.test.ts
import { afterEach, beforeEach, describe, expect, it } 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);
});
afterEach(() => {
blogContainer.unbindAll();
});
it("translates ArticleNotFoundError → NOT_FOUND", async () => {
const caller = blogRouter.createCaller({});
try {
await caller.articleBySlug({ slug: "missing" });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
expect((e as TRPCError).code).toBe("NOT_FOUND");
}
});
it("translates zod parse failure → BAD_REQUEST", async () => {
const caller = blogRouter.createCaller({});
try {
await caller.articleBySlug({} as unknown as { slug: string });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
expect((e as TRPCError).code).toBe("BAD_REQUEST");
}
});
});
```
For features where a domain error can only be triggered by an empty store (e.g. `navigation`'s `HeaderNotFoundError`), inline a `NullXRepository` and rebind the container:
```typescript
it("translates HeaderNotFoundError → NOT_FOUND", async () => {
@injectable()
class NullHeaderRepository implements IHeaderRepository {
async getHeader() { return undefined; }
}
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
navigationContainer.bind(NAVIGATION_SYMBOLS.IHeaderRepository).to(NullHeaderRepository);
const caller = navigationRouter.createCaller({});
try {
await caller.header({});
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
expect((e as TRPCError).code).toBe("NOT_FOUND");
}
});
```
Every feature needs at least one `NOT_FOUND` (or domain-error) test and one `BAD_REQUEST` (schema validation) test.
---
## 11. Presenter Shape Tests (R27/R28)
When a controller's presenter **reshapes** the use-case output (rather than returning it unchanged), the controller test must assert the **view shape** — not the full use-case output.
**Example — auth `sign-in` (non-identity presenter):**
The `signInUseCase` returns `{ session, cookie }`. The presenter extracts `cookie` and returns it directly. The controller test must assert the cookie's shape:
```typescript
// packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts
describe("signInController", () => {
it("returns a cookie on successful sign-in", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
await users.createUser(
userFactory.build({ username: "alice", passwordHash: "hashed_testpassword" }),
);
const useCase = signInUseCase(users, auth);
const controller = signInController(useCase);
const result = await controller({ username: "alice", password: "testpassword" });
// assert the VIEW shape (cookie), not the use-case output ({ session, cookie })
expect(result.name).toBe("session");
expect(result.value).toBeTruthy();
});
});
```
**Identity presenters skip this.** Blog, marketing-pages, navigation, and media controllers all use identity presenters (`return value`). Their controller tests assert on the same fields the use case would return — that is fine, because the presenter does not transform.
**Rule of thumb:** if `presenter(value)` does anything other than `return value`, write a test that cannot pass by accident — assert a field that only exists on the *view*, not on `XOutput`.
Void controllers (`signOutController`, `deleteMediaController`) return `Promise<void>` and have no presenter — no view-shape test applies.
---
## 12. Contract Suite Usage
A contract suite asserts that every implementation of a repository interface satisfies the same behavioral contract. The suite runs once per implementation; the implementation is supplied via `buildSubject`.
@@ -340,8 +518,8 @@ A contract suite asserts that every implementation of a repository interface sat
```typescript
import { it, expect, beforeEach } from "vitest";
import { defineContractSuite } from "@repo/core-testing/contract";
import type { ICommentsRepository } from "../application/repositories/comments-repository.interface.js";
import { commentFactory } from "../__factories__/comment.factory.js";
import type { ICommentsRepository } from "../application/repositories/comments.repository.interface";
import { commentFactory } from "../__factories__/comment.factory";
export const commentsRepositoryContract =
defineContractSuite<ICommentsRepository>(
@@ -367,10 +545,10 @@ export const commentsRepositoryContract =
2. Run the contract against the mock implementation:
```typescript
// packages/<feature>/src/infrastructure/repositories/mock-comments.repository.test.ts
// packages/<feature>/src/infrastructure/repositories/comments.repository.mock.test.ts
import { describe } from "vitest";
import { commentsRepositoryContract } from "@/__contracts__/comments-repository.contract";
import { MockCommentsRepository } from "./mock-comments.repository";
import { MockCommentsRepository } from "./comments.repository.mock";
describe("MockCommentsRepository", () => {
commentsRepositoryContract.run(async () => new MockCommentsRepository());
@@ -380,30 +558,36 @@ describe("MockCommentsRepository", () => {
3. Run the contract against the Payload implementation (with `vi.mock('payload')`):
```typescript
// packages/<feature>/src/infrastructure/repositories/payload-comments.repository.test.ts
// packages/<feature>/src/infrastructure/repositories/comments.repository.test.ts
import { describe, vi } from "vitest";
import { commentsRepositoryContract } from "@/__contracts__/comments-repository.contract";
import { PayloadCommentsRepository } from "./payload-comments.repository";
import { CommentsRepository } from "./comments.repository";
import { stubPayloadConfig } from "@repo/core-testing/payload";
vi.mock("payload", () => ({ getPayload: vi.fn() }));
describe("PayloadCommentsRepository", () => {
describe("CommentsRepository", () => {
commentsRepositoryContract.run(async () => {
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(buildStub());
return new PayloadCommentsRepository(stubPayloadConfig);
return new CommentsRepository(stubPayloadConfig);
});
});
```
4. Run tests. Fix until green. Both implementations now share the same contract.
4. Run tests. Fix until green.
The `buildSubject` pattern is the key: each `run()` call provides a fresh instance, so every contract `it()` starts with a clean repository.
The `buildSubject` pattern ensures each `run()` call supplies a fresh instance every contract `it()` starts with a clean repository.
**File naming convention (post-Plan-8):**
- Mock implementation: `<x>.repository.mock.ts` (not `mock-<x>.repository.ts`)
- Mock test: `<x>.repository.mock.test.ts`
- Real implementation: `<x>.repository.ts` (no `payload-` prefix)
- Interface: `<x>.repository.interface.ts`
---
## 10. Running Tests
## 13. Running Tests
**Watch mode (recommended during development):**
@@ -422,7 +606,7 @@ it.only("returns undefined for a missing slug", async () => { ... });
Run the file directly:
```bash
pnpm test --filter @repo/blog -- articles.controller.test.ts
pnpm test --filter @repo/blog -- get-article-by-slug.controller.test.ts
```
**Debug a failing test:**
@@ -436,7 +620,7 @@ pnpm test --filter @repo/blog -- --reporter=verbose
For node-level debugging:
```bash
node --inspect-brk node_modules/.bin/vitest run src/interface-adapters/controllers/articles.controller.test.ts
node --inspect-brk node_modules/.bin/vitest run src/interface-adapters/controllers/get-article-by-slug.controller.test.ts
```
Then attach Chrome DevTools at `chrome://inspect`.