Files
agentic-dev/docs/superpowers/specs/2026-05-05-tdd-foundation-design.md
Danijel Martinek bffc6a96b3 docs(plan-8): partial Lazar doc-update pass (CLAUDE.md, overview.md, ADR-012, Plan 7 annotations)
First slice of the Plan 8 deferred doc-update checklist:
- CLAUDE.md Key Conventions: factory-function use cases/controllers,
  entities/models/<x>.ts paths, .toDynamicValue DI bindings, direct
  injection in tests
- docs/architecture/overview.md data-flow box updated to factory style
  (controller resolved via container.get<IXController>; use case factory
  takes deps as args)
- docs/decisions/adr-012-lazar-conformance.md created — records the
  conformance decision and four intentional divergences
- docs/superpowers/plans/2026-05-05-plan-7-tdd-foundation.md and the
  matching spec annotated with a "pre-Plan-8 layout" note pointing at
  the refactor log

Remaining Plan 8 doc-update items (root AGENTS.md, per-feature AGENTS.md,
adding-a-feature.md, tdd-workflow.md, testing-strategy.md,
vertical-feature-spec.md §6/§10, core-testing AGENTS.md) intentionally
paused — Plan 9 (input/output unification) will change overlapping
content, so resuming after Plan 9 lands avoids double-churn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:17:35 +02:00

27 KiB

TDD Foundation — Design Spec

Note (2026-05-05, post-Plan-8): File-path references in this spec describe the pre-Plan-8 layout. Plan 8 (Lazar pattern conformance) introduced entities/models/, entities/errors/<domain>.ts, the .mock.ts suffix, dropped payload- prefixes, and made use cases / controllers factory functions. The TDD principles, gap catalogue, and core-testing package design remain valid; only the example paths and snippets shifted. See docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md and ADR-012 for the mapping.

Date: 2026-05-05 Status: Approved Author: Claude Opus 4.7 (1M context) Reviewer: Danijel Supersedes: none — extends 2026-04-21-vertical-monorepo-refactor-design.md §13B


1. Goal

Make full TDD the path of least resistance in this monorepo so that agentic workers (and humans) cannot produce production code without first writing a failing test. Close the ten gaps catalogued in §3.

The success criterion is operational, not aspirational: a fresh subagent given any plan task in this template should hit a failing test before they touch production code, and a human reviewer should be able to read a single guide (docs/guides/tdd-workflow.md) and the per-feature AGENTS.md to understand why each test exists.

2. Non-goals

  • Mutation testing (deferred — Tier 3)
  • Visual regression beyond Storybook smoke (deferred — Tier 3)
  • Property-based testing (deferred)
  • Replacing existing passing tests (we extend the test suite, we do not rewrite working tests for stylistic reasons)
  • Docker-in-CI for full Payload integration tests (mock at payload module boundary instead — much faster, and the contract suite catches drift)

3. The ten gaps

# Gap Closed by
1 Zero tests in core-ui, core-api, core-cms, core-trpc §6.5, §6.6
2 No React component testing infrastructure §5, §6.5
3 No TDD process documentation (only placement docs exist) §7.1
4 Inline fixtures duplicated across tests; no factories §5.1, §6.3
5 No contract tests — Mock and Payload repos drift independently §5.2, §6.4
6 Vitest base lacks safety defaults (clearMocks, restoreMocks, jsdom env) §6.2
7 adding-a-feature.md does not enforce TDD order §7.2
8 Storybook stories are not executed as tests §6.8
9 Apps have no unit tests (route handlers, providers, bind-production wiring) §6.7
10 No CI — tests can rot silently §6.11

4. Architecture overview

