Mocking Prisma Client in Vitest
mockDeep<PrismaClient>() gives you a typed fake Prisma client for unit tests. Every model and every query method exists as soon as you read it, for example prisma.user.findMany. Each method is a spy, so you set its answer in one call.
import { beforeEach, describe, expect, it } from 'vitest';
import { asInstance, mockDeep, resetAutoSpy } from 'vitest-auto-spy';
import type { PrismaClient } from './generated/client';
import { UserService } from './users';
const prisma = mockDeep<PrismaClient>();
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
resetAutoSpy(prisma);
service = new UserService(asInstance(prisma));
});
it('lists names, falling back to the email', async () => {
prisma.user.findMany.resolveWith([
{ id: 1, email: 'ada@example.test', name: 'Ada' },
{ id: 2, email: 'bob@example.test', name: null },
]);
await expect(service.listNames()).resolves.toEqual(['Ada', 'bob@example.test']);
expect(prisma.user.findMany).toHaveBeenCalledWith({ orderBy: { id: 'asc' } });
});
it('reports a failed insert', async () => {
prisma.user.create.rejectWith(new Error('Unique constraint failed on the fields: (`email`)'));
await expect(service.register('ada@example.test')).resolves.toBe(-1);
});
});The service under test receives the client through its constructor. This is the one thing the recipe needs from your code: the client must come in from outside (a constructor argument, a NestJS provider, a factory argument), not from a module-level import. Then the spec passes in the mock and needs no vi.mock.
// users.ts
import type { PrismaClient, User } from './generated/client';
export class UserService {
constructor(private readonly db: PrismaClient) {}
async listNames(): Promise<string[]> {
const users = await this.db.user.findMany({ orderBy: { id: 'asc' } });
return users.map((user) => user.name ?? user.email);
}
async findByEmail(email: string): Promise<User | null> {
return this.db.user.findUnique({ where: { email } });
}
async register(email: string): Promise<number> {
try {
const user = await this.db.user.create({ data: { email } });
return user.id;
} catch {
return -1;
}
}
}What each call does:
mockDeep<PrismaClient>()needs no generated mock file and no list of models.prisma.user,prisma.postand every query method appear on first read.resolveWith(rows)makes the query resolve withrows. The rows are typed by what the method returns. A stale fixture (a renamed column, a string where the schema saysInt) is a compile error, not a green test.rejectWith(error)makes the query reject. NomockImplementation, no cast.resetAutoSpy(prisma)clears recorded calls and configured answers on every model and method. One mock serves the whole file and starts clean in each test. If you prefer to create the mock inside a test, declare it withusing(TypeScript 5.2 explicit resource management) and it resets at the end of the block: seeusing.asInstance(prisma)passes the mock where a realPrismaClientis expected. The mock's own type is not directly assignable toPrismaClient.
The examples on this page were checked against a client generated by Prisma 7.10 from this schema:
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
title String
authorId Int
author User @relation(fields: [authorId], references: [id])
}Answer findUnique with a row or null
findUnique resolves with the row, or with null when nothing matches. Answer it the same way:
it('returns null for an unknown email', async () => {
prisma.user.findUnique.resolveWith(null);
await expect(service.findByEmail('nobody@example.test')).resolves.toBeNull();
expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { email: 'nobody@example.test' } });
});
it('returns the user it finds', async () => {
const ada = { id: 1, email: 'ada@example.test', name: 'Ada' };
prisma.user.findUnique.resolveWith(ada);
await expect(service.findByEmail('ada@example.test')).resolves.toEqual(ada);
});Return a different answer on each call
it('answers per call', async () => {
prisma.user.findMany.resolveWithPerCall([{ value: [{ id: 1, email: 'ada@example.test', name: 'Ada' }] }, { value: [] }]);
await expect(service.listNames()).resolves.toEqual(['Ada']);
await expect(service.listNames()).resolves.toEqual([]);
});resolveWithPerCall gives the first call the first entry, the second call the second, and so on. Use it for polling, retries and pagination. Each entry is an object: value is what that call resolves with, and an optional delay (in milliseconds) makes that call resolve later. value is typed the same way as in resolveWith.
Answer based on the arguments
it('only the conflicting email fails', async () => {
prisma.user.create.calledWith({ data: { email: 'taken@example.test' } }).rejectWith(new Error('Unique constraint failed'));
prisma.user.create.resolveWith({ id: 7, email: 'new@example.test', name: null });
await expect(service.register('taken@example.test')).resolves.toBe(-1);
await expect(service.register('new@example.test')).resolves.toBe(7);
});The query rejects only for that exact argument. So the test passes only if the code sent the right query. Why this is stronger than one answer plus a toHaveBeenCalledWith at the end: cause and effect.
Arguments are compared by value; key order does not matter. Asymmetric matchers work at any depth: calledWith({ where: { email: expect.stringContaining('@') } }).
A call whose arguments match no calledWith gets the method's general answer (the plain resolveWith above). Without a plain resolveWith, it returns undefined. To make any other arguments throw, use mustBeCalledWith:
prisma.user.findUnique.mustBeCalledWith({ where: { email: 'ada@example.test' } }).resolveWith(null);
await expect(service.findByEmail('bob@example.test')).rejects.toThrow(); // arguments do not matchMock an interactive $transaction
With the callback form, $transaction passes a transaction client to your callback, and your code runs its queries on it. In a unit test, make the transaction client the same mock. Then the queries land where the spec configured them:
// users.ts
async moveTitle(fromId: number, toId: number): Promise<void> {
await this.db.$transaction(async (tx) => {
const post = await tx.post.delete({ where: { id: fromId } });
await tx.post.create({ data: { title: post.title, authorId: toId } });
});
}it('runs the transaction callback against the same mock', async () => {
prisma.$transaction.mockImplementation((callback) => callback(asInstance(prisma)));
prisma.post.delete.resolveWith({ id: 1, title: 'Hello', authorId: 1 });
await service.moveTitle(1, 2);
expect(prisma.post.create).toHaveBeenCalledWith({ data: { title: 'Hello', authorId: 2 } });
});mockImplementation is typed against the callback form, so callback needs no type annotation.
The array form, $transaction([query, query]), receives promises your code already got from the mock. The mock ignores those promises: the code gets exactly what you pass to resolveWith([...results]), one result per query, in order:
// code under test: const [user, total] = await db.$transaction([db.user.create(...), db.user.count()]);
prisma.$transaction.resolveWith([{ id: 7, email: 'new@example.test', name: null }, 3]);Make an unmocked query fail
By default, a query the spec did not configure returns undefined, and your code fails somewhere later. To fail right at the call, pass fallbackMockImplementation in the second argument:
const prisma = mockDeep<PrismaClient>(
{},
{
fallbackMockImplementation: () => {
throw new Error('query not mocked');
},
},
);Common mistake: mockDeep<PrismaClient>({ fallbackMockImplementation }) does not compile. The first argument holds initial values for the mock's members. (vitest-mock-extended takes this option in the first argument, so code moved from it needs this change.)
How the fallback behaves:
- It answers only members nobody configured.
- After you set
calledWithon a query, the query counts as configured. A call with other arguments follows thecalledWithrules in Answer based on the arguments, not the fallback. - A configured
$transactionstill runs its callback. Queries inside it hit the fallback unless the spec configured them. resetAutoSpy(prisma)clears what the test configured, so the fallback applies again.
vi.spyOn(prisma.user, 'findMany') also works, even before anything read that member. It returns the same spy as prisma.user.findMany, so configuring one configures the other.
Coming from vitest-mock-extended
Prisma's own testing guide uses vitest-mock-extended. A spec has the same structure: a deep mock of PrismaClient, reset before each test. The helpers differ:
vitest-mock-extended | mockDeep from vitest-auto-spy | |
|---|---|---|
| Resolve a query | mockResolvedValue(rows) | resolveWith(rows) |
| Reject a query | mockRejectedValue(error) | rejectWith(error) |
| Throw from a non-async member | mockThrow(error) on Vitest 4.1+, mockImplementation before it | failWith(error), on every runner |
| A value per call | chained mockResolvedValueOnce | resolveWithPerCall([...]) |
| Answer by arguments | calledWith(…).mockResolvedValue(…) | calledWith(…).resolveWith(…), mustBeCalledWith to fail on anything else |
| Reset between tests | mockReset(prisma) | resetAutoSpy(prisma), or using |
| Fail an unmocked query | mockDeep({ fallbackMockImplementation }) | mockDeep({}, { fallbackMockImplementation }) |
| Supported runners | peer vitest >=4.0.0 | peer vitest >=2.1.0; also runs on bun:test and node:test |
If vitest-mock-extended already works for you, switching is mostly renaming. Reasons to switch: one-call helpers for a query's outcome, and one dependency that also covers classes, streams and framework adapters in the rest of your tests. The detailed comparison is in Comparison.
What a mocked client does not test
A mocked client tests how your code uses Prisma: which query it sent and what it did with the answer. It does not test the query itself: a where that matches nothing in a real database, a relation that needs an include, a constraint the schema enforces. Keep a few integration tests against a real database for those, and keep the unit tests fast.
In depth
A mockDeep member that you read by index (mock.items[0]) becomes a real array. That is for array-valued members on the mock. Query results still go through resolveWith([row]): do not try to build a findMany result by indexing the mock.
Related
- Auto-mock by type:
mockDeep,createAutoMockandcreateMock, and what a Proxy-based mock cannot do. - Control helpers: every helper used on this page.
- NestJS: giving the mock to a Nest unit, where
PrismaServiceis the usual injection point.