Files
agentic-dev/docs/guides/testing-strategy.md
Danijel Martinek e0a592baa7 docs(guide): testing-strategy.md — direct factory injection + R25/R26/R27/R28 obligations
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.
2026-05-06 16:47:30 +02:00

12 KiB

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

Related ADRs: ADR-012 — Clean Architecture conformance; ADR-013 — 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.

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

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

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

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:

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:

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:

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

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:

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:

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

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

{
  "scripts": {
    "test": "turbo run test",
    "test:e2e": "turbo run test:e2e"
  }
}

Root turbo.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