fix(core-testing): address code review feedback for Task 1
- Remove unused @trpc/tanstack-react-query dependency - Document renderWithProviders tRPC provider omission (boundary constraint) - Implement deep merge in defineFactory (preserves nested sibling keys) - Document httpBatchLink<any> rationale in mock-trpc.ts - Align core-testing's own vitest.config with safety defaults (mockReset, unstubGlobals) - Add createMockTrpcClient usage example to AGENTS.md Reviewer: superpowers:code-reviewer (Task 1 of Plan 7). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ Shared testing utilities. Tag: `tooling`. May be depended on by any package as a
|
|||||||
- `@repo/core-testing/factory` — `defineFactory<T>(builder)` for test data factories
|
- `@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/contract` — `defineContractSuite<T>(name, suite)` for cross-impl contract tests
|
||||||
- `@repo/core-testing/react` — `renderWithProviders`, `createMockTrpcClient`
|
- `@repo/core-testing/react` — `renderWithProviders`, `createMockTrpcClient`
|
||||||
|
- `renderWithProviders` does NOT include a tRPC provider. Consumers needing tRPC should wire their own TRPCProvider (from their app's tRPC client setup) and use `createMockTrpcClient` as the client. This constraint exists because tooling packages cannot import `AppRouter` from `@repo/core-api`.
|
||||||
- `@repo/core-testing/payload` — `stubPayloadConfig`, `mockPayloadModule`
|
- `@repo/core-testing/payload` — `stubPayloadConfig`, `mockPayloadModule`
|
||||||
- `@repo/core-testing/setup/jsdom` — vitest setupFile (jest-dom + cleanup)
|
- `@repo/core-testing/setup/jsdom` — vitest setupFile (jest-dom + cleanup)
|
||||||
- `@repo/core-testing/setup/node` — vitest setupFile (no-op placeholder)
|
- `@repo/core-testing/setup/node` — vitest setupFile (no-op placeholder)
|
||||||
@@ -23,6 +24,22 @@ export const articleFactory = defineFactory<Article>(({ sequence }) => ({
|
|||||||
}));
|
}));
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Using createMockTrpcClient
|
||||||
|
|
||||||
|
For component tests that consume tRPC procedures, mock the responses by procedure path (dot-separated):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createMockTrpcClient } from "@repo/core-testing/react";
|
||||||
|
import type { AppRouter } from "@repo/core-api"; // import in your app/feature, not in core-testing
|
||||||
|
|
||||||
|
const trpcClient = createMockTrpcClient<AppRouter>({
|
||||||
|
"blog.articleBySlug": { id: "1", title: "Hello", slug: "hello" },
|
||||||
|
"blog.listArticles": [],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Combine with your app's TRPCProvider for components that need a tRPC client in the render tree.
|
||||||
|
|
||||||
## Adding a contract suite
|
## Adding a contract suite
|
||||||
|
|
||||||
See `docs/guides/tdd-workflow.md` §"Contract suite usage".
|
See `docs/guides/tdd-workflow.md` §"Contract suite usage".
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
"@testing-library/user-event": "^14.5.0",
|
"@testing-library/user-event": "^14.5.0",
|
||||||
"@trpc/client": "^11.0.0",
|
"@trpc/client": "^11.0.0",
|
||||||
"@trpc/tanstack-react-query": "^11.0.0",
|
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -55,4 +55,26 @@ describe("defineFactory", () => {
|
|||||||
userFactory.reset();
|
userFactory.reset();
|
||||||
expect(userFactory.build().id).toBe("user-1");
|
expect(userFactory.build().id).toBe("user-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("deep-merges nested object overrides without losing sibling keys", () => {
|
||||||
|
const factory = defineFactory<{ id: string; meta: { source: string; tags: string[] } }>(({ sequence }) => ({
|
||||||
|
id: `id-${sequence}`,
|
||||||
|
meta: { source: "default", tags: ["a", "b"] },
|
||||||
|
}));
|
||||||
|
const result = factory.build({ meta: { source: "custom" } as never });
|
||||||
|
expect(result.meta.source).toBe("custom");
|
||||||
|
expect(result.meta.tags).toEqual(["a", "b"]); // sibling key preserved
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces array overrides atomically (does not concat)", () => {
|
||||||
|
const factory = defineFactory<{ tags: string[] }>(() => ({ tags: ["a", "b"] }));
|
||||||
|
const result = factory.build({ tags: ["c"] });
|
||||||
|
expect(result.tags).toEqual(["c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces Date overrides atomically", () => {
|
||||||
|
const factory = defineFactory<{ when: Date }>(() => ({ when: new Date("2026-01-01") }));
|
||||||
|
const result = factory.build({ when: new Date("2030-12-31") });
|
||||||
|
expect(result.when.getFullYear()).toBe(2030);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,27 @@ export interface Factory<T> {
|
|||||||
reset(): void;
|
reset(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||||
|
return typeof v === "object" && v !== null && Object.getPrototypeOf(v) === Object.prototype;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepMerge<T>(base: T, overrides: Partial<T>): T {
|
||||||
|
if (!isPlainObject(base) || !isPlainObject(overrides)) {
|
||||||
|
return (overrides ?? base) as T;
|
||||||
|
}
|
||||||
|
const result: Record<string, unknown> = { ...base };
|
||||||
|
for (const key of Object.keys(overrides)) {
|
||||||
|
const baseVal = (base as Record<string, unknown>)[key];
|
||||||
|
const overrideVal = (overrides as Record<string, unknown>)[key];
|
||||||
|
if (isPlainObject(baseVal) && isPlainObject(overrideVal)) {
|
||||||
|
result[key] = deepMerge(baseVal, overrideVal);
|
||||||
|
} else {
|
||||||
|
result[key] = overrideVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
export function defineFactory<T extends object>(
|
export function defineFactory<T extends object>(
|
||||||
builder: (ctx: FactoryContext) => T,
|
builder: (ctx: FactoryContext) => T,
|
||||||
): Factory<T> {
|
): Factory<T> {
|
||||||
@@ -16,7 +37,7 @@ export function defineFactory<T extends object>(
|
|||||||
build(overrides) {
|
build(overrides) {
|
||||||
sequence += 1;
|
sequence += 1;
|
||||||
const base = builder({ sequence });
|
const base = builder({ sequence });
|
||||||
return { ...base, ...(overrides ?? {}) } as T;
|
return deepMerge(base, overrides ?? {});
|
||||||
},
|
},
|
||||||
buildList(count, overrides) {
|
buildList(count, overrides) {
|
||||||
return Array.from({ length: count }, () => this.build(overrides));
|
return Array.from({ length: count }, () => this.build(overrides));
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export function createMockTrpcClient<TRouter extends AnyTRPCRouter>(
|
|||||||
return new Response(JSON.stringify([{ result: { data: superjson.serialize(result) } }]), { status: 200 });
|
return new Response(JSON.stringify([{ result: { data: superjson.serialize(result) } }]), { status: 200 });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// httpBatchLink<TRouter> requires a conditional TransformerOptions<TRouter> that
|
||||||
|
// depends on whether the router uses a transformer; making this generic-friendly
|
||||||
|
// without parameterizing twice requires <any> here.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const link = httpBatchLink<any>({
|
const link = httpBatchLink<any>({
|
||||||
url: "http://mock/api/trpc",
|
url: "http://mock/api/trpc",
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ export interface RenderOptions {
|
|||||||
queryClient?: QueryClient;
|
queryClient?: QueryClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wraps the given UI with QueryClientProvider only.
|
||||||
|
// This helper intentionally omits a TRPCProvider. Adding one would require importing
|
||||||
|
// AppRouter from @repo/core-api, which violates the tooling→core-composition boundary rule.
|
||||||
|
// For components that need tRPC in the render tree, the consumer must:
|
||||||
|
// 1. Wire their own TRPCProvider (from their app's tRPC client setup).
|
||||||
|
// 2. Pass a client built with createMockTrpcClient as the tRPC client.
|
||||||
export function renderWithProviders(
|
export function renderWithProviders(
|
||||||
ui: ReactElement,
|
ui: ReactElement,
|
||||||
options: RenderOptions = {},
|
options: RenderOptions = {},
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export default defineConfig({
|
|||||||
setupFiles: ["./src/setup/jsdom.ts"],
|
setupFiles: ["./src/setup/jsdom.ts"],
|
||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
restoreMocks: true,
|
restoreMocks: true,
|
||||||
|
mockReset: true,
|
||||||
|
unstubGlobals: true,
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: { "@": path.resolve(__dirname, "./src") },
|
alias: { "@": path.resolve(__dirname, "./src") },
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -443,9 +443,6 @@ importers:
|
|||||||
'@trpc/server':
|
'@trpc/server':
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.16.0(typescript@5.9.3)
|
version: 11.16.0(typescript@5.9.3)
|
||||||
'@trpc/tanstack-react-query':
|
|
||||||
specifier: ^11.0.0
|
|
||||||
version: 11.16.0(@tanstack/react-query@5.96.2(react@19.2.4))(@trpc/client@11.16.0(@trpc/server@11.16.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.16.0(typescript@5.9.3))(react@19.2.4)(typescript@5.9.3)
|
|
||||||
payload:
|
payload:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)
|
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)
|
||||||
|
|||||||
Reference in New Issue
Block a user