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/contract` — `defineContractSuite<T>(name, suite)` for cross-impl contract tests
|
||||
- `@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/setup/jsdom` — vitest setupFile (jest-dom + cleanup)
|
||||
- `@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
|
||||
|
||||
See `docs/guides/tdd-workflow.md` §"Contract suite usage".
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -55,4 +55,26 @@ describe("defineFactory", () => {
|
||||
userFactory.reset();
|
||||
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;
|
||||
}
|
||||
|
||||
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>(
|
||||
builder: (ctx: FactoryContext) => T,
|
||||
): Factory<T> {
|
||||
@@ -16,7 +37,7 @@ export function defineFactory<T extends object>(
|
||||
build(overrides) {
|
||||
sequence += 1;
|
||||
const base = builder({ sequence });
|
||||
return { ...base, ...(overrides ?? {}) } as T;
|
||||
return deepMerge(base, overrides ?? {});
|
||||
},
|
||||
buildList(count, 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 });
|
||||
};
|
||||
|
||||
// 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
|
||||
const link = httpBatchLink<any>({
|
||||
url: "http://mock/api/trpc",
|
||||
|
||||
@@ -7,6 +7,12 @@ export interface RenderOptions {
|
||||
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(
|
||||
ui: ReactElement,
|
||||
options: RenderOptions = {},
|
||||
|
||||
@@ -9,6 +9,8 @@ export default defineConfig({
|
||||
setupFiles: ["./src/setup/jsdom.ts"],
|
||||
clearMocks: true,
|
||||
restoreMocks: true,
|
||||
mockReset: true,
|
||||
unstubGlobals: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -443,9 +443,6 @@ importers:
|
||||
'@trpc/server':
|
||||
specifier: ^11.0.0
|
||||
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:
|
||||
specifier: ^3.0.0
|
||||
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)
|
||||
|
||||
Reference in New Issue
Block a user