docs(guides): add TDD workflow + restructure adding-a-feature for TDD order

New: docs/guides/tdd-workflow.md — red-green-refactor cycle, AAA,
mocking decision tree, coverage targets, factory + contract usage.
Restructured: adding-a-feature.md interleaves tests with implementation;
TDD order is required, not optional. testing-strategy.md cross-links
the new guide. AGENTS.md and CLAUDE.md surface both.

Spec: §7

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 19:28:00 +02:00
parent bd50a67662
commit 17d3488fb4
5 changed files with 1268 additions and 208 deletions

File diff suppressed because it is too large Load Diff

470
docs/guides/tdd-workflow.md Normal file
View File

@@ -0,0 +1,470 @@
# TDD Workflow
TDD in this monorepo is not dogma — it is a feedback mechanism. Writing a test first forces you to design the interface before the implementation, surface integration issues early, and guarantee that every line of production code is covered by an intentional assertion. The cycle keeps each increment small: one failing test, the minimal code to pass it, a clean refactor. Nothing more.
---
## 1. The Red-Green-Refactor Cycle
### Worked example: `getArticleBySlug`
**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.
```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";
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" });
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();
});
});
```
**Run it — confirm RED:**
```
pnpm test --filter @repo/blog -- articles.controller.test.ts
FAIL src/interface-adapters/controllers/articles.controller.test.ts
getArticleBySlugController
× returns the article when the slug exists
AssertionError: expected undefined to equal "hello-world"
```
**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);
}
```
**Run again — confirm GREEN:**
```
pnpm test --filter @repo/blog -- articles.controller.test.ts
PASS src/interface-adapters/controllers/articles.controller.test.ts
getArticleBySlugController
✓ returns the article when the slug exists
✓ returns undefined for a missing slug
```
**Step 3 — Refactor**
Extract the schema parse + error throw into a helper if the same pattern appears in three controllers:
```typescript
function parseOrThrow<T>(schema: z.ZodSchema<T>, raw: unknown, msg: string): T {
const parsed = schema.safeParse(raw);
if (!parsed.success) throw new InputParseError(msg, { cause: parsed.error });
return parsed.data;
}
```
Re-run tests — still GREEN. Commit.
---
## 2. Test Naming
Every test file follows these conventions:
```
describe(<SubjectUnderTest>)
it("<does X> when <condition Y>")
```
**Three examples:**
```typescript
// Entity validation
describe("articleSchema", () => {
it("accepts a minimal valid article with default status", () => { ... });
it("rejects empty title", () => { ... });
it("rejects title over 255 chars", () => { ... });
});
// Use case
describe("getArticlesUseCase", () => {
it("returns all articles with no filters", async () => { ... });
it("filters by status when status is provided", async () => { ... });
});
// Controller
describe("articles controller", () => {
describe("createArticleController", () => {
it("creates an article on valid input", async () => { ... });
it("throws InputParseError on missing title", async () => { ... });
});
});
```
Rules:
- `describe` names the class or function under test — not the file.
- `it` uses active voice: `returns`, `throws`, `filters`, `creates`.
- Conditions go after `when`: `it("returns undefined when slug is missing")`.
- No `should` — it adds words without meaning.
---
## 3. Arrange / Act / Assert (AAA)
Every test body has three clearly separated sections. No logic between Act and Assert.
**Example 1 — use case test:**
```typescript
it("filters by status when status is provided", async () => {
// Arrange
await repo.createArticle(articleFactory.build({ status: "draft" }));
await repo.createArticle(articleFactory.build({ status: "published" }));
// Act
const result = await getArticlesUseCase({ status: "published" });
// Assert
expect(result).toHaveLength(1);
expect(result[0]?.status).toBe("published");
});
```
**Example 2 — React component test:**
```typescript
it("renders the article title when data loads", async () => {
// Arrange
const article = articleFactory.build({ title: "My Post" });
const screen = renderWithProviders(<ArticleCard article={article} />);
// Act
// (render is the act; no user interaction needed here)
// Assert
expect(screen.getByText("My Post")).toBeInTheDocument();
});
```
Keep Arrange lean — use factories, not hand-rolled objects. Keep Assert specific — test exactly what the step under test owns, not downstream effects.
---
## 4. When to Mock — Decision Tree
```
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.
Is it a repository test (Payload implementation)?
→ vi.mock('payload') at the top of the file.
→ Provide a stub via stubPayloadConfig from @repo/core-testing/payload.
→ Run the contract suite to prove correctness.
Is it a React component that fetches data?
→ renderWithProviders from @repo/core-testing/react with tRPC mocks.
→ 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.
```
The rule: mock the thing your layer depends on, never the thing under test.
---
## 5. Test Pyramid for This Monorepo
| Layer | Tool | Target ratio | Location pattern |
|---|---|---|---|
| Entity (schema, type guards) | Vitest | Highest — every entity | `src/entities/*.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` |
| Feature integration (tRPC) | Vitest + createCaller | Medium — happy path + error | `src/integrations/api/router.test.ts` |
| Component | Vitest + RTL | Per UI component | `src/ui/**/*.test.tsx` |
| E2E | Playwright | Few — smoke + critical flows | `apps/web-next/e2e/*.spec.ts` |
Entities and use cases have the highest ratio because they encode business rules. E2E tests have the lowest ratio because they are slow and test the full stack.
---
## 6. What NOT to Test
- **Plain getters/setters** — if a function only returns `this.field`, the test adds no signal.
- **Framework code** — do not test that Next.js routes requests correctly; test the handler that Next.js calls.
- **Third-party libraries** — do not test that Zod parses a `z.string()` correctly; test that your schema rejects your domain-specific invalid inputs.
- **Types-only modules** — a file containing only `export type Foo = ...` cannot have runtime behavior; skip it.
- **Generated code** — Payload-generated types in `node_modules/.payload/`, migration files; these are not your code.
- **`console.log` calls** — test observable output, not side-channel logging.
- **Private implementation details** — if refactoring internals breaks a test without breaking any observable behavior, the test was testing the wrong thing.
---
## 7. Coverage Targets
| Scope | Statements | Branches | Functions | Lines |
|---|---|---|---|---|
| Baseline (all packages) | 80% | 75% | 80% | 80% |
| Entities | 100% | 100% | 100% | 100% |
| Use cases | 100% | 100% | 100% | 100% |
| Controllers | 100% | 100% | 100% | 100% |
| Infrastructure (repos) | 80% | 75% | 80% | 80% |
**Inspect coverage locally:**
```bash
pnpm test --coverage --filter @repo/blog
```
HTML report lands at `packages/blog/coverage/index.html` — open it in a browser to see uncovered branches highlighted in red.
To run coverage across all packages:
```bash
pnpm test -- --coverage
```
Coverage thresholds are enforced in `packages/core-typescript/vitest.base.ts` and inherited by every package's `vitest.config.ts` via `nodeVitestConfig` / `jsdomVitestConfig`.
---
## 8. Factory Usage
**When to use `factory.build()`**
Use `articleFactory.build()` (from `packages/blog/src/__factories__/article.factory.ts`) whenever you need a valid `Article` in a test and you do not care about specific field values. Override only what the test assertion depends on:
```typescript
// Good — only override what the test cares about
articleFactory.build({ slug: "my-slug", status: "published" })
// Avoid — hand-crafting the full object obscures intent
{
id: "abc",
title: "Article 1",
slug: "my-slug",
content: null,
status: "published",
authorId: "user-1",
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
}
```
Always call `factory.reset()` in `beforeEach` to restart the sequence counter.
**When to hand-craft**
Hand-craft objects only when testing boundary values (empty string, max-length title, null content) where the exact shape matters more than the valid-object semantics a factory provides.
**Adding a new factory**
1. Create `packages/<feature>/src/__factories__/<entity>.factory.ts`:
```typescript
import { defineFactory } from "@repo/core-testing/factory";
import type { Comment } from "../entities/comment.js";
export const commentFactory = defineFactory<Comment>(({ sequence }) => ({
id: `comment-${sequence}`,
articleId: "article-1",
body: `Comment body ${sequence}`,
authorId: "user-1",
createdAt: new Date("2026-01-01T00:00:00Z"),
}));
```
2. Re-export from `packages/<feature>/src/__factories__/index.ts`.
3. Call `commentFactory.reset()` in every `beforeEach` that uses it.
The `defineFactory` function lives in `packages/core-testing/src/factory/define-factory.ts`.
---
## 9. 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`.
**How to add a contract suite for a new repository**
1. Define the contract in `packages/<feature>/src/__contracts__/<entity>-repository.contract.ts`:
```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";
export const commentsRepositoryContract =
defineContractSuite<ICommentsRepository>(
"ICommentsRepository",
({ buildSubject }) => {
let repo: ICommentsRepository;
beforeEach(async () => {
commentFactory.reset();
repo = await buildSubject();
});
it("createComment returns a comment with the correct fields", async () => {
const seed = commentFactory.build({ body: "Hello" });
const created = await repo.createComment(seed);
expect(typeof created.id).toBe("string");
expect(created.body).toBe("Hello");
});
},
);
```
2. Run the contract against the mock implementation:
```typescript
// packages/<feature>/src/infrastructure/repositories/mock-comments.repository.test.ts
import { describe } from "vitest";
import { commentsRepositoryContract } from "@/__contracts__/comments-repository.contract";
import { MockCommentsRepository } from "./mock-comments.repository";
describe("MockCommentsRepository", () => {
commentsRepositoryContract.run(async () => new MockCommentsRepository());
});
```
3. Run the contract against the Payload implementation (with `vi.mock('payload')`):
```typescript
// packages/<feature>/src/infrastructure/repositories/payload-comments.repository.test.ts
import { describe, vi } from "vitest";
import { commentsRepositoryContract } from "@/__contracts__/comments-repository.contract";
import { PayloadCommentsRepository } from "./payload-comments.repository";
import { stubPayloadConfig } from "@repo/core-testing/payload";
vi.mock("payload", () => ({ getPayload: vi.fn() }));
describe("PayloadCommentsRepository", () => {
commentsRepositoryContract.run(async () => {
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(buildStub());
return new PayloadCommentsRepository(stubPayloadConfig);
});
});
```
4. Run tests. Fix until green. Both implementations now share the same contract.
The `buildSubject` pattern is the key: each `run()` call provides a fresh instance, so every contract `it()` starts with a clean repository.
---
## 10. Running Tests
**Watch mode (recommended during development):**
```bash
pnpm test --watch --filter @repo/blog
```
Vitest re-runs only the affected files on save. Use this instead of manual re-runs.
**Focus a single test:**
```typescript
it.only("returns undefined for a missing slug", async () => { ... });
```
Run the file directly:
```bash
pnpm test --filter @repo/blog -- articles.controller.test.ts
```
**Debug a failing test:**
Add `console.log` inline, or launch with the Vitest inspector:
```bash
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
```
Then attach Chrome DevTools at `chrome://inspect`.
**Coverage:**
```bash
pnpm test --coverage --filter @repo/blog
# HTML report: packages/blog/coverage/index.html
pnpm test -- --coverage
# HTML reports: packages/*/coverage/index.html
```
**Storybook smoke tests:**
```bash
pnpm test:stories
```
Requires Storybook running on port 6006. Start it first: `pnpm dev --filter @repo/storybook`.
**Playwright e2e:**
```bash
pnpm test:e2e # headless
pnpm test:e2e -- --ui # interactive UI mode
pnpm test:e2e -- --headed # visible browser
```
E2E tests live in `apps/web-next/e2e/`. The `webServer` block in `apps/web-next/playwright.config.ts` starts the dev server automatically.

View File

@@ -2,6 +2,8 @@
A layered approach: per-feature DI containers + 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.
## Test placement
| Level | Location | Tool | Example |