Three concentric layers of TDD support:

  1. Tooling layercore-typescript and core-eslint provide configs every package inherits.
  2. Test-utils layer — a new package @repo/core-testing (tag: tooling) provides shared helpers: defineFactory, defineContractSuite, renderWithProviders, mock-payload helper, mock-trpc helper, jsdom setup file.
  3. Per-package convention — every feature/core package follows the same skeleton: factories in src/__factories__/, contract suites in src/__contracts__/, colocated *.test.{ts,tsx} for units, tests/*.feature.test.ts for integration.
┌─────────────────────────────────────────────────────────────┐
│  apps/* (web-next, web-tanstack, cms, storybook)            │
│  ├─ vitest.config.ts (jsdom)                                 │
│  ├─ src/**/*.test.{ts,tsx}    ← unit + RSC smoke             │
│  └─ e2e/*.spec.ts             ← Playwright (existing)        │
├─────────────────────────────────────────────────────────────┤
│  packages/<feature>/* (auth, blog, media, marketing-pages,   │
│                        navigation)                           │
│  ├─ vitest.config.ts (node by default; jsdom for ui/)        │
│  ├─ src/__factories__/*.factory.ts                           │
│  ├─ src/__contracts__/*-repository.contract.ts               │
│  ├─ src/**/*.test.ts          ← unit                         │
│  └─ tests/*.feature.test.ts   ← feature integration          │
├─────────────────────────────────────────────────────────────┤
│  packages/core-* (shared, ui, api, cms, trpc)                │
│  ├─ vitest.config.ts (jsdom for core-ui; node for rest)      │
│  └─ src/**/*.test.{ts,tsx}    ← composition smoke            │
├─────────────────────────────────────────────────────────────┤
│  packages/core-testing (NEW, tooling tag)                    │
│  ├─ factory/define-factory                                   │
│  ├─ contract/define-contract-suite                           │
│  ├─ react/render-with-providers, mock-trpc                   │
│  ├─ payload/mock-payload-module, stub-config                 │
│  └─ setup/{jsdom,node}                                       │
├─────────────────────────────────────────────────────────────┤
│  packages/core-typescript (tooling)                          │
│  ├─ vitest.base.node.ts   ← safety defaults + coverage       │
│  └─ vitest.base.jsdom.ts  ← extends node + jsdom + setup     │
└─────────────────────────────────────────────────────────────┘

Boundary rules unchanged. core-testing is tooling, so any package may depend on it (devDependency only).

5. New package: @repo/core-testing

5.1 factory/

// define-factory.ts
export interface FactoryContext {
  sequence: number;
}

export interface Factory<T> {
  build(overrides?: Partial<T>): T;
  buildList(count: number, overrides?: Partial<T>): T[];
  reset(): void;
}

export function defineFactory<T>(builder: (ctx: FactoryContext) => T): Factory<T> {
  let sequence = 0;
  return {
    build(overrides) {
      sequence += 1;
      const base = builder({ sequence });
      return deepMerge(base, overrides ?? {}) as T;
    },
    buildList(count, overrides) {
      return Array.from({ length: count }, () => this.build(overrides));
    },
    reset() {
      sequence = 0;
    },
  };
}

Required factories per feature (created in plan):

Feature Factories
auth userFactory, sessionFactory
blog articleFactory
media mediaFactory
marketing-pages pageFactory, siteSettingsFactory
navigation headerFactory, navItemFactory

Mock repositories are refactored to consume factories where they need defaults — eliminates per-test inline fixture duplication.

5.2 contract/

// define-contract-suite.ts
export interface ContractContext<T> {
  buildSubject: () => Promise<T> | T;
}

export interface ContractSuite<T> {
  run(buildSubject: () => Promise<T> | T): void;
}

export function defineContractSuite<T>(
  name: string,
  suite: (ctx: ContractContext<T>) => void,
): ContractSuite<T> {
  return {
    run(buildSubject) {
      describe(`Contract: ${name}`, () => {
        suite({ buildSubject });
      });
    },
  };
}

Repository interfaces with multiple implementations get a contract suite. The same suite runs against the Mock impl AND the Payload impl (with vi.mock('payload')). Required for:

  • IArticlesRepository (Mock + Payload)
  • IUsersRepository (Mock + Payload)
  • ISessionsRepository (Mock + Payload)
  • IMediaRepository (Mock + Payload)
  • IPagesRepository (Mock + Payload)
  • ISiteSettingsRepository (Mock + Payload)
  • IHeaderRepository (Mock + Payload)

5.3 react/

// render-with-providers.tsx
export interface RenderOptions {
  trpc?: { mocks?: Record<string, unknown> };
  queryClient?: QueryClient;
}

