Adds "Asserting spans and captures" section to tdd-workflow.md with RecordingTracer/Logger usage and inline withSpan wrapping pattern for direct-injection tests. Adds R49/R50 section to testing-strategy.md covering the no-sentry guard, contract suite span assertions, and RecordingTracer/Logger field reference. Adds "TRACER / LOGGER (Plan 10)" subsection to dependency-flow.md showing the bindAll → feature-container wiring path. Adds an "src/instrumentation/" section to core-shared/AGENTS.md documenting the two interfaces, three impl pairs, withSpan helper, scrubbers, both Next.js + Vite/React init helpers, and the subpath exports. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 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
- Colocated unit tests validate single functions/classes in isolation
- Feature-level tests exercise the full feature with mocked repos (direct injection, no container)
- E2E tests prove the app works end-to-end (minimal smoke specs initially)
- Per-feature containers are used at the router level; use-case + controller tests inject mocks directly into the factory (no container)
R49 / R50 — Instrumentation testing (Plan 10)
R49 — No real Sentry in tests. The core-testing/setup/no-sentry.ts guard mocks @sentry/nextjs, @sentry/node, and @sentry/react at the module level, so any code that imports them gets a no-op surface during vitest runs. Tests that want to assert specific Sentry SDK calls add their own vi.mock(...) per file.
R50 — Repository contracts assert span shape. Every __contracts__/<x>-repository.contract.ts includes a span emission (R50) describe block enumerating one assertion per public method. Suites run against both mock and real (Payload-backed) implementations, ensuring span emission stays in sync. Wire the recording tracer at the call site:
const tracer = new RecordingTracer();
articlesRepositoryContract.run(
() => new MockArticlesRepository(tracer),
{ tracer: () => tracer },
);
Capture vs span assertions:
RecordingTracer.spans— every span emitted with{ name, op, attributes, status, durationMs }.RecordingLogger.captures— everycaptureException/captureMessagecall.RecordingLogger.breadcrumbs— every breadcrumb added.RecordingLogger.users— everysetUsercall (history).
Test cleanup: call tracer.reset() and logger.reset() in beforeEach if the test creates one shared instance across multiple cases.
5. Mock repos are the default; only use real Payload in dedicated infrastructure tests