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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 13:37:35 +02:00
parent 1ab3a3274f
commit 4ca083690f
25 changed files with 946 additions and 10 deletions

View File

@@ -22,6 +22,7 @@ export default [
{ type: "app", pattern: "apps/*" },
{ type: "tooling", pattern: "packages/core-eslint" },
{ type: "tooling", pattern: "packages/core-typescript" },
{ type: "tooling", pattern: "packages/core-testing" },
{ type: "core-composition", pattern: "packages/core-api" },
{ type: "core-composition", pattern: "packages/core-cms" },
{ type: "core", pattern: "packages/core-*" },

View File

@@ -0,0 +1,28 @@
# @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".

View File

@@ -0,0 +1,11 @@
import baseConfig from "@repo/core-eslint/base";
export default [
...baseConfig,
{
rules: {
// test utilities are intended to be used in test contexts
"no-console": "off",
},
},
];

View File

@@ -0,0 +1,49 @@
{
"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/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": {
"@trpc/server": "^11.0.0",
"payload": "^3.0.0"
},
"peerDependenciesMeta": {
"@trpc/server": { "optional": true },
"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"
}
}

View File

@@ -0,0 +1,27 @@
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());
});

View File

@@ -0,0 +1,22 @@
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 });
});
},
};
}

View File

@@ -0,0 +1 @@
export { defineContractSuite, type ContractContext, type ContractSuite } from "./define-contract-suite.js";

View File

@@ -0,0 +1,58 @@
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");
});
});

View File

@@ -0,0 +1,28 @@
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;
},
};
}

View File

@@ -0,0 +1 @@
export { defineFactory, type Factory, type FactoryContext } from "./define-factory.js";

View File

@@ -0,0 +1,2 @@
export * from "./factory/index.js";
export * from "./contract/index.js";

View File

@@ -0,0 +1,2 @@
export { stubPayloadConfig } from "./stub-config.js";
export { mockPayloadModule } from "./mock-payload-module.js";

View File

@@ -0,0 +1,10 @@
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),
}));
}

View File

@@ -0,0 +1,6 @@
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;

View File

@@ -0,0 +1,2 @@
export { renderWithProviders, type RenderOptions } from "./render-with-providers.js";
export { createMockTrpcClient } from "./mock-trpc.js";

View File

@@ -0,0 +1,29 @@
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 });
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const link = httpBatchLink<any>({
url: "http://mock/api/trpc",
transformer: superjson,
fetch: fetchStub,
});
return createTRPCClient<TRouter>({ links: [link] });
}

View File

@@ -0,0 +1,15 @@
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();
});
});

View File

@@ -0,0 +1,29 @@
import type { PropsWithChildren, ReactElement } from "react";
import { render } from "@testing-library/react";
import 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>
);
const result = render(ui, { wrapper: Wrapper });
return Object.assign(result, { queryClient });
}

View File

@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});

View File

@@ -0,0 +1,3 @@
// Reserved for future global node-env setup. Currently a no-op so that
// vitest configs may reference @repo/core-testing/setup/node uniformly.
export {};

View File

@@ -0,0 +1,12 @@
{
"extends": "@repo/core-typescript/react-library.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "vitest.config.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://turborepo.dev/schema.json",
"extends": ["//"],
"tags": ["tooling"]
}

View File

@@ -0,0 +1,16 @@
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") },
},
});

583
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,14 @@
"@repo/marketing-pages/api": ["packages/marketing-pages/src/integrations/api/router.ts"],
"@repo/navigation": ["packages/navigation/src/index.ts"],
"@repo/navigation/cms": ["packages/navigation/src/integrations/cms/index.ts"],
"@repo/navigation/api": ["packages/navigation/src/integrations/api/router.ts"]
"@repo/navigation/api": ["packages/navigation/src/integrations/api/router.ts"],
"@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"]
}
}
}