export function renderWithProviders(
  ui: ReactElement,
  options: RenderOptions = {},
): RenderResult & { queryClient: QueryClient } {
  const queryClient = options.queryClient ?? new QueryClient({
    defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
  });
  const trpcClient = createMockTrpcClient(options.trpc?.mocks ?? {});
  const Wrapper = ({ children }: PropsWithChildren) => (
    <QueryClientProvider client={queryClient}>
      <TrpcProvider client={trpcClient} queryClient={queryClient}>
        {children}
      </TrpcProvider>
    </QueryClientProvider>
  );
  return { ...render(ui, { wrapper: Wrapper }), queryClient };
}

createMockTrpcClient is a thin wrapper that returns hardcoded data per procedure path; full type inference preserved via the AppRouter type from @repo/core-api.

5.4 payload/

// mock-payload-module.ts
export function mockPayloadModule(impl: Partial<Payload>): void {
  vi.mock('payload', () => ({
    getPayload: vi.fn().mockResolvedValue(impl),
  }));
}

// stub-config.ts
export const stubPayloadConfig = {} as SanitizedConfig;

5.5 setup/

// jsdom.ts
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';

afterEach(() => {
  cleanup();
});

// node.ts
// Currently a no-op; reserved for future global setup.
export {};

5.6 Package shape

packages/core-testing/
├── src/
│   ├── factory/
│   │   ├── define-factory.ts
│   │   ├── define-factory.test.ts
│   │   └── index.ts
│   ├── contract/
│   │   ├── define-contract-suite.ts
│   │   ├── define-contract-suite.test.ts
│   │   └── index.ts
│   ├── react/
│   │   ├── render-with-providers.tsx
│   │   ├── render-with-providers.test.tsx
│   │   ├── mock-trpc.ts
│   │   └── index.ts
│   ├── payload/
│   │   ├── mock-payload-module.ts
│   │   ├── stub-config.ts
│   │   └── index.ts
│   ├── setup/
│   │   ├── jsdom.ts
│   │   └── node.ts
│   └── index.ts
├── package.json     # tag: tooling, exports: ./factory, ./contract, ./react, ./payload, ./setup/jsdom, ./setup/node
├── tsconfig.json
├── vitest.config.ts
└── AGENTS.md

package.json exports map:

{
  "exports": {
    ".": "./src/index.ts",
    "./factory": "./src/factory/index.ts",
    "./contract": "./src/contract/index.ts",
    "./react": "./src/react/index.ts",
    "./payload": "./src/payload/index.ts",
    "./setup/jsdom": "./src/setup/jsdom.ts",
    "./setup/node": "./src/setup/node.ts"
  }
}

6. Closing each gap

6.1 Tests in core packages (Gap 1)

  • core-ui — jsdom env. Add one .test.tsx per primitive (Button, Input, Card, etc. — discovered during execution). Use RTL + renderWithProviders (no providers needed for primitives, but pattern uniform).
  • core-api — node env. Test that appRouter exposes the expected feature routers (appRouter._def.procedures walked recursively).
  • core-cms — node env. Test that the composed Payload config has expected collections + globals (config.collections.map(c => c.slug)).
  • core-trpc — node env (provider tests use jsdom). Test that the client factory wires httpBatchLink + superjson. Use msw or fetch stub.

6.2 React component testing (Gap 2)

core-typescript exports two configs:

// vitest.base.node.ts
export const nodeVitestConfig = defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
    setupFiles: ['@repo/core-testing/setup/node'],
    clearMocks: true,
    restoreMocks: true,
    mockReset: true,
    unstubGlobals: true,
    sequence: { shuffle: true },
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**'],
      exclude: [
        'src/**/*.test.{ts,tsx}',
        'src/**/index.ts',
        'src/__factories__/**',
        'src/__contracts__/**',
      ],
      thresholds: { statements: 80, branches: 75, functions: 80, lines: 80 },
    },
  },
});

// vitest.base.jsdom.ts
import { mergeConfig } from 'vitest/config';
import { nodeVitestConfig } from './vitest.base.node';

