docs(spec): TDD foundation design — close 10 gaps for agentic TDD
Catalogues the ten gaps that prevent reliable TDD by agents and humans in this monorepo, and specifies the closure plan: a new @repo/core-testing package (factories + contract suites + RTL helpers + payload mocks), jsdom + safety defaults in core-typescript Vitest bases, tests in core-* and apps, Storybook test-runner, coverage thresholds, CI workflow, and two new docs (tdd-workflow.md plus a restructured adding-a-feature.md that interleaves tests with implementation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
667
docs/superpowers/specs/2026-05-05-tdd-foundation-design.md
Normal file
667
docs/superpowers/specs/2026-05-05-tdd-foundation-design.md
Normal file
@@ -0,0 +1,667 @@
|
||||
# TDD Foundation — Design Spec
|
||||
|
||||
**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 layer** — `core-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/`
|
||||
|
||||
```typescript
|
||||
// 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/`
|
||||
|
||||
```typescript
|
||||
// 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/`
|
||||
|
||||
```typescript
|
||||
// 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/`
|
||||
|
||||
```typescript
|
||||
// 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/`
|
||||
|
||||
```typescript
|
||||
// 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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
```typescript
|
||||
// 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:
|
||||
|
||||
```typescript
|
||||
// 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)
|
||||
|
||||
```typescript
|
||||
// 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:
|
||||
|
||||
```typescript
|
||||
// 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)
|
||||
|
||||
```bash
|
||||
pnpm add -D -w @storybook/test-runner playwright --filter @repo/storybook
|
||||
```
|
||||
|
||||
`apps/storybook/package.json` gains:
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
```typescript
|
||||
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`:
|
||||
|
||||
```yaml
|
||||
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 naming** — `describe(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."**
|
||||
|
||||
### 7.3 Cross-link
|
||||
|
||||
`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.
|
||||
Reference in New Issue
Block a user