Files
agentic-dev/docs/guides/tdd-workflow.md

28 KiB
Raw Blame History

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 getArticleBySlug(slug: string): Promise<Article | undefined>. Before implementing anything, write a controller test that constructs the dependencies directly — no container rebinding.

// 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", () => {
  it("returns the article when the slug exists", async () => {
    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("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,
    );
  });
});

Run it — confirm RED:

pnpm test --filter @repo/blog -- get-article-by-slug.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"

Step 2 — Write the minimal implementation (GREEN)

// 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 -- get-article-by-slug.controller.test.ts

 PASS  src/interface-adapters/controllers/get-article-by-slug.controller.test.ts
  getArticleBySlugController
    ✓ returns the article when the slug exists
    ✓ throws ArticleNotFoundError for a missing slug

Step 3 — Refactor

If the same safeParse + InputParseError throw pattern appears in multiple controllers, extract a shared parseOrThrow helper:

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:

// 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("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 () => { ... });
});

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:

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 useCase({ status: "published" });

  // Assert
  expect(result).toHaveLength(1);
  expect(result[0]?.status).toBe("published");
});

Example 2 — React component test:

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?
  → 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.
  → 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?
  → 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.


5. Test Pyramid for This Monorepo

Layer Tool Target ratio Location pattern
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
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:

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:

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:

// 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 (or inline before use) 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:
import { defineFactory } from "@repo/core-testing/factory";
import type { Comment } from "../entities/models/comment";

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"),
}));
  1. Re-export from packages/<feature>/src/__factories__/index.ts.
  2. 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. Output Validation Tests

Every non-void use case must have an output-validation 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> — they have no output schema.

Pattern A — reach into _articles (or equivalent backing array) when the typed API prevents you from inserting bad data:

// 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", () => {
  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):

// 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", () => {
  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 output-validation tests in a separate describe block labelled "<useCase> output validation" so they are easy to grep.


10. Router Error-Mapping Tests

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:

// 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 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:

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

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:

// 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.

How to add a contract suite for a new repository

  1. Define the contract in packages/<feature>/src/__contracts__/<entity>-repository.contract.ts:
import { it, expect, beforeEach } from "vitest";
import { defineContractSuite } from "@repo/core-testing/contract";
import type { ICommentsRepository } from "../application/repositories/comments.repository.interface";
import { commentFactory } from "../__factories__/comment.factory";

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");
      });
    },
  );
  1. Run the contract against the mock implementation:
// packages/<feature>/src/infrastructure/repositories/comments.repository.mock.test.ts
import { describe } from "vitest";
import { commentsRepositoryContract } from "@/__contracts__/comments-repository.contract";
import { MockCommentsRepository } from "./comments.repository.mock";

describe("MockCommentsRepository", () => {
  commentsRepositoryContract.run(async () => new MockCommentsRepository());
});
  1. Run the contract against the Payload implementation (with vi.mock('payload')):
// packages/<feature>/src/infrastructure/repositories/comments.repository.test.ts
import { describe, vi } from "vitest";
import { commentsRepositoryContract } from "@/__contracts__/comments-repository.contract";
import { CommentsRepository } from "./comments.repository";
import { stubPayloadConfig } from "@repo/core-testing/payload";

vi.mock("payload", () => ({ getPayload: vi.fn() }));

describe("CommentsRepository", () => {
  commentsRepositoryContract.run(async () => {
    const { getPayload } = await import("payload");
    (getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(buildStub());
    return new CommentsRepository(stubPayloadConfig);
  });
});
  1. Run tests. Fix until green.

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

13. Running Tests

Watch mode (recommended during development):

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:

it.only("returns undefined for a missing slug", async () => { ... });

Run the file directly:

pnpm test --filter @repo/blog -- get-article-by-slug.controller.test.ts

Debug a failing test:

Add console.log inline, or launch with the Vitest inspector:

pnpm test --filter @repo/blog -- --reporter=verbose

For node-level debugging:

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.

Coverage:

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:

pnpm test:stories

Requires Storybook running on port 6006. Start it first: pnpm dev --filter @repo/storybook.

Playwright e2e:

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.


Asserting spans and captures

Use cases, controllers, and repositories emit OpenTelemetry-style spans through the ITracer interface. Repositories also call logger.captureException inline; use cases and controllers get capture composed in via withCapture at DI bind time. Tests that need to assert either inject RecordingTracer + RecordingLogger:

import {
  RecordingTracer,
  RecordingLogger,
} from "@repo/core-testing/instrumentation";
import { withSpan, withCapture } from "@repo/core-shared/instrumentation";
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";

describe("blog.getArticles use case", () => {
  it("emits a use-case span when invoked", async () => {
    const tracer = new RecordingTracer();
    const logger = new RecordingLogger();
    const repo = new MockArticlesRepository(tracer, logger);
    // Use cases are wrapped at DI bind time. For direct-injection tests,
    // mirror the binder's sandwich: withSpan(withCapture(factory(deps))).
    const wrapped = withSpan(
      tracer,
      { name: "blog.getArticles", op: "use-case" },
      withCapture(
        logger,
        { feature: "blog", layer: "use-case", name: "blog.getArticles" },
        getArticlesUseCase(repo),
      ),
    );
    await wrapped({ limit: 10 });
    expect(tracer.findSpan("blog.getArticles")?.op).toBe("use-case");
    expect(tracer.findSpan("articles.getArticles")?.op).toBe("repository");
  });
});

Capture assertions use RecordingLogger. Note: RecordingLogger.captureException honours the __sentryReported flag — so when a repo's catch block captures and the outer withCapture sees the bubbled error, only the inner-most call records:

const logger = new RecordingLogger();
const repo = new MockArticlesRepository(tracer, logger);
// Force an infra error in your test setup, then:
expect(logger.captures).toHaveLength(1); // exactly one — flag prevents double
expect(logger.captures[0]).toMatchObject({
  kind: "exception",
  ctx: { tags: { feature: "blog", repo: "articles", method: "getArticles" } },
});

For an end-to-end example (controller → use case → repo, all wrapped, asserting no double-capture across layers), see packages/blog/tests/r44-no-double-capture.test.ts.

Default mocks (when you don't need assertions): construct new MockArticlesRepository() with no args — the constructor defaults bind NoopTracer + NoopLogger.


Conformance gates (post-TDD)

After your tests are green and the impl is committed, four gates check that the new code stays consistent with the feature's manifest:

  1. TypeScript brands — the ProductionUseCase<I, O, M> slot in bind-production.ts only accepts factories wrapped through withSpan + withCapture + (if mutating with audits) withAudit.
  2. ESLint rules — five conformance/* rules check manifest ↔ code drift; see docs/guides/conformance-quickref.md.
  3. Boot assertionassertFeatureConformance runs at the tail of every bindProductionX(ctx); pnpm dev refuses to start on drift.
  4. CI drift gatepnpm conformance runs after pnpm lint in CI; fails on orphan event consumers across features.

The TDD red-green cycle covers behavioural correctness; the conformance gates cover architectural correctness.