Some context first: idb-client-generator is a Prisma generator I built that creates a fully client-side ORM backed by IndexedDB. It mirrors Prisma's query API: same models, same operations, same argument shapes. So apps can work offline with an identical developer experience. That means the test suite needs to verify that every query produces identical results across both ORMs.
That's the problem this helper solves:
- Run the same query against Prisma and the generated IDB client, then compare results
- Keep the test code DRY, no boilerplate per model or operation
- Make every test call fully type-safe with editor autocompletion
On the surface, this seems like a super straightforward problem. Since my client heavily reuses Prisma's types, I can just create a helper function that takes in a query... wait, what does it take in? A string? An object? A function? And how do I make it type-safe?
If you go with plain strings, you lose type safety and autocompletion. If you hardcode certain objects, the function becomes much less flexible, since you'd have to add new types whenever you add models or queries. If you go with functions, you might end up with a very complex API that's hard to use and understand.
What the end result looks like
Before diving into how it works, here's what you get. The entire test call looks like this:
await expectQueryToSucceed({
page,
model: "user",
operation: "create",
query: { data: { name: "John Doe" } },
});
That's it. Just write the query you want to test, and the helper function takes care of the rest. It runs the query against both the Prisma client and my custom ORM, compares the results, and gives you clear feedback on whether the test passed or failed. While being completely type-safe.
And I can't stress the type-safety part enough. Here are some screenshots from my editor (VSCode with the Vercel theme) to show you how it works in practice:




Model names, operation names, query fields, field types, everything is validated at compile time. If you try to run a query on a model that doesn't exist or pass the wrong type for a field, TypeScript catches it before you even run the test.
How it works
The type magic
The foundation is two utility types that dynamically extract the valid model names and operations from the Prisma client:
type Model = Exclude<keyof typeof prisma, `$${string}` | symbol>;
type Op = Exclude<Operation, "findRaw" | "aggregateRaw" | `$${string}`>;
Model grabs every key from the Prisma client and filters out internal methods (anything starting with $) and symbols. Op does the same for operations, excluding raw query types. These two types are what power the autocompletion you saw in the screenshots above.
Then there's the QueryParams type that ties it all together:
type QueryParams<M extends Model, F extends Op> = {
page: Page;
model: M;
operation: F;
query?: Prisma.Args<(typeof prisma)[M], F>;
};
The key here is Prisma.Args<(typeof prisma)[M], F>, this dynamically infers the correct query argument type based on the model and operation you pass in. So if you're doing user.create, the query field expects exactly what prisma.user.create() would accept. No manual type mapping needed.
The query runner
The runQuery function does the actual comparison: it runs the query against Prisma, submits the same query through the browser to the custom ORM, and returns both results.
export async function runQuery<M extends Model, F extends Op>(params: QueryParams<M, F>) {
const { page, model, operation, query } = params;
const operationFunction = prisma[model][operation] as (...args: unknown[]) => unknown;
const prismaClientResult = await operationFunction(query);
await submitQuery(page, model, operation, query);
await expect(page.getByRole("button", { name: "Run query" })).not.toBeDisabled();
const idbClientResult = JSON.parse((await page.getByRole("code").last().textContent()) ?? "");
return { idbClientResult, prismaClientResult };
}
And expectQueryToSucceed and expectQueryToFail are thin wrappers that handle the assertion logic, checking equality for success cases and verifying error messages for failure cases.
The full code
Here's the complete helper file for reference:
import type { Prisma } from "$lib/generated/prisma/client";
import { expect, type Page } from "@playwright/test";
import { prisma } from "../src/lib/prisma";
import type { Operation } from "@prisma/client/runtime/client";
type Model = Exclude<keyof typeof prisma, `$${string}` | symbol>;
type Op = Exclude<Operation, "findRaw" | "aggregateRaw" | `$${string}`>;
type QueryParams<M extends Model, F extends Op> = {
page: Page;
model: M;
operation: F;
query?: Prisma.Args<(typeof prisma)[M], F>;
};
async function submitQuery(page: Page, model: string, operation: string, query: unknown) {
await page.getByTestId("query-input").fill(`${model}.${operation}(${JSON.stringify(query)})`);
await page.getByRole("button", { name: "Run query" }).click();
}
export async function runQuery<M extends Model, F extends Op>(params: QueryParams<M, F>) {
const { page, model, operation, query } = params;
const operationFunction = prisma[model][operation] as (...args: unknown[]) => unknown;
const prismaClientResult = await operationFunction(query);
await submitQuery(page, model, operation, query);
await expect(page.getByRole("button", { name: "Run query" })).not.toBeDisabled();
const textContent = await page.getByRole("code").last().textContent();
expect(textContent, "Expected query result element to have text content").not.toBeNull();
const idbClientResult = JSON.parse(textContent!);
return { idbClientResult, prismaClientResult };
}
export async function expectQueryToSucceed<M extends Model, F extends Op>(params: QueryParams<M, F>) {
const { idbClientResult, prismaClientResult } = await runQuery(params);
expect(idbClientResult).toEqual(JSON.parse(JSON.stringify(prismaClientResult)));
return prismaClientResult;
}
export async function expectQueryToFail<M extends Model, F extends Op>(
params: QueryParams<M, F> & { errorMessage: string; expectPrismaToAlsoFail?: boolean }
) {
const { page, model, operation, query, errorMessage, expectPrismaToAlsoFail = true } = params;
const operationFunction = prisma[model][operation] as (...args: unknown[]) => unknown;
if (expectPrismaToAlsoFail) {
await expect(operationFunction(query)).rejects.toThrowError();
}
await submitQuery(page, model, operation, query);
await expect(page.getByRole("listitem").first()).toContainText(errorMessage);
}
Here's the file link on GitHub if you want to explore it in its original context: queryRunnerHelper.ts.
I came back to this function months later while refactoring the codebase, and it still worked perfectly, no changes needed despite the project evolving significantly around it. It also makes AI-assisted test writing trivially easy, since TypeScript catches every invalid query at compile time.
Fifty lines, zero maintenance, with full type safety across every model and operation. Generics done right just pay for themselves.