export const jsdomVitestConfig = mergeConfig(nodeVitestConfig, defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['@repo/core-testing/setup/jsdom'],
    include: ['src/**/*.test.{ts,tsx}', 'tests/**/*.test.{ts,tsx}'],
  },
}));

6.3 Factories (Gap 4)

See §5.1. One factory file per entity, in src/__factories__/. Factories deliver stable defaults so snapshot diffs only reflect the SUT's behavior. __factories__ excluded from coverage.

6.4 Contract suites (Gap 5)

See §5.2. One contract suite per repository interface, run against every implementation. Each repo *.test.ts becomes ~5 lines: vi.mock('payload', ...); contractSuite.run(() => new RealRepo(stubConfig));. Specific edge cases unique to one impl (e.g., Payload doc → domain mapping) stay in the impl-specific test file alongside the contract run.

6.5 jsdom + RTL (Gap 2 + Gap 1)

core-ui adopts jsdomVitestConfig. Test pattern:

// packages/core-ui/src/atoms/button/button.test.tsx
import { describe, it, expect } from 'vitest';
import { renderWithProviders } from '@repo/core-testing/react';
import userEvent from '@testing-library/user-event';
import { screen } from '@testing-library/react';
import { Button } from './button';

describe('Button', () => {
  it('renders the label', () => {
    renderWithProviders(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
  });

  it('calls onClick when activated', async () => {
    const handleClick = vi.fn();
    renderWithProviders(<Button onClick={handleClick}>Click me</Button>);
    await userEvent.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledOnce();
  });
});

6.6 Composition tests for core-api, core-cms, core-trpc (Gap 1)

// packages/core-api/src/router.test.ts
describe('appRouter composition', () => {
  it('exposes blog router', () => {
    expect(appRouter._def.procedures).toHaveProperty('blog');
  });
  it('exposes auth, media, marketing-pages, navigation routers', () => { ... });
});

// packages/core-cms/src/config.test.ts
describe('payloadConfig composition', () => {
  it('registers all feature collections', () => {
    const slugs = config.collections.map(c => c.slug);
    expect(slugs).toEqual(expect.arrayContaining(['articles', 'users', 'media', 'pages']));
  });
});

6.7 App tests (Gap 9)

Per app:

// apps/web-next/src/server/bind-production.test.ts
describe('bindProduction', () => {
  it('binds all 4 feature repositories exactly once', async () => {
    const spies = {
      blog: vi.spyOn(blogModule, 'bindProductionArticles'),
      auth: vi.spyOn(authModule, 'bindProductionUsers'),
      media: vi.spyOn(mediaModule, 'bindProductionMedia'),
      marketing: vi.spyOn(marketingModule, 'bindProductionPages'),
    };
    await bindProduction();
    Object.values(spies).forEach(s => expect(s).toHaveBeenCalledOnce());
  });
});

// apps/web-next/src/app/providers.test.tsx
describe('Providers', () => {
  it('wraps children with QueryClientProvider and TrpcProvider', () => {
    renderWithProviders(<Providers><div data-testid="child" /></Providers>);
    expect(screen.getByTestId('child')).toBeInTheDocument();
  });
});

// apps/cms/src/payload.config.test.ts
describe('payload.config', () => {
  it('exports a SanitizedConfig with all feature collections', () => {
    expect(payloadConfig.collections.length).toBeGreaterThanOrEqual(4);
  });
});

6.8 Storybook test runner (Gap 8)

pnpm add -D -w @storybook/test-runner playwright --filter @repo/storybook

apps/storybook/package.json gains:

{
  "scripts": {
    "test-storybook": "test-storybook --url http://localhost:6006"
  }
}

Root script: pnpm test:stories runs Storybook test-runner against a started Storybook instance. CI builds Storybook static, serves it, runs test-storybook against the static URL. Smoke-tests every story (mounts, no console errors).

6.9 Coverage thresholds (Gap 6)

Initial threshold: 80% statements / 75% branches / 80% functions / 80% lines (project-wide). entities/, application/use-cases/, interface-adapters/controllers/ should hit 100% — enforced via per-directory config:

coverage: {
  thresholds: {
    'src/entities/**': { statements: 100, branches: 100 },
    'src/application/use-cases/**': { statements: 100, branches: 95 },
    'src/interface-adapters/controllers/**': { statements: 100, branches: 95 },
    statements: 80, branches: 75, functions: 80, lines: 80,
  },
},

Tightening allowed as suite matures. Initial pass may need test additions to cross threshold — included as part of each task.

6.10 Sequence shuffling

sequence: { shuffle: true } surfaces tests that depend on execution order (a class of flake that's nearly impossible to debug otherwise). If a test fails on a non-default seed, the failure log includes the seed for reproduction.

6.11 CI (Gap 10)

.github/workflows/ci.yml:

name: CI
on:
  push: { branches: [main] }
  pull_request:

env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}

jobs:
  validate:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_USER: postgres
          POSTGRES_DB: cms
        ports: ['5432:5432']
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm typecheck
      - run: pnpm lint
      - run: pnpm turbo boundaries
      - run: pnpm test -- --coverage
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms
          PAYLOAD_SECRET: test-secret-do-not-use-in-prod
      - run: pnpm build
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage
          path: '**/coverage/lcov.info'

  e2e:
    needs: validate
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env: { POSTGRES_PASSWORD: postgres, POSTGRES_USER: postgres, POSTGRES_DB: cms }
        ports: ['5432:5432']
        options: >-
          --health-cmd "pg_isready -U postgres" --health-interval 10s
          --health-timeout 5s --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps chromium
      - run: pnpm test:e2e
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms
          PAYLOAD_SECRET: test-secret-do-not-use-in-prod

  storybook:
    needs: validate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm build-storybook --filter @repo/storybook
      - run: pnpm exec playwright install --with-deps chromium
      - run: pnpm exec concurrently -k -s first -n "SB,TEST" -c "magenta,blue" "pnpm exec http-server apps/storybook/storybook-static --port 6006 --silent" "pnpm exec wait-on tcp:6006 && pnpm test-storybook --filter @repo/storybook"

7. Documentation changes

7.1 New: docs/guides/tdd-workflow.md

Outline:

  1. The cycle — red, green, refactor, with the blog example walked end-to-end.
  2. Test namingdescribe(SUT) / it("does X when Y").
  3. AAA structure — Arrange / Act / Assert with examples.
  4. When to mock — decision tree (DOT graph):
    Pure function?       → no mock
    Use case?            → mock repository at DI level (rebind)
    Repository (Payload)?→ mock 'payload' module + use stubPayloadConfig
    Component with data? → renderWithProviders with trpc.mocks
    Route handler?       → mock at boundary only (no deep mocks)
    
  5. Test pyramid for this monorepo — counts per layer; entities + use-cases + controllers > integration > component > e2e.
  6. What NOT to test — getters/setters, framework code, third-party libs, types-only modules.
  7. Coverage targets — 80/75/80/80 baseline; 100% in entities + use-cases + controllers.
  8. Factory usage — when to call factory.build() vs construct objects manually.
  9. Contract suite usage — how to add a new repo impl (write impl, run contract).
  10. Running tests — watch mode, focused tests (it.only), debugging failures.

7.2 Restructure: docs/guides/adding-a-feature.md

Interleave tests with implementation. Each layer becomes a red-green pair:

Step 1: Write failing test for entity schema (Zod parse rejects invalid input)
Step 2: Implement entity to pass
Step 3: Write factory in src/__factories__/<entity>.factory.ts
Step 4: Write failing test for use case using factory + mock repo
Step 5: Implement use case to pass
Step 6: Write contract suite in src/__contracts__/<entity>-repository.contract.ts
Step 7: Implement Mock repo, run contract against it (red → green)
Step 8: Implement Payload repo, run same contract (red → green)
Step 9: Write failing controller test
Step 10: Implement controller to pass
Step 11: Write failing tRPC integration test in tests/<feature>.feature.test.ts
Step 12: Wire router to pass
Step 13: (UI optional) Write failing component test with renderWithProviders
Step 14: Implement component to pass
Step 15: Wire into core-api / core-cms, run typecheck + lint + boundaries

Add at top: "You may not advance to the next layer until the current layer's tests are red, then green."

testing-strategy.md becomes the placement doc; tdd-workflow.md is the process doc. Each links to the other. AGENTS.md references both.

7.4 ADR

New: docs/decisions/adr-011-tdd-foundation.md capturing:

  • Why a dedicated core-testing package (vs duplicating helpers)
  • Why factories instead of fixture files
  • Why contract suites instead of separate test files per impl
  • Why sequence.shuffle (catches order-dependent tests)
  • Why vi.mock('payload') over a real test DB (speed, determinism)

7.5 AGENTS.md updates

Root AGENTS.md: add @repo/core-testing row to package map, link docs/guides/tdd-workflow.md.

Each feature AGENTS.md: add a "Tests" section listing factories, contract suites, and example test commands.

Root CLAUDE.md: add a "TDD" subsection under Quick Start with the canonical commands.

8. Plan structure (preview)

Plan 7 — TDD Foundation, with these tasks (each TDD'd, self-reviewed, two-stage reviewed):

# Task Approx. files
1 Scaffold @repo/core-testing (TDD itself) ~20 created
2 Add jsdom + node Vitest bases to core-typescript, migrate 5 feature configs 2 created, 5 modified
3 Add factories to all 5 features, refactor mock repos to consume them 8 created, 5 modified
4 Define + run contract suites for all 7 repository interfaces 7 created, 14 modified (each impl .test.ts runs the contract)
5 Add tests + jsdom config to core-ui ~10 created
6 Add tests + node config to core-api, core-cms, core-trpc ~10 created
7 Add unit tests to apps/web-next, apps/web-tanstack, apps/cms ~10 created
8 Storybook test-runner integration in apps/storybook 1 modified, 1 created
9 Write docs/guides/tdd-workflow.md, restructure adding-a-feature.md, cross-link 1 created, 2 modified
10 ADR-011 + AGENTS.md updates + CLAUDE.md update 1 created, 6 modified
11 CI workflow .github/workflows/ci.yml with services + coverage upload 1 created
12 Coverage thresholds enforced; tighten where suites are mature 0 created, 12 modified (per-package vitest configs)

Estimated rough total: ~70 files created, ~25 files modified.

9. Tradeoffs

Decision Pro Con
New core-testing package DRY; one canonical place for helpers; testable in isolation One more package to maintain (but small, mostly stable)
Coverage thresholds Builds fail when coverage drops; agents can't merge regressions Initial enforcement may need test additions to cross threshold (planned for)
Sequence shuffle Catches order-dependent flakes early First run may surface latent flakes; we fix them as found
vi.mock('payload') over real DB in tests Fast (~ms vs seconds), deterministic, no Docker requirement Requires contract suite to catch mock/real drift (which is exactly why we add §5.2)
Storybook test-runner Every story becomes a smoke test for free Adds a CI job (~1-2 min); offset by Turbo cache
CI in this template TDD without CI rots in weeks Template users must rotate the Turbo token themselves; documented in CI yaml comments

10. Acceptance criteria

  • @repo/core-testing exists, exports the documented surface, has its own tests passing.
  • Every feature has factories in src/__factories__/ and a contract suite per repository interface.
  • core-ui, core-api, core-cms, core-trpc each have ≥1 passing test.
  • All 4 apps have ≥1 unit test (provider/bind-production/route).
  • pnpm test -- --coverage passes with thresholds.
  • pnpm test-storybook passes against built Storybook.
  • docs/guides/tdd-workflow.md exists; adding-a-feature.md interleaves tests with implementation.
  • .github/workflows/ci.yml runs typecheck, lint, boundaries, test, build, e2e, storybook on every PR.
  • ADR-011 committed.
  • AGENTS.md and CLAUDE.md reference the new TDD workflow.

11. Out of scope (explicit)

  • Mutation testing (Stryker)
  • Property-based testing (fast-check)
  • Visual regression beyond Storybook smoke (Chromatic, Percy)
  • Real-DB integration tests (testcontainers Postgres)
  • Performance/load testing
  • Security scanning in CI (separate concern)

These are good ideas for later but would expand scope past "make TDD frictionless." Tracked as future work.