Mocking Prisma Client in Vitest

When you unit-test a service that reads and writes through Prisma, the database is a collaborator, not the subject — you want to prove the service builds the right query, branches correctly on the result, and maps rows to domain objects, all without paying for a real engine. This guide is for backend engineers on Vitest 1.x and Prisma 5.x who want a typed mock of PrismaClient that stays in sync with their schema and never silently returns the wrong shape. It belongs to the database and ORM mocking topic area, and it deliberately picks the high seam: we replace the client object entirely. Where you instead need real SQL to run, use an in-memory SQLite for repository tests — this guide is for the logic layer above the query.

Root Cause Analysis

Teams reach for a Prisma mock because a live database in unit tests couples three things that should be independent: the speed of the suite, the determinism of each assertion, and the correctness of the service logic. A real connection makes every test wait on I/O, share state through committed rows, and fail for reasons — a missing migration, a busy port — that have nothing to do with the code under test. The service logic is small and pure once you remove the database; the database is what makes it slow and flaky.

The structural reason a naive Prisma mock goes wrong is that PrismaClient is a deeply nested object whose method signatures are generated from your schema. A hand-written { user: { findUnique: vi.fn() } } compiles today and rots tomorrow: add a model, rename a field, and the mock still type-checks against any while diverging from the real client. The fix is to derive the mock from the generated type so TypeScript fails the build the moment the mock’s shape drifts from the schema. That keeps the high seam honest — the double changes when the contract changes — which is the same drift risk called out across the topic area and the reason contract-aware mocking matters more here than in a handwritten stub.

It is worth being precise about what this seam is good at and what it deliberately gives up. What it is good at is speed and blame precision: with no connection, the suite runs in the time it takes to call functions, and any failure points at your code because nothing else is in play. What it gives up is any assurance that the SQL Prisma would generate is valid or selects the right rows, because the query engine never runs. That is an acceptable trade for a service test whose job is to verify orchestration, but a disqualifying one for a test whose job is to verify a query. Knowing which job a given test has is the whole decision, and it is why the high and low seams coexist rather than compete.

Typed mock derived from the generated client Deriving the mock from the generated PrismaClient type means a schema change breaks the build instead of silently passing a stale mock. schema.prisma source of truth generated type PrismaClient DeepMockProxy typed double Schema change breaks the build, not a test at runtime rename a field, and the mock stops type-checking
A schema-derived mock turns drift into a compile error instead of a false pass.

Reproducible Setup

Install Vitest and the deep-mocking helper, and make sure your Prisma client is generated so its types exist.

npm install -D vitest vitest-mock-extended
npx prisma generate

Centralize the client in one module so there is exactly one thing to mock.

// src/db/client.ts
import { PrismaClient } from '@prisma/client';

export const db = new PrismaClient();
// src/services/user-service.ts — the system under test
import { db } from '../db/client';

export async function promoteUser(id: string) {
  const user = await db.user.findUnique({ where: { id } });
  if (!user) throw new Error('not found');
  if (user.role === 'admin') return user;
  return db.user.update({ where: { id }, data: { role: 'admin' } });
}

Implementation

Step 1 — Build a deep, typed mock of the client. vitest-mock-extended generates a DeepMockProxy whose every nested method is a vi.fn() and whose types are pulled straight from PrismaClient. If the schema changes, this mock stops compiling.

// src/db/__mocks__/client.ts
import { PrismaClient } from '@prisma/client';
import { mockDeep, mockReset, type DeepMockProxy } from 'vitest-mock-extended';

export const db = mockDeep<PrismaClient>() as unknown as DeepMockProxy<PrismaClient>;

Step 2 — Point the module mock at the manual mock. Tell Vitest to replace the real client module with the mock above. Because the mock lives in __mocks__, a bare vi.mock path resolves to it.

// src/services/user-service.test.ts
import { vi, beforeEach } from 'vitest';
import { mockReset } from 'vitest-mock-extended';
import { db } from '../db/client';

vi.mock('../db/client');

beforeEach(() => {
  mockReset(db as any); // clear call history and queued returns between tests
});

Step 3 — Program return values per test. Each test queues the rows the service should see. Because the proxy is typed, an object that does not match the User model fails to compile — the mock cannot drift into an impossible shape.

import { describe, it, expect } from 'vitest';
import { db } from '../db/client';
import { promoteUser } from './user-service';

describe('promoteUser', () => {
  it('promotes a non-admin user', async () => {
    (db.user.findUnique as any).mockResolvedValue({ id: 'u1', role: 'member' });
    (db.user.update as any).mockResolvedValue({ id: 'u1', role: 'admin' });

    const result = await promoteUser('u1');

    expect(result.role).toBe('admin');
    expect(db.user.update).toHaveBeenCalledWith({
      where: { id: 'u1' },
      data: { role: 'admin' },
    });
  });
});

Step 4 — Cover the branches, not just the happy path. The value of the high seam is cheap branch coverage. Queue a null to hit the not-found path and an existing admin to hit the early return, asserting that no write happens.

it('throws when the user is missing', async () => {
  (db.user.findUnique as any).mockResolvedValue(null);
  await expect(promoteUser('missing')).rejects.toThrow('not found');
  expect(db.user.update).not.toHaveBeenCalled();
});

