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:
File diff suppressed because it is too large
Load Diff
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user