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:
@@ -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-*" },
|
||||
|
||||
28
packages/core-testing/AGENTS.md
Normal file
28
packages/core-testing/AGENTS.md
Normal 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".
|
||||
11
packages/core-testing/eslint.config.js
Normal file
11
packages/core-testing/eslint.config.js
Normal 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",
|
||||
},
|
||||
},
|
||||
];
|
||||
49
packages/core-testing/package.json
Normal file
49
packages/core-testing/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
22
packages/core-testing/src/contract/define-contract-suite.ts
Normal file
22
packages/core-testing/src/contract/define-contract-suite.ts
Normal 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 });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
1
packages/core-testing/src/contract/index.ts
Normal file
1
packages/core-testing/src/contract/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { defineContractSuite, type ContractContext, type ContractSuite } from "./define-contract-suite.js";
|
||||
58
packages/core-testing/src/factory/define-factory.test.ts
Normal file
58
packages/core-testing/src/factory/define-factory.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
28
packages/core-testing/src/factory/define-factory.ts
Normal file
28
packages/core-testing/src/factory/define-factory.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
1
packages/core-testing/src/factory/index.ts
Normal file
1
packages/core-testing/src/factory/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { defineFactory, type Factory, type FactoryContext } from "./define-factory.js";
|
||||
2
packages/core-testing/src/index.ts
Normal file
2
packages/core-testing/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./factory/index.js";
|
||||
export * from "./contract/index.js";
|
||||
2
packages/core-testing/src/payload/index.ts
Normal file
2
packages/core-testing/src/payload/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { stubPayloadConfig } from "./stub-config.js";
|
||||
export { mockPayloadModule } from "./mock-payload-module.js";
|
||||
10
packages/core-testing/src/payload/mock-payload-module.ts
Normal file
10
packages/core-testing/src/payload/mock-payload-module.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
6
packages/core-testing/src/payload/stub-config.ts
Normal file
6
packages/core-testing/src/payload/stub-config.ts
Normal 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;
|
||||
2
packages/core-testing/src/react/index.ts
Normal file
2
packages/core-testing/src/react/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { renderWithProviders, type RenderOptions } from "./render-with-providers.js";
|
||||
export { createMockTrpcClient } from "./mock-trpc.js";
|
||||
29
packages/core-testing/src/react/mock-trpc.ts
Normal file
29
packages/core-testing/src/react/mock-trpc.ts
Normal 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] });
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
29
packages/core-testing/src/react/render-with-providers.tsx
Normal file
29
packages/core-testing/src/react/render-with-providers.tsx
Normal 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 });
|
||||
}
|
||||
7
packages/core-testing/src/setup/jsdom.ts
Normal file
7
packages/core-testing/src/setup/jsdom.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
3
packages/core-testing/src/setup/node.ts
Normal file
3
packages/core-testing/src/setup/node.ts
Normal 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 {};
|
||||
12
packages/core-testing/tsconfig.json
Normal file
12
packages/core-testing/tsconfig.json
Normal 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"]
|
||||
}
|
||||
5
packages/core-testing/turbo.json
Normal file
5
packages/core-testing/turbo.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://turborepo.dev/schema.json",
|
||||
"extends": ["//"],
|
||||
"tags": ["tooling"]
|
||||
}
|
||||
16
packages/core-testing/vitest.config.ts
Normal file
16
packages/core-testing/vitest.config.ts
Normal 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
583
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user