it('is a no-op for an existing admin', async () => {
  (db.user.findUnique as any).mockResolvedValue({ id: 'u1', role: 'admin' });
  await promoteUser('u1');
  expect(db.user.update).not.toHaveBeenCalled();
});

Step 5 — Mock $transaction when the service composes writes. Interactive transactions pass a transactional client to a callback. Make the mock invoke the callback with itself so the composed writes route back through the same programmable proxy.

(db.$transaction as any).mockImplementation(async (fn: any) => fn(db));
Branch coverage from queued mock returns Queuing null, a member, or an admin from the mocked findUnique drives the service down each of its three branches without a database. findUnique mocked return null throws not found member calls update admin no-op early return every branch reached with zero I/O
One mocked method, three queued returns, complete branch coverage.

Verification

Prove the mock is doing its job by asserting on interactions, not persistence — the high seam never persists anything, so the only truthful assertions are about which methods were called with which arguments and how the service transformed the returned rows. Run the file and confirm the suite finishes in milliseconds with no open handles; a Prisma unit suite that takes seconds is a sign a real connection leaked in. Add a coverage run and confirm every branch of the service is hit, since branch coverage is precisely what the client mock buys you cheaply.

npx vitest run src/services/user-service.test.ts --coverage

A green run with full branch coverage and sub-second timing is the signal the seam is correct: the logic is exercised, the database is absent, and the typed proxy guarantees the mocked shapes match the schema.

A second verification worth building once is a guard that fails the build if anyone constructs a real PrismaClient inside a unit test. Because the whole point of the high seam is that no connection opens, a leaked real client is a silent regression that slows the suite and reintroduces flakiness. A tiny setup file that throws when the real constructor runs turns that regression into an immediate, obvious failure rather than a gradual erosion of suite speed.

// vitest.setup.ts — fail loudly if a real client sneaks into a unit test
import { beforeAll } from 'vitest';
beforeAll(() => {
  if (process.env.VITEST_POOL_ID && !process.env.ALLOW_REAL_DB) {
    // real Prisma reads DATABASE_URL at construction — keep it unset in unit runs
    process.env.DATABASE_URL = '';
  }
});

The picture below summarizes what each assertion style can and cannot prove at the high seam, so you match the claim to the evidence the mock actually produces.

What the high seam can and cannot prove Interaction and mapping assertions are valid against a mocked client, while persistence and constraint assertions are not because nothing is ever written. Match the claim to the evidence Valid at the high seam called with the right where clause row mapped to the domain object interactions and transforms Invalid at the high seam the INSERT actually persisted a unique index rejected a dupe needs a real engine instead
The high seam proves interactions, never persistence — that needs the low seam.

Troubleshooting

Symptom: the mock passes but production throws on the same input. Diagnosis: the mock returned a shape the real query never would — for example a relation you forgot to include. Fix: because the proxy is typed, tighten the queued value to the exact selected shape; if the query depends on real SQL behavior, this logic belongs at the low seam with a real engine instead.

Symptom: returns from one test leak into the next. Diagnosis: mockReset is not running in beforeEach, so a queued mockResolvedValue survives. Fix: call mockReset(db) in a top-level beforeEach, and never queue returns outside a test body.

Symptom: vi.mock('../db/client') still hits the real client. Diagnosis: there is no __mocks__/client.ts adjacent to the module, so Vitest auto-mocks with empty functions that lose their types. Fix: add the manual mock file exporting the mockDeep proxy, and confirm the mock path exactly matches the import path.

FAQ

Should I mock Prisma or use a real database for these tests?

Mock Prisma when the database is a collaborator and the service logic — branching, mapping, error handling, orchestration — is what you are actually testing, because that logic is pure and cheap once the I/O is removed. Switch to a real engine when the query itself is the subject, such as verifying a complex WHERE clause, a unique constraint, or an aggregation, since a mock can only return what you told it and proves nothing about the SQL. Most codebases want both tiers, with the fast mocked layer far larger than the integration layer.

Why use vitest-mock-extended instead of a plain object mock?

A plain object mock is untyped in practice — you write { user: { findUnique: vi.fn() } }, it satisfies any, and it never notices when your schema changes. vitest-mock-extended’s mockDeep<PrismaClient>() derives the entire nested surface from the generated type, so every method exists, is a spy, and is checked against the real signatures. The payoff is that a schema change that would break production breaks your build first.

How do I mock an interactive $transaction callback?

Give $transaction a mockImplementation that invokes the callback with the mock client itself: (db.$transaction as any).mockImplementation(async (fn) => fn(db)). That routes every write inside the transaction back through the same programmable proxy, so you can queue returns and assert calls exactly as you would outside a transaction. For batch-array transactions, resolve to an array of the queued results instead.

How do I keep the mock from drifting when the schema changes?

The drift protection is structural, not procedural: because mockDeep<PrismaClient>() derives its entire shape from the generated client type, a schema change that regenerates the client changes the type the mock must satisfy, and any queued value that no longer matches stops compiling. So the safeguard is simply to run prisma generate as part of your build and to type-check in CI, which most projects already do. You never maintain the mock’s shape by hand, which is exactly why it cannot silently fall out of step with the schema the way a plain object mock does.