Twelve self-contained tasks closing all 10 gaps from the spec: 1. Scaffold @repo/core-testing 2. Vitest base configs (jsdom + node) in core-typescript 3. Factories per feature 4. Contract suites per repository interface 5. core-ui jsdom + RTL tests 6. core-api / core-cms / core-trpc composition tests 7. App unit tests (web-next, web-tanstack, cms) 8. Storybook test-runner integration 9. tdd-workflow.md + adding-a-feature.md restructure 10. ADR-011 + per-feature AGENTS.md 11. CI workflow 12. Per-directory coverage thresholds Each task TDD'd, single-commit-scoped, ready for subagent dispatch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
72 KiB
Plan 7 — TDD Foundation Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make full TDD frictionless in this monorepo by closing the ten gaps catalogued in docs/superpowers/specs/2026-05-05-tdd-foundation-design.md.
Architecture: Add a new @repo/core-testing package providing factories, contract suites, RTL helpers, and Payload mocks. Layer Vitest safety defaults (jsdom + node bases with coverage thresholds) into core-typescript. Add tests to every package and app, rewrite the docs to enforce TDD order, and add a CI workflow that runs typecheck + lint + boundaries + test + build + e2e + storybook on every PR.
Tech Stack: Vitest 3, @testing-library/react, @testing-library/user-event, jsdom, @storybook/test-runner, GitHub Actions, msw (or fetch stubs), tsx.
Spec: docs/superpowers/specs/2026-05-05-tdd-foundation-design.md — read this first if any task is unclear.
Worktree: Execute on branch feature/tdd-foundation in .worktrees/tdd-foundation/.
Cross-cutting conventions (re-read at the start of every task)
- TDD always: write a failing test, run it to confirm RED, write minimal implementation, run to confirm GREEN, refactor, commit.
- Source files use relative imports (
../foo.js); test files use@/alias. - Every new vitest config must extend
nodeVitestConfigorjsdomVitestConfigfrom@repo/core-typescriptand declare the@/alias. - Commit per task with a clear message. Never bundle two tasks into one commit.
- After each task: run
pnpm typecheck && pnpm lint && pnpm testfrom repo root before declaring DONE. - If a step says "expected: PASS" and the test fails, do NOT proceed. Diagnose and fix before moving on.
Task 1: Scaffold @repo/core-testing package
Files:
-
Create:
packages/core-testing/package.json -
Create:
packages/core-testing/tsconfig.json -
Create:
packages/core-testing/vitest.config.ts -
Create:
packages/core-testing/eslint.config.js -
Create:
packages/core-testing/turbo.json -
Create:
packages/core-testing/AGENTS.md -
Create:
packages/core-testing/src/index.ts -
Create:
packages/core-testing/src/factory/define-factory.ts -
Create:
packages/core-testing/src/factory/define-factory.test.ts -
Create:
packages/core-testing/src/factory/index.ts -
Create:
packages/core-testing/src/contract/define-contract-suite.ts -
Create:
packages/core-testing/src/contract/define-contract-suite.test.ts -
Create:
packages/core-testing/src/contract/index.ts -
Create:
packages/core-testing/src/setup/jsdom.ts -
Create:
packages/core-testing/src/setup/node.ts -
Create:
packages/core-testing/src/payload/stub-config.ts -
Create:
packages/core-testing/src/payload/mock-payload-module.ts -
Create:
packages/core-testing/src/payload/index.ts -
Create:
packages/core-testing/src/react/render-with-providers.tsx -
Create:
packages/core-testing/src/react/render-with-providers.test.tsx -
Create:
packages/core-testing/src/react/mock-trpc.ts -
Create:
packages/core-testing/src/react/index.ts -
Modify:
tsconfig.base.json— add@repo/core-testing/*aliases -
Step 1: Create package skeleton
mkdir -p packages/core-testing/src/{factory,contract,setup,payload,react}
- Step 2: Write package.json
packages/core-testing/package.json:
{
"name": "@repo/core-testing",
"version": "0.0.1",
"private": true,
"type": "module",
"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"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.0",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/tanstack-react-query": "^11.0.0",
"@tanstack/react-query": "^5.59.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"superjson": "^2.2.0",
"vitest": "^3.0.0"
},
"peerDependencies": {
"payload": "^3.0.0"
},
"peerDependenciesMeta": {
"payload": { "optional": true }
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"jsdom": "^25.0.0",
"typescript": "^5.8.0"
}
}
- Step 3: Write tsconfig.json
packages/core-testing/tsconfig.json:
{
"extends": "@repo/core-typescript/react-library.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
- Step 4: Write vitest.config.ts
packages/core-testing/vitest.config.ts:
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "jsdom",
include: ["src/**/*.test.{ts,tsx}"],
setupFiles: ["./src/setup/jsdom.ts"],
clearMocks: true,
restoreMocks: true,
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});
- Step 5: Write eslint.config.js
packages/core-testing/eslint.config.js:
import { config as base } from "@repo/core-eslint/base";
export default [
...base,
{
rules: {
// test utilities are intended to be used in test contexts
"no-console": "off",
},
},
];
- Step 6: Write turbo.json (tag: tooling)
packages/core-testing/turbo.json:
{
"$schema": "https://turborepo.dev/schema.json",
"extends": ["//"],
"tags": ["tooling"]
}
- Step 7: Write failing test for
defineFactory
packages/core-testing/src/factory/define-factory.test.ts:
import { describe, it, expect, beforeEach } from "vitest";
import { defineFactory } from "@/factory/define-factory";
interface User {
id: string;
name: string;
age: number;
createdAt: Date;
}
describe("defineFactory", () => {
const userFactory = defineFactory<User>(({ sequence }) => ({
id: `user-${sequence}`,
name: `User ${sequence}`,
age: 30,
createdAt: new Date("2026-01-01T00:00:00Z"),
}));
beforeEach(() => userFactory.reset());
it("builds a default object", () => {
const u = userFactory.build();
expect(u).toEqual({
id: "user-1",
name: "User 1",
age: 30,
createdAt: new Date("2026-01-01T00:00:00Z"),
});
});
it("increments sequence per build", () => {
const a = userFactory.build();
const b = userFactory.build();
expect(a.id).toBe("user-1");
expect(b.id).toBe("user-2");
});
it("applies overrides", () => {
const u = userFactory.build({ name: "Alice", age: 25 });
expect(u.name).toBe("Alice");
expect(u.age).toBe(25);
expect(u.id).toBe("user-1");
});
it("buildList builds N items with same overrides", () => {
const list = userFactory.buildList(3, { age: 40 });
expect(list).toHaveLength(3);
expect(list.map((u) => u.id)).toEqual(["user-1", "user-2", "user-3"]);
expect(list.every((u) => u.age === 40)).toBe(true);
});
it("reset() restarts the sequence", () => {
userFactory.build();
userFactory.build();
userFactory.reset();
expect(userFactory.build().id).toBe("user-1");
});
});
- Step 8: Run test to verify RED
pnpm install
cd packages/core-testing && pnpm test
Expected: FAIL — defineFactory not exported.
- Step 9: Implement
defineFactory
packages/core-testing/src/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 extends object>(
builder: (ctx: FactoryContext) => T,
): Factory<T> {
let sequence = 0;
return {
build(overrides) {
sequence += 1;
const base = builder({ sequence });
return { ...base, ...(overrides ?? {}) } as T;
},
buildList(count, overrides) {
return Array.from({ length: count }, () => this.build(overrides));
},
reset() {
sequence = 0;
},
};
}
packages/core-testing/src/factory/index.ts:
export { defineFactory, type Factory, type FactoryContext } from "./define-factory.js";
- Step 10: Run test to verify GREEN
cd packages/core-testing && pnpm test
Expected: 5/5 PASS in define-factory.test.ts.
- Step 11: Write failing test for
defineContractSuite
packages/core-testing/src/contract/define-contract-suite.test.ts:
import { describe, it, expect } from "vitest";
import { defineContractSuite } from "@/contract/define-contract-suite";
interface Adder {
add(a: number, b: number): number;
}
const adderContract = defineContractSuite<Adder>("Adder", ({ buildSubject }) => {
it("adds two positive numbers", async () => {
const subject = await buildSubject();
expect(subject.add(2, 3)).toBe(5);
});
it("handles zero", async () => {
const subject = await buildSubject();
expect(subject.add(0, 0)).toBe(0);
});
});
class RealAdder implements Adder {
add(a: number, b: number) {
return a + b;
}
}
describe("RealAdder satisfies Adder contract", () => {
adderContract.run(() => new RealAdder());
});
- Step 12: Run test to verify RED
cd packages/core-testing && pnpm test
Expected: FAIL — defineContractSuite not exported.
- Step 13: Implement
defineContractSuite
packages/core-testing/src/contract/define-contract-suite.ts:
import { describe } from "vitest";
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 });
});
},
};
}
packages/core-testing/src/contract/index.ts:
export { defineContractSuite, type ContractContext, type ContractSuite } from "./define-contract-suite.js";
- Step 14: Run test to verify GREEN
cd packages/core-testing && pnpm test
Expected: 7/7 PASS (5 factory + 2 contract).
- Step 15: Implement setup files
packages/core-testing/src/setup/jsdom.ts:
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});
packages/core-testing/src/setup/node.ts:
// Reserved for future global node-env setup. Currently a no-op so that
// vitest configs may reference @repo/core-testing/setup/node uniformly.
export {};
- Step 16: Implement payload stubs
packages/core-testing/src/payload/stub-config.ts:
import type { SanitizedConfig } from "payload";
// Minimal SanitizedConfig stub for tests that need to construct repos
// without actually loading the real Payload config. Repository tests
// that mock the `payload` module never read fields off this object.
export const stubPayloadConfig = {} as SanitizedConfig;
packages/core-testing/src/payload/mock-payload-module.ts:
import { vi } from "vitest";
import type { Payload } from "payload";
// Helper to mock the `payload` package with a custom getPayload impl.
// Call inside a test file BEFORE importing the SUT.
export function mockPayloadModule(impl: Partial<Payload>): void {
vi.mock("payload", () => ({
getPayload: vi.fn().mockResolvedValue(impl),
}));
}
packages/core-testing/src/payload/index.ts:
export { stubPayloadConfig } from "./stub-config.js";
export { mockPayloadModule } from "./mock-payload-module.js";
- Step 17: Implement react helpers
packages/core-testing/src/react/mock-trpc.ts:
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import type { AnyTRPCRouter } from "@trpc/server";
// Returns a tRPC client whose fetch is a stub honouring the provided mocks.
// Mocks are keyed by procedure path ("blog.articleBySlug") returning the
// raw response body.
export function createMockTrpcClient<TRouter extends AnyTRPCRouter>(
mocks: Record<string, unknown> = {},
) {
const fetchStub: typeof fetch = async (input) => {
const url = typeof input === "string" ? input : (input as Request).url;
const path = new URL(url, "http://mock").pathname.replace(/^\/api\/trpc\//, "");
const result = mocks[path];
if (result === undefined) {
return new Response(JSON.stringify([{ error: { code: -32603, message: `No mock for ${path}` } }]), { status: 200 });
}
return new Response(JSON.stringify([{ result: { data: superjson.serialize(result) } }]), { status: 200 });
};
return createTRPCClient<TRouter>({
links: [
httpBatchLink({
url: "http://mock/api/trpc",
transformer: superjson,
fetch: fetchStub,
}),
],
});
}
packages/core-testing/src/react/render-with-providers.tsx:
import type { PropsWithChildren, ReactElement } from "react";
import { render, type RenderResult } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export interface RenderOptions {
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 Wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return { ...render(ui, { wrapper: Wrapper }), queryClient };
}
packages/core-testing/src/react/render-with-providers.test.tsx:
import { describe, it, expect } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "@/react/render-with-providers";
describe("renderWithProviders", () => {
it("renders the child", () => {
renderWithProviders(<div data-testid="x">hi</div>);
expect(screen.getByTestId("x")).toBeInTheDocument();
});
it("returns the queryClient instance", () => {
const { queryClient } = renderWithProviders(<div />);
expect(queryClient).toBeDefined();
});
});
packages/core-testing/src/react/index.ts:
export { renderWithProviders, type RenderOptions } from "./render-with-providers.js";
export { createMockTrpcClient } from "./mock-trpc.js";
- Step 18: Write top-level barrel
packages/core-testing/src/index.ts:
export * from "./factory/index.js";
export * from "./contract/index.js";
- Step 19: Add tsconfig path aliases
Edit tsconfig.base.json and add to compilerOptions.paths:
"@repo/core-testing": ["packages/core-testing/src/index.ts"],
"@repo/core-testing/factory": ["packages/core-testing/src/factory/index.ts"],
"@repo/core-testing/contract": ["packages/core-testing/src/contract/index.ts"],
"@repo/core-testing/react": ["packages/core-testing/src/react/index.ts"],
"@repo/core-testing/payload": ["packages/core-testing/src/payload/index.ts"],
"@repo/core-testing/setup/jsdom": ["packages/core-testing/src/setup/jsdom.ts"],
"@repo/core-testing/setup/node": ["packages/core-testing/src/setup/node.ts"]
- Step 20: Write AGENTS.md
packages/core-testing/AGENTS.md:
# @repo/core-testing
Shared testing utilities. Tag: `tooling`. May be depended on by any package as a devDependency.
## Subpath exports
- `@repo/core-testing/factory` — `defineFactory<T>(builder)` for test data factories
- `@repo/core-testing/contract` — `defineContractSuite<T>(name, suite)` for cross-impl contract tests
- `@repo/core-testing/react` — `renderWithProviders`, `createMockTrpcClient`
- `@repo/core-testing/payload` — `stubPayloadConfig`, `mockPayloadModule`
- `@repo/core-testing/setup/jsdom` — vitest setupFile (jest-dom + cleanup)
- `@repo/core-testing/setup/node` — vitest setupFile (no-op placeholder)
## Adding a factory
```typescript
import { defineFactory } from "@repo/core-testing/factory";
export const articleFactory = defineFactory<Article>(({ sequence }) => ({
id: `article-${sequence}`,
title: `Article ${sequence}`,
// stable defaults — overrides drive variation
}));
Adding a contract suite
See docs/guides/tdd-workflow.md §"Contract suite usage".
- [ ] **Step 21: Verify everything**
```bash
pnpm install
pnpm typecheck --filter @repo/core-testing
pnpm test --filter @repo/core-testing
pnpm lint --filter @repo/core-testing
pnpm turbo boundaries
Expected: all green; 7 tests pass.
- Step 22: Commit
git add packages/core-testing tsconfig.base.json
git commit -m "feat(core-testing): scaffold shared testing utilities package
Adds @repo/core-testing (tag: tooling) with:
- factory/defineFactory: monotonic-sequence object factories with overrides
- contract/defineContractSuite: shared test suites runnable against multiple impls
- react/renderWithProviders + createMockTrpcClient: RTL helpers
- payload/stubPayloadConfig + mockPayloadModule: Payload mocking helpers
- setup/{jsdom,node}: vitest setup files
Spec: docs/superpowers/specs/2026-05-05-tdd-foundation-design.md §5"
Task 2: Vitest base configs (jsdom + node) in core-typescript
Files:
-
Modify:
packages/core-typescript/vitest.base.ts→ split into node/jsdom -
Create:
packages/core-typescript/vitest.base.node.ts -
Create:
packages/core-typescript/vitest.base.jsdom.ts -
Modify:
packages/core-typescript/package.json(exports) -
Modify: 5 feature
vitest.config.tsto use new base + alias -
Modify:
packages/core-shared/vitest.config.ts -
Step 1: Write failing test for the new node base
packages/core-typescript/vitest.base.node.test.ts:
import { describe, it, expect } from "vitest";
import { nodeVitestConfig } from "./vitest.base.node";
describe("nodeVitestConfig", () => {
it("uses node environment", () => {
expect(nodeVitestConfig.test?.environment).toBe("node");
});
it("enables clearMocks, restoreMocks, mockReset", () => {
expect(nodeVitestConfig.test?.clearMocks).toBe(true);
expect(nodeVitestConfig.test?.restoreMocks).toBe(true);
expect(nodeVitestConfig.test?.mockReset).toBe(true);
});
it("declares coverage thresholds", () => {
expect(nodeVitestConfig.test?.coverage?.thresholds).toMatchObject({
statements: 80, branches: 75, functions: 80, lines: 80,
});
});
it("includes src and tests glob", () => {
expect(nodeVitestConfig.test?.include).toEqual(
expect.arrayContaining(["src/**/*.test.ts", "tests/**/*.test.ts"]),
);
});
it("excludes factories and contracts from coverage", () => {
expect(nodeVitestConfig.test?.coverage?.exclude).toEqual(
expect.arrayContaining(["src/__factories__/**", "src/__contracts__/**"]),
);
});
});
- Step 2: Run to verify RED
cd packages/core-typescript && pnpm test
Expected: FAIL — module does not exist.
- Step 3: Implement
vitest.base.node.ts
packages/core-typescript/vitest.base.node.ts:
import { defineConfig } from "vitest/config";
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,
},
},
},
});
- Step 4: Write failing test for jsdom base
packages/core-typescript/vitest.base.jsdom.test.ts:
import { describe, it, expect } from "vitest";
import { jsdomVitestConfig } from "./vitest.base.jsdom";
describe("jsdomVitestConfig", () => {
it("uses jsdom environment", () => {
expect(jsdomVitestConfig.test?.environment).toBe("jsdom");
});
it("loads the jsdom setup file", () => {
expect(jsdomVitestConfig.test?.setupFiles).toEqual(
expect.arrayContaining(["@repo/core-testing/setup/jsdom"]),
);
});
it("includes tsx files", () => {
expect(jsdomVitestConfig.test?.include).toEqual(
expect.arrayContaining(["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"]),
);
});
it("inherits clearMocks from node base", () => {
expect(jsdomVitestConfig.test?.clearMocks).toBe(true);
});
});
- Step 5: Run to verify RED
cd packages/core-typescript && pnpm test
Expected: FAIL — module does not exist.
- Step 6: Implement
vitest.base.jsdom.ts
packages/core-typescript/vitest.base.jsdom.ts:
import { defineConfig, mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "./vitest.base.node.js";
export const jsdomVitestConfig = mergeConfig(
nodeVitestConfig,
defineConfig({
test: {
environment: "jsdom",
setupFiles: ["@repo/core-testing/setup/jsdom"],
include: ["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"],
},
}),
);
- Step 7: Update package.json exports
Edit packages/core-typescript/package.json exports map to add:
"./vitest.base.node": "./vitest.base.node.ts",
"./vitest.base.jsdom": "./vitest.base.jsdom.ts"
(Keep existing ./vitest.base for backwards-compat — it should re-export nodeVitestConfig as baseVitestConfig from vitest.base.ts.)
- Step 8: Update legacy
vitest.base.ts
packages/core-typescript/vitest.base.ts:
// Backwards-compat re-export. New code should import from
// vitest.base.node or vitest.base.jsdom directly.
export { nodeVitestConfig as baseVitestConfig } from "./vitest.base.node.js";
- Step 9: Add devDependency on @repo/core-testing for the type aliases used by setupFiles
Edit packages/core-typescript/package.json devDependencies — add "@repo/core-testing": "workspace:*".
- Step 10: Run tests for core-typescript to verify GREEN
pnpm install
cd packages/core-typescript && pnpm test
Expected: 9/9 PASS.
- Step 11: Migrate feature vitest configs
For each of packages/{auth,blog,marketing-pages,navigation,core-shared}/vitest.config.ts, replace contents with:
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});
- Step 12: Run all tests to confirm migration didn't break anything
pnpm test
Expected: existing 96 tests still pass (some may now flag coverage threshold misses — note them, will fix in Task 12).
- Step 13: Commit
git add packages/core-typescript packages/auth/vitest.config.ts packages/blog/vitest.config.ts packages/marketing-pages/vitest.config.ts packages/navigation/vitest.config.ts packages/core-shared/vitest.config.ts
git commit -m "feat(core-typescript): split vitest base into node + jsdom flavors
Adds vitest.base.node and vitest.base.jsdom with safety defaults
(clearMocks, restoreMocks, mockReset, unstubGlobals, sequence.shuffle)
and coverage thresholds (80/75/80/80). Migrates all feature configs
to the new base. Existing baseVitestConfig kept as backwards-compat
re-export of nodeVitestConfig.
Spec: §6.2"
Task 3: Add factories to all 5 features
Files:
-
Create:
packages/auth/src/__factories__/user.factory.ts -
Create:
packages/auth/src/__factories__/user.factory.test.ts -
Create:
packages/auth/src/__factories__/index.ts -
Create:
packages/blog/src/__factories__/article.factory.ts -
Create:
packages/blog/src/__factories__/article.factory.test.ts -
Create:
packages/blog/src/__factories__/index.ts -
Create:
packages/marketing-pages/src/__factories__/page.factory.ts -
Create:
packages/marketing-pages/src/__factories__/page.factory.test.ts -
Create:
packages/marketing-pages/src/__factories__/site-settings.factory.ts -
Create:
packages/marketing-pages/src/__factories__/index.ts -
Create:
packages/navigation/src/__factories__/header.factory.ts -
Create:
packages/navigation/src/__factories__/header.factory.test.ts -
Create:
packages/navigation/src/__factories__/index.ts -
Create:
packages/media/src/__factories__/media.factory.ts -
Create:
packages/media/src/__factories__/media.factory.test.ts -
Create:
packages/media/src/__factories__/index.ts -
Modify: each feature's
package.json— add@repo/core-testingdevDependency -
Modify: existing tests in each feature to use factories where it cuts boilerplate (lightweight pass; do not rewrite working tests just for stylistic reasons)
-
Step 1: Write failing test for
articleFactory
packages/blog/src/__factories__/article.factory.test.ts:
import { describe, it, expect, beforeEach } from "vitest";
import { articleFactory } from "@/__factories__/article.factory";
describe("articleFactory", () => {
beforeEach(() => articleFactory.reset());
it("returns an Article with stable defaults", () => {
const a = articleFactory.build();
expect(a.title).toBe("Article 1");
expect(a.slug).toBe("article-1");
expect(a.status).toBe("draft");
expect(a.createdAt).toEqual(new Date("2026-01-01T00:00:00Z"));
});
it("applies overrides", () => {
const a = articleFactory.build({ status: "published", title: "X" });
expect(a.status).toBe("published");
expect(a.title).toBe("X");
});
});
- Step 2: Run to verify RED
cd packages/blog && pnpm test
Expected: FAIL — article.factory.ts does not exist.
- Step 3: Implement factory + add devDependency
Edit packages/blog/package.json to add "@repo/core-testing": "workspace:*" under devDependencies. Run pnpm install.
packages/blog/src/__factories__/article.factory.ts:
import { defineFactory } from "@repo/core-testing/factory";
import type { Article } from "../entities/article.js";
export const articleFactory = defineFactory<Article>(({ sequence }) => ({
id: `article-${sequence}`,
title: `Article ${sequence}`,
slug: `article-${sequence}`,
content: null,
status: "draft",
authorId: "user-1",
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
}));
packages/blog/src/__factories__/index.ts:
export { articleFactory } from "./article.factory.js";
- Step 4: Run to verify GREEN
cd packages/blog && pnpm test
Expected: existing tests still pass + 2 new factory tests pass.
- Step 5: Repeat steps 1-4 for each remaining factory
For each, write a small *.factory.test.ts (2-3 tests), then implement:
packages/auth/src/__factories__/user.factory.ts:
import { defineFactory } from "@repo/core-testing/factory";
import type { User } from "../entities/user.js";
export const userFactory = defineFactory<User>(({ sequence }) => ({
id: `user-${sequence}`,
email: `user${sequence}@example.com`,
role: "user",
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
}));
Adjust fields to match the actual User entity. Read packages/auth/src/entities/user.ts first.
packages/marketing-pages/src/__factories__/page.factory.ts:
import { defineFactory } from "@repo/core-testing/factory";
import type { Page } from "../entities/page.js";
export const pageFactory = defineFactory<Page>(({ sequence }) => ({
id: `page-${sequence}`,
slug: `page-${sequence}`,
title: `Page ${sequence}`,
content: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
}));
Read packages/marketing-pages/src/entities/page.ts and adapt.
packages/marketing-pages/src/__factories__/site-settings.factory.ts:
import { defineFactory } from "@repo/core-testing/factory";
import type { SiteSettings } from "../entities/site-settings.js";
export const siteSettingsFactory = defineFactory<SiteSettings>(({ sequence }) => ({
id: `settings-${sequence}`,
siteName: `Site ${sequence}`,
// adapt to actual entity
}));
Read packages/marketing-pages/src/entities/site-settings.ts and adapt.
packages/navigation/src/__factories__/header.factory.ts:
import { defineFactory } from "@repo/core-testing/factory";
import type { Header } from "../entities/header.js";
export const headerFactory = defineFactory<Header>(({ sequence }) => ({
id: `header-${sequence}`,
navItems: [],
// adapt to actual entity
}));
Read packages/navigation/src/entities/header.ts and adapt.
packages/media/src/__factories__/media.factory.ts:
Media has no entity layer currently — create a minimal Media interface in this factory's file or skip if no consumer needs it. Read packages/media/src first; if there's no entity, write the factory to return a Payload Media doc shape:
import { defineFactory } from "@repo/core-testing/factory";
export interface Media {
id: string;
alt: string;
url: string;
filename: string;
mimeType: string;
filesize: number;
}
export const mediaFactory = defineFactory<Media>(({ sequence }) => ({
id: `media-${sequence}`,
alt: `Media ${sequence}`,
url: `https://cdn.example.com/media-${sequence}.png`,
filename: `media-${sequence}.png`,
mimeType: "image/png",
filesize: 1024,
}));
- Step 6: Refactor existing tests to consume factories where it removes boilerplate
In each feature, find tests that inline raw fixture objects (like packages/blog/src/application/use-cases/get-articles.use-case.test.ts:23-32) and replace with articleFactory.build({ overrides }). Light pass — only edit tests where the change is mechanical and obviously correct.
Example refactor:
// before:
await repo.createArticle({
id: "1", title: "A", slug: "a", content: null, status: "draft",
authorId: "u1", createdAt: now, updatedAt: now,
});
// after:
await repo.createArticle(articleFactory.build({ id: "1", title: "A", slug: "a" }));
- Step 7: Run all tests
pnpm test
Expected: all tests pass; factories add ~10 new tests.
- Step 8: Commit
git add packages/auth packages/blog packages/marketing-pages packages/navigation packages/media
git commit -m "feat(features): add test factories to all 5 features
Adds src/__factories__/<entity>.factory.ts to auth, blog, marketing-pages,
navigation, media. Each factory uses defineFactory from @repo/core-testing
with stable date defaults (2026-01-01) so snapshot diffs reflect SUT
behavior only. Refactors mechanical inline-fixture tests to use factories.
Spec: §5.1, §6.3"
Task 4: Contract suites for repository interfaces
Files:
-
Create:
packages/blog/src/__contracts__/articles-repository.contract.ts -
Create:
packages/auth/src/__contracts__/users-repository.contract.ts -
Create:
packages/marketing-pages/src/__contracts__/pages-repository.contract.ts -
Create:
packages/marketing-pages/src/__contracts__/site-settings-repository.contract.ts -
Create:
packages/navigation/src/__contracts__/header-repository.contract.ts -
Modify: each
*.repository.test.ts(mock + payload impls) to invokecontract.run() -
Step 1: Write failing contract test for
IArticlesRepository
packages/blog/src/__contracts__/articles-repository.contract.ts:
import { it, expect, beforeEach } from "vitest";
import { defineContractSuite } from "@repo/core-testing/contract";
import type { IArticlesRepository } from "../application/repositories/articles-repository.interface.js";
import { articleFactory } from "../__factories__/article.factory.js";
export const articlesRepositoryContract = defineContractSuite<IArticlesRepository>(
"IArticlesRepository",
({ buildSubject }) => {
let repo: IArticlesRepository;
beforeEach(async () => {
articleFactory.reset();
repo = await buildSubject();
});
it("createArticle persists then getArticleBySlug returns it", async () => {
const seed = articleFactory.build({ slug: "x" });
await repo.createArticle(seed);
const result = await repo.getArticleBySlug("x");
expect(result?.id).toBe(seed.id);
});
it("getArticleBySlug returns undefined for missing slug", async () => {
expect(await repo.getArticleBySlug("does-not-exist")).toBeUndefined();
});
it("listArticles returns all when no filter", async () => {
await repo.createArticle(articleFactory.build());
await repo.createArticle(articleFactory.build());
const list = await repo.listArticles();
expect(list).toHaveLength(2);
});
it("listArticles filters by status", async () => {
await repo.createArticle(articleFactory.build({ status: "draft" }));
await repo.createArticle(articleFactory.build({ status: "published" }));
const drafts = await repo.listArticles({ status: "draft" });
expect(drafts).toHaveLength(1);
expect(drafts[0]?.status).toBe("draft");
});
},
);
(If the actual IArticlesRepository interface differs, adjust assertions to match. Read the interface file first.)
- Step 2: Wire contract into mock impl test
Create or edit packages/blog/src/infrastructure/repositories/mock-articles.repository.test.ts:
import { describe } from "vitest";
import { MockArticlesRepository } from "./mock-articles.repository";
import { articlesRepositoryContract } from "../../__contracts__/articles-repository.contract";
describe("MockArticlesRepository", () => {
articlesRepositoryContract.run(() => new MockArticlesRepository());
});
- Step 3: Run to verify GREEN for mock impl
cd packages/blog && pnpm test mock-articles.repository
Expected: 4 contract tests pass against MockArticlesRepository. If any fail, the mock has a bug — fix the mock to match the contract.
- Step 4: Wire contract into payload impl test
Edit packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts — wrap existing impl-specific tests in their own describe and add a contract block. Use vi.mock('payload') to back the contract's repo with an in-memory store:
import { describe, vi, beforeEach } from "vitest";
import { PayloadArticlesRepository } from "./payload-articles.repository";
import { articlesRepositoryContract } from "../../__contracts__/articles-repository.contract";
import { stubPayloadConfig } from "@repo/core-testing/payload";
// Build an in-memory Payload-shaped store so the same contract suite runs.
function buildPayloadStub() {
const store = new Map<string, unknown>();
return {
create: vi.fn(async ({ data }: { data: { id: string } & Record<string, unknown> }) => {
store.set(data.id, data);
return data;
}),
find: vi.fn(async ({ where }: { where?: { slug?: { equals: string } } }) => {
const all = Array.from(store.values()) as Array<{ slug: string }>;
const docs = where?.slug ? all.filter((d) => d.slug === where.slug?.equals) : all;
return { docs };
}),
findByID: vi.fn(async ({ id }: { id: string }) => store.get(id)),
};
}
vi.mock("payload", () => ({
getPayload: vi.fn(),
}));
describe("PayloadArticlesRepository", () => {
describe("contract", () => {
articlesRepositoryContract.run(async () => {
const stub = buildPayloadStub();
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
return new PayloadArticlesRepository(stubPayloadConfig);
});
});
// ... keep existing impl-specific tests (Payload doc → domain mapping, etc.)
});
If the existing tests cover specific Payload-doc-to-domain mapping cases that the contract can't hit (e.g., the author field becoming authorId), keep those as separate it blocks alongside the contract.
- Step 5: Run to verify GREEN for payload impl
cd packages/blog && pnpm test payload-articles.repository
Expected: contract tests pass against PayloadArticlesRepository (with the in-memory stub).
- Step 6: Repeat for the remaining 4 contracts
For each of:
IUsersRepository(auth) — mock only currently; contract still defined for future Payload implIPagesRepository(marketing-pages) — mock + payloadISiteSettingsRepository(marketing-pages) — mock + payloadIHeaderRepository(navigation) — mock + payload
Read the interface, draft the contract suite (4-8 cases per repo), wire into both impls (mock first, then payload via in-memory stub).
- Step 7: Run all tests
pnpm test
Expected: existing 96 + ~30 new contract assertions pass.
- Step 8: Commit
git add packages/{auth,blog,marketing-pages,navigation}/src/__contracts__ packages/{auth,blog,marketing-pages,navigation}/src/infrastructure/repositories/*.test.ts
git commit -m "feat(features): contract suites for all repository interfaces
Each repository interface now has a contract suite under
src/__contracts__/. Both Mock and Payload implementations run the
same suite, eliminating mock-vs-real drift. Payload impls back the
contract with an in-memory stub via vi.mock('payload') + a small
buildPayloadStub helper.
Spec: §5.2, §6.4"
Task 5: Tests + jsdom config for core-ui
Files:
-
Create:
packages/core-ui/vitest.config.ts -
Modify:
packages/core-ui/package.json— add devDeps + scripts -
Create:
packages/core-ui/src/atoms/button/button.test.tsx -
Create:
packages/core-ui/src/atoms/input/input.test.tsx -
Create:
packages/core-ui/src/atoms/label/label.test.tsx -
Create at least one
.test.tsxper discovered molecule and organism (readpackages/core-ui/src/{molecules,organisms,templates}/first) -
Step 1: Add devDependencies
Edit packages/core-ui/package.json:
{
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@storybook/react": "^8.6.0",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.0",
"@types/react": "^19.0.0",
"jsdom": "^25.0.0",
"vitest": "^3.0.0"
}
}
Run pnpm install.
- Step 2: Create
vitest.config.ts
packages/core-ui/vitest.config.ts:
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { jsdomVitestConfig } from "@repo/core-typescript/vitest.base.jsdom";
export default mergeConfig(jsdomVitestConfig, {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});
- Step 3: Write failing test for Button
packages/core-ui/src/atoms/button/button.test.tsx:
import { describe, it, expect, vi } from "vitest";
import { renderWithProviders } from "@repo/core-testing/react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "./button";
describe("Button", () => {
it("renders children inside a button", () => {
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}>Go</Button>);
await userEvent.click(screen.getByRole("button", { name: "Go" }));
expect(handleClick).toHaveBeenCalledOnce();
});
it("applies the variant class", () => {
renderWithProviders(<Button variant="destructive">X</Button>);
expect(screen.getByRole("button")).toHaveClass(/destructive/);
});
it("applies the size class", () => {
renderWithProviders(<Button size="lg">X</Button>);
expect(screen.getByRole("button")).toHaveClass(/h-11/);
});
it("disabled prop sets the attribute", () => {
renderWithProviders(<Button disabled>X</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
});
- Step 4: Run to verify GREEN
cd packages/core-ui && pnpm test
Expected: 5/5 PASS (Button already exists).
- Step 5: Repeat for Input, Label
Read each component file first, then write a small test suite that exercises rendering, props, and one user interaction.
packages/core-ui/src/atoms/input/input.test.tsx:
import { describe, it, expect } from "vitest";
import { renderWithProviders } from "@repo/core-testing/react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Input } from "./input";
describe("Input", () => {
it("renders an input element", () => {
renderWithProviders(<Input placeholder="email" />);
expect(screen.getByPlaceholderText("email")).toBeInTheDocument();
});
it("accepts user input", async () => {
renderWithProviders(<Input placeholder="email" />);
const input = screen.getByPlaceholderText("email");
await userEvent.type(input, "hi@example.com");
expect(input).toHaveValue("hi@example.com");
});
});
packages/core-ui/src/atoms/label/label.test.tsx:
import { describe, it, expect } from "vitest";
import { renderWithProviders } from "@repo/core-testing/react";
import { screen } from "@testing-library/react";
import { Label } from "./label";
describe("Label", () => {
it("renders the text", () => {
renderWithProviders(<Label htmlFor="x">Email</Label>);
expect(screen.getByText("Email")).toBeInTheDocument();
});
});
- Step 6: Discover and test molecules + organisms + templates
ls packages/core-ui/src/molecules packages/core-ui/src/organisms packages/core-ui/src/templates
For each component discovered, create a corresponding *.test.tsx with at least:
- A "renders" smoke test
- One prop-driven assertion
- One interaction test if the component handles user events
Keep tests minimal but real. The goal is establishing the pattern + at least one test per file, not exhaustive coverage in this task.
- Step 7: Run all core-ui tests
cd packages/core-ui && pnpm test
Expected: all new tests pass.
- Step 8: Commit
git add packages/core-ui
git commit -m "feat(core-ui): add jsdom Vitest config + RTL tests for components
Adopts jsdomVitestConfig from @repo/core-typescript. Adds
@testing-library/react, @testing-library/user-event, jsdom devDeps.
Writes smoke + interaction tests for every atom/molecule/organism/template
using renderWithProviders from @repo/core-testing/react.
Spec: §6.1, §6.5"
Task 6: Tests + node config for core-api, core-cms, core-trpc
Files:
-
Create:
packages/core-api/vitest.config.ts -
Create:
packages/core-api/src/router.test.ts -
Modify:
packages/core-api/package.json— add test script + devDeps -
Create:
packages/core-cms/vitest.config.ts -
Create:
packages/core-cms/src/payload.config.test.ts -
Modify:
packages/core-cms/package.json— add test script + devDeps -
Create:
packages/core-trpc/vitest.config.ts -
Create:
packages/core-trpc/src/client.test.ts -
Modify:
packages/core-trpc/package.json— add test script + devDeps -
Step 1: Add vitest devDep + script to core-api
Edit packages/core-api/package.json:
- Scripts: add
"test": "vitest run --passWithNoTests" - DevDependencies: add
"@repo/core-testing": "workspace:*","vitest": "^3.0.0"
Run pnpm install.
- Step 2: Create vitest.config.ts
packages/core-api/vitest.config.ts:
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});
- Step 3: Write failing composition test
packages/core-api/src/router.test.ts:
import { describe, it, expect } from "vitest";
import { appRouter } from "./root";
describe("appRouter composition", () => {
it("exposes auth, blog, marketingPages, navigation routers", () => {
const procedures = appRouter._def.procedures;
expect(Object.keys(procedures)).toEqual(
expect.arrayContaining(["auth", "blog", "marketingPages", "navigation"]),
);
});
it("blog router has expected procedures", () => {
const blog = (appRouter._def.procedures as Record<string, { _def: { procedures: Record<string, unknown> } }>).blog;
expect(blog._def.procedures).toHaveProperty("articleBySlug");
expect(blog._def.procedures).toHaveProperty("listArticles");
});
});
- Step 4: Run to verify GREEN
cd packages/core-api && pnpm test
Expected: 2/2 PASS. If procedure shape differs, adjust assertions to match reality (the goal is verifying composition, not enforcing a specific shape).
- Step 5: Repeat for core-cms
Edit packages/core-cms/package.json similarly.
packages/core-cms/vitest.config.ts: same pattern.
packages/core-cms/src/payload.config.test.ts:
import { describe, it, expect } from "vitest";
import config from "./payload.config";
describe("payloadConfig composition", () => {
it("registers all feature collections", async () => {
const resolved = await config;
const slugs = resolved.collections?.map((c) => c.slug) ?? [];
expect(slugs).toEqual(expect.arrayContaining(["users", "articles", "pages", "media"]));
});
it("registers all feature globals", async () => {
const resolved = await config;
const slugs = resolved.globals?.map((g) => g.slug) ?? [];
expect(slugs).toEqual(expect.arrayContaining(["site-settings", "header"]));
});
});
(buildConfig returns either a Promise or value; awaiting works for both.)
- Step 6: Repeat for core-trpc
Edit packages/core-trpc/package.json similarly.
packages/core-trpc/vitest.config.ts: same pattern, but jsdom (provider tests):
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { jsdomVitestConfig } from "@repo/core-typescript/vitest.base.jsdom";
export default mergeConfig(jsdomVitestConfig, {
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});
packages/core-trpc/src/client.test.ts:
import { describe, it, expect } from "vitest";
import { useTRPC, TRPCProvider } from "./client";
describe("core-trpc client exports", () => {
it("exports useTRPC hook", () => {
expect(useTRPC).toBeTypeOf("function");
});
it("exports TRPCProvider component", () => {
expect(TRPCProvider).toBeTypeOf("function");
});
});
Add a more meaningful provider test if the providers/ folder has stable wiring to assert (e.g., assert httpBatchLink config includes superjson).
- Step 7: Run all tests
pnpm test
Expected: existing tests + 6+ new core-* tests pass.
- Step 8: Commit
git add packages/core-api packages/core-cms packages/core-trpc
git commit -m "feat(core-*): add Vitest configs + composition tests
core-api: appRouter exposes all 4 feature routers + blog procedure shape.
core-cms: payloadConfig registers all collections + globals.
core-trpc: client + provider exports verified.
Spec: §6.1, §6.6"
Task 7: Unit tests for apps
Files:
-
Create:
apps/web-next/vitest.config.ts -
Create:
apps/web-next/src/server/bind-production.test.ts -
Create:
apps/web-next/src/app/providers.test.tsx -
Modify:
apps/web-next/package.json— add test script + devDeps -
Create:
apps/web-tanstack/vitest.config.ts -
Create:
apps/web-tanstack/src/<provider-or-bind>.test.tsx -
Modify:
apps/web-tanstack/package.json— add test script + devDeps -
Create:
apps/cms/vitest.config.ts -
Create:
apps/cms/src/payload.config.test.ts(or wherever the config is exported) -
Modify:
apps/cms/package.json— add test script + devDeps -
Step 1: Add devDeps and script to web-next
Edit apps/web-next/package.json:
- Scripts:
"test": "vitest run --passWithNoTests" - DevDependencies:
"@repo/core-testing": "workspace:*","vitest": "^3.0.0", plus@testing-library/react,@testing-library/jest-dom,@testing-library/user-event,jsdomif not already present.
Run pnpm install.
- Step 2: Create vitest.config.ts
apps/web-next/vitest.config.ts:
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { jsdomVitestConfig } from "@repo/core-typescript/vitest.base.jsdom";
export default mergeConfig(jsdomVitestConfig, {
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});
- Step 3: Write failing test for
bindAllProduction
apps/web-next/src/server/bind-production.test.ts:
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) }));
vi.mock("@repo/blog/di/bind-production", () => ({ bindProductionBlog: vi.fn() }));
vi.mock("@repo/auth/di/bind-production", () => ({ bindProductionAuth: vi.fn() }));
vi.mock("@repo/marketing-pages/di/bind-production", () => ({ bindProductionMarketingPages: vi.fn() }));
vi.mock("@repo/navigation/di/bind-production", () => ({ bindProductionNavigation: vi.fn() }));
describe("bindAllProduction", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
it("binds all four feature production repos", async () => {
const { bindAllProduction } = await import("./bind-production");
const { bindProductionBlog } = await import("@repo/blog/di/bind-production");
const { bindProductionAuth } = await import("@repo/auth/di/bind-production");
const { bindProductionMarketingPages } = await import("@repo/marketing-pages/di/bind-production");
const { bindProductionNavigation } = await import("@repo/navigation/di/bind-production");
await bindAllProduction();
expect(bindProductionBlog).toHaveBeenCalledOnce();
expect(bindProductionAuth).toHaveBeenCalledOnce();
expect(bindProductionMarketingPages).toHaveBeenCalledOnce();
expect(bindProductionNavigation).toHaveBeenCalledOnce();
});
it("is idempotent — second call does not re-bind", async () => {
const { bindAllProduction } = await import("./bind-production");
const { bindProductionBlog } = await import("@repo/blog/di/bind-production");
await bindAllProduction();
await bindAllProduction();
expect(bindProductionBlog).toHaveBeenCalledOnce();
});
});
- Step 4: Run to verify GREEN
cd apps/web-next && pnpm test
Expected: 2/2 PASS.
- Step 5: Write failing providers test
Read apps/web-next/src/app/providers.tsx to understand its shape, then:
apps/web-next/src/app/providers.test.tsx:
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { Providers } from "./providers";
describe("Providers", () => {
it("renders children", () => {
render(
<Providers>
<div data-testid="child">hi</div>
</Providers>,
);
expect(screen.getByTestId("child")).toBeInTheDocument();
});
});
If Providers requires server-side data (e.g., a tRPC initial state), pass it as a prop or mock the dependency.
- Step 6: Run to verify GREEN
cd apps/web-next && pnpm test
Expected: PASS.
- Step 7: Repeat for web-tanstack and cms
For apps/web-tanstack:
- vitest.config.ts (jsdom)
- One test asserting the providers wire
QueryClientProvider - One test asserting the equivalent of bindAllProduction (if applicable)
For apps/cms:
-
vitest.config.ts (node)
-
A
payload.config.test.tsthat asserts the exported config has expected collections/globals (similar to core-cms but with the app-level config) -
Step 8: Run all tests
pnpm test
Expected: all tests pass.
- Step 9: Commit
git add apps/web-next apps/web-tanstack apps/cms
git commit -m "feat(apps): add unit tests for providers + bind-production + cms config
web-next: bindAllProduction calls all 4 feature binders exactly once;
Providers renders children. web-tanstack: equivalent providers + bind tests.
cms: payload.config exports a SanitizedConfig with all expected collections.
Spec: §6.7, §9"
Task 8: Storybook test-runner integration
Files:
-
Modify:
apps/storybook/package.json— add@storybook/test-runner, playwright, scripts -
Create:
apps/storybook/test-runner.config.ts -
Create:
apps/storybook/.storybook/test-runner.test.ts(smoke check the test-runner config loads) -
Modify: root
package.json— addtest:storiesscript -
Modify: root
turbo.json— addtest-storybooktask -
Step 1: Add devDeps
Edit apps/storybook/package.json devDependencies:
{
"@storybook/test-runner": "^0.21.0",
"playwright": "^1.50.0",
"concurrently": "^9.0.0",
"http-server": "^14.0.0",
"wait-on": "^8.0.0"
}
And scripts:
{
"scripts": {
"build-storybook": "storybook build",
"test-storybook": "test-storybook --url http://localhost:6006",
"test:stories": "concurrently -k -s first -n 'SB,TEST' -c 'magenta,blue' 'pnpm exec http-server storybook-static --port 6006 --silent' 'pnpm exec wait-on tcp:6006 && pnpm test-storybook'"
}
}
Run pnpm install.
- Step 2: Create test-runner.config.ts
apps/storybook/test-runner.config.ts:
import type { TestRunnerConfig } from "@storybook/test-runner";
const config: TestRunnerConfig = {
async preVisit(page) {
page.on("console", (msg) => {
if (msg.type() === "error") {
throw new Error(`Console error in story: ${msg.text()}`);
}
});
},
};
export default config;
- Step 3: Add test script to root package.json
Edit root package.json scripts:
{
"scripts": {
"test:stories": "turbo run test:stories"
}
}
- Step 4: Add task to root turbo.json
Edit root turbo.json:
{
"tasks": {
"test:stories": {
"dependsOn": ["build-storybook"],
"cache": false
},
"build-storybook": {
"outputs": ["storybook-static/**"]
}
}
}
- Step 5: Verify the storybook test runner works locally
pnpm install
pnpm exec playwright install --with-deps chromium
pnpm build-storybook --filter @repo/storybook
cd apps/storybook && pnpm test:stories
Expected: every story mounts without console errors. If any story fails, fix it (or document as a known issue with a TODO).
- Step 6: Commit
git add apps/storybook package.json turbo.json
git commit -m "feat(storybook): wire @storybook/test-runner for story smoke tests
Every story is now executed as a smoke test (mount + no console errors)
via @storybook/test-runner. New script: pnpm test:stories runs
build-storybook then test-storybook against the static build.
Spec: §6.8"
Task 9: Documentation — tdd-workflow.md + restructure adding-a-feature.md
Files:
-
Create:
docs/guides/tdd-workflow.md -
Modify:
docs/guides/adding-a-feature.md— interleave tests with implementation -
Modify:
docs/guides/testing-strategy.md— cross-link to tdd-workflow -
Modify:
AGENTS.md— link both guides -
Modify:
CLAUDE.md— add TDD subsection under Quick Start -
Step 1: Write
docs/guides/tdd-workflow.md
Sections required (no placeholders):
- Header + intent (~50 words on why TDD here, not as dogma)
- The cycle — Red, Green, Refactor with a fully worked blog example: write failing
getArticleBySlugtest → run → see fail → implement → run → see pass → refactor - Test naming —
describe(SubjectUnderTest)+it("does X when Y")with 3 examples - AAA — Arrange / Act / Assert with two examples (use case + component)
- When to mock — decision tree as a DOT graph block:
Pure function? → no mock Use case? → rebind repository at DI level Repository (Payload)?→ vi.mock('payload') + stubPayloadConfig Component with data? → renderWithProviders with mocks Route handler? → mock at boundary only - Test pyramid for this monorepo — table with ratios (entities ≥ use-cases ≥ controllers ≥ feature integration ≥ component ≥ e2e)
- What NOT to test — bullet list (getters/setters, framework code, third-party libs, types-only modules, generated code)
- Coverage targets — 80/75/80/80 baseline, 100% in entities/use-cases/controllers, how to inspect locally
- Factory usage — when to call
factory.build()vs hand-crafted object; how to add a new factory - Contract suite usage — how to add a new repo impl: write impl → run contract → fix until green; example with code
- Running tests — watch mode, focused tests (
it.only), debugging failures,--coverage,pnpm test:stories,pnpm test:e2e
(Each section short and concrete; no "TODO" or "see elsewhere" without an actual link.)
- Step 2: Restructure
adding-a-feature.md
Replace Part 2 ("Build the Layers") with an interleaved test-first sequence. Each layer becomes red→green:
### Step 1: Write failing test for entity schema
Create `packages/<feature>/src/entities/<entity>.test.ts`:
[concrete failing test code]
Run: `pnpm test --filter @repo/<feature>`
Expected: FAIL — entity does not exist.
### Step 2: Implement entity to pass
[concrete entity code]
Run: `pnpm test --filter @repo/<feature>`
Expected: PASS.
### Step 3: Write factory in src/__factories__/<entity>.factory.ts
[concrete factory code]
### Step 4: Write failing test for use case
[concrete use case test using factory]
### Step 5: Implement use case to pass
[concrete use case code]
### Step 6: Write contract suite in src/__contracts__/<entity>-repository.contract.ts
[concrete contract code]
### Step 7: Implement Mock repo, run contract
[concrete mock code]
### Step 8: Implement Payload repo, run same contract
[concrete payload code with vi.mock setup]
### Step 9: Write failing controller test
[concrete controller test]
### Step 10: Implement controller to pass
[concrete controller code]
### Step 11: Write failing tRPC integration test
[concrete tests/<feature>.feature.test.ts code]
### Step 12: Wire router to pass
[concrete router code]
### Step 13: (UI optional) Write failing component test
### Step 14: Implement component to pass
### Step 15: Wire into core-api / core-cms, run typecheck + lint + boundaries
Add at the top:
> **TDD Order Required:** You may not advance to the next layer until the
> current layer's tests are red, then green. See `docs/guides/tdd-workflow.md`.
- Step 3: Cross-link
Edit docs/guides/testing-strategy.md — add a sentence at the top: 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.
Edit AGENTS.md — under "Specification & Guides", add bullet: **TDD Workflow** — docs/guides/tdd-workflow.md — red-green-refactor cycle, mocking decision tree, coverage targets.
Edit CLAUDE.md — under "Quick Start", add:
## TDD
```bash
pnpm test --watch --filter @repo/<feature> # watch one feature
pnpm test -- --coverage # full run with coverage
pnpm test:stories # Storybook smoke tests
pnpm test:e2e # Playwright e2e
See docs/guides/tdd-workflow.md for the full cycle.
- [ ] **Step 4: Commit**
```bash
git add docs/guides/tdd-workflow.md docs/guides/adding-a-feature.md docs/guides/testing-strategy.md AGENTS.md CLAUDE.md
git commit -m "docs(guides): add TDD workflow + restructure adding-a-feature for TDD order
New: docs/guides/tdd-workflow.md — red-green-refactor cycle, AAA,
mocking decision tree, coverage targets, factory + contract usage.
Restructured: adding-a-feature.md interleaves tests with implementation;
TDD order is required, not optional. testing-strategy.md cross-links
the new guide. AGENTS.md and CLAUDE.md surface both.
Spec: §7"
Task 10: ADR-011 + per-feature AGENTS.md updates
Files:
-
Create:
docs/decisions/adr-011-tdd-foundation.md -
Modify:
AGENTS.md— add@repo/core-testingrow to package map -
Modify: each feature
AGENTS.md— add "Tests" section -
Modify:
packages/core-testing/AGENTS.md(already created in Task 1; verify complete) -
Step 1: Write ADR-011
docs/decisions/adr-011-tdd-foundation.md:
# ADR-011: TDD Foundation
**Status:** Accepted
**Date:** 2026-05-05
**Supersedes:** none
**Spec:** docs/superpowers/specs/2026-05-05-tdd-foundation-design.md
## Context
The vertical-feature monorepo refactor (ADRs 001-010) established
clean architecture with per-feature DI containers but did not enforce
TDD as the path of least resistance. Agentic workers were producing
code-first commits with tests added later, leading to test theatre and
mock/real drift.
## Decision
1. **New package `@repo/core-testing` (tag: tooling)** — shared test
utilities: defineFactory, defineContractSuite, renderWithProviders,
mock-payload helpers, jsdom setup file. Tagged `tooling` so any
package may depend on it as devDependency without boundary violation.
2. **Vitest base configs split into node + jsdom** with safety defaults
(clearMocks, restoreMocks, mockReset, unstubGlobals, sequence.shuffle)
and coverage thresholds (80/75/80/80 baseline; 100% in entities +
use-cases + controllers).
3. **Factories per feature** in `src/__factories__/` replace inline
fixtures. Stable date defaults (2026-01-01) so snapshot diffs reflect
SUT behavior only.
4. **Contract suites per repository interface** in `src/__contracts__/`
run against every implementation (Mock + Payload). Eliminates the
class of bug where the mock and the real impl drift apart.
5. **Tests in core-* packages and apps** — composition smoke tests
(appRouter, payloadConfig, bind-production, providers).
6. **Storybook test-runner** — every story executed as a smoke test.
7. **CI workflow** — typecheck + lint + boundaries + test + build +
e2e + storybook on every PR. Coverage uploaded as artifact.
8. **Two new docs** — tdd-workflow.md (process) + restructured
adding-a-feature.md (interleaves tests with impl).
## Alternatives considered
- **Fixture files instead of factories** — rejected. Fixtures rot with
schema changes and require manual updates per test.
- **One shared test file per impl** — rejected. Contract suites give
the same coverage in fewer LOC and prevent drift.
- **Real Postgres in tests via testcontainers** — rejected for unit
tests (slow, complex). Repository contract suites + vi.mock('payload')
give equivalent confidence in milliseconds.
- **Stryker mutation testing** — deferred. Coverage thresholds + contract
suites get us most of the way; mutation testing is incremental.
## Consequences
- New package to maintain (small, mostly stable surface).
- Coverage thresholds may fail builds initially; we add tests to cross
threshold as part of Plan 7.
- Sequence shuffle may surface latent flakes; we fix as found.
- Templates for new features now require writing tests first; this is
by design.
## Refines
- ADR-006 (boundary tags) — adds @repo/core-testing as a tooling package.
- Step 2: Update root AGENTS.md package map
In AGENTS.md, add row to the Package Map table:
| `@repo/core-testing` | tooling | Shared test utilities (defineFactory, defineContractSuite, renderWithProviders, payload mocks) |
Update the "Five tags" section: tooling (3 packages) — packages/core-eslint, core-typescript, core-testing.
- Step 3: Add Tests section to each feature AGENTS.md
For each packages/<feature>/AGENTS.md, add a section near the bottom:
## Tests
- **Factories:** `src/__factories__/<entity>.factory.ts` — use `factoryName.build({ overrides })` to construct test data with stable defaults.
- **Contract suite:** `src/__contracts__/<entity>-repository.contract.ts` — runs against every repository implementation (mock + payload).
- **Unit tests:** colocated as `*.test.ts` next to the source file.
- **Feature integration:** `tests/<feature>.feature.test.ts` — full slice through tRPC router → controller → use case → mock repo.
```bash
pnpm test --filter @repo/<feature> # all tests for this feature
pnpm test --filter @repo/<feature> -- --watch # watch mode
See docs/guides/tdd-workflow.md for the cycle.
- [ ] **Step 4: Verify core-testing AGENTS.md is complete**
The file was created in Task 1; ensure it documents factory + contract usage.
- [ ] **Step 5: Commit**
```bash
git add docs/decisions/adr-011-tdd-foundation.md AGENTS.md packages/*/AGENTS.md
git commit -m "docs(adr): ADR-011 TDD foundation; update AGENTS.md per-feature
Captures the decision to add @repo/core-testing, factories, contract
suites, vitest safety defaults, coverage thresholds, Storybook
test-runner, and CI as one cohesive TDD foundation. Per-feature
AGENTS.md gains a Tests section pointing to factories, contract suite,
and the canonical test commands.
Spec: §7.4, §7.5"
Task 11: CI workflow
Files:
-
Create:
.github/workflows/ci.yml -
Step 1: Create
.github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
CI: true
jobs:
validate:
name: typecheck + lint + boundaries + test + build
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: cms_test
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
- name: Test with coverage
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms_test
PAYLOAD_SECRET: test-secret-do-not-use-in-prod
run: pnpm test -- --coverage
- run: pnpm build
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: '**/coverage/lcov.info'
retention-days: 7
e2e:
name: Playwright e2e
needs: validate
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: cms_test
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
- name: Run e2e
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms_test
PAYLOAD_SECRET: test-secret-do-not-use-in-prod
run: pnpm test:e2e
storybook:
name: Storybook smoke tests
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 exec playwright install --with-deps chromium
- run: pnpm build-storybook --filter @repo/storybook
- run: pnpm test:stories
- Step 2: Commit
mkdir -p .github/workflows
git add .github/workflows/ci.yml
git commit -m "ci: add GitHub Actions workflow
Runs typecheck + lint + boundaries + test (with coverage) + build
on every push to main and every PR. Postgres service for tests that
need DB. Playwright e2e and Storybook smoke tests gated on validate
job passing. Coverage uploaded as artifact (lcov format) for downstream
tools (Codecov, etc.) — wiring left to template users.
Spec: §6.11"
Task 12: Tighten coverage thresholds where suites are mature
Files:
-
Modify:
packages/{auth,blog,marketing-pages,navigation}/vitest.config.ts— add per-directory thresholds -
Step 1: Run coverage to see current state
pnpm test -- --coverage
Expected: each feature reports coverage. Note any features below 80/75/80/80 baseline.
- Step 2: Add per-directory thresholds for each feature
For each feature where entities/, use-cases/, and controllers/ are at 100%:
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
test: {
coverage: {
thresholds: {
"src/entities/**": { statements: 100, branches: 100, functions: 100, lines: 100 },
"src/application/use-cases/**": { statements: 100, branches: 95, functions: 100, lines: 100 },
"src/interface-adapters/controllers/**": { statements: 100, branches: 95, functions: 100, lines: 100 },
statements: 80, branches: 75, functions: 80, lines: 80,
},
},
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});
If a directory is below threshold, either add tests to cross threshold or document why (e.g., generated types) and exclude.
- Step 3: Run coverage again
pnpm test -- --coverage
Expected: all thresholds met.
- Step 4: Commit
git add packages/*/vitest.config.ts
git commit -m "test: enforce per-directory coverage thresholds
entities + use-cases + controllers must hit 100% (95% branches).
Project-wide baseline remains 80/75/80/80. Tightening these directories
reflects the architectural intent: these are the pure-logic layers and
should be exhaustively tested.
Spec: §6.9"
Final verification
After Task 12 commits:
pnpm install
pnpm typecheck
pnpm lint
pnpm turbo boundaries
pnpm test -- --coverage
pnpm build
pnpm exec playwright install --with-deps chromium
pnpm test:e2e
pnpm build-storybook --filter @repo/storybook
pnpm test:stories
Expected: all green.
Self-review (run after writing the plan, before execution)
- Spec coverage: all 10 gaps mapped to tasks (1→T1+T2+core-testing setup, 2→T1+T5, 3→T9, 4→T1+T3, 5→T1+T4, 6→T2, 7→T9, 8→T8, 9→T7, 10→T11)
- No placeholders — every step has concrete code or commands
- Type consistency —
defineFactory,defineContractSuite,renderWithProviders,mockPayloadModulenamed identically across all tasks - Each task is independently committable
Execution
Per superpowers:subagent-driven-development: dispatch one implementer subagent per task with the task text + scene-setting context, then spec compliance review, then code quality review, then proceed.