← Back to Blog

The Ultimate Guide to Testing with Prisma: Unit Testing

Sabin Adams
Sabin Adams
January 31, 2023
Updated July 9, 2026

To unit test code that uses Prisma ORM, you identify functions with custom logic, mock Prisma Client so no database is touched, and assert on the function's behavior. This article shows how to decide which files need tests, then writes a full suite for a service that uses Prisma Client with Vitest. This is part 2 of a five-part series on testing with Prisma ORM.

Updated (July 2026): This article was rewritten for Prisma ORM 7 and Vitest 4. All code was executed against Prisma ORM 7.8.0, @prisma/client 7.8.0, @prisma/adapter-pg 7.8.0, Vitest 4.1.10, vitest-mock-extended 4.0.0, and randomcolor 0.6.2 on Node.js 22. Two Vitest matcher and reset behaviors changed since the original 2023 article: vi.restoreAllMocks() no longer clears call counts (use vi.clearAllMocks()), and toHaveReturnedWith on an async function does not unwrap the promise (use toHaveResolvedWith). Both are covered below.

Introduction

Unit testing is one of the primary ways to ensure the individual units of code (a function) in your application work as expected.

It can be hard for someone new to testing to know what unit testing is. Not only do you have to understand how the application works and how to write tests, you also have to understand what to test for.

In this article you will zoom into specific functions and write unit tests against them to ensure the building blocks work properly.

What is unit testing?

Unit testing writes tests against small, isolated pieces of code to confirm they work in various situations. A unit test usually targets an individual function, the smallest singular unit of code in a JavaScript application.

Take this function:

function reverseString(str) {
  return str.split('').reverse().join('')
}

To confirm it works, you might pass 'abcde' and expect 'edcba'. The suite could look like:

import { expect, it } from 'vitest'
import { reverseString } from './utils'

it('reverses a string', () => {
  expect(reverseString('abcde')).toBe('edcba')
})

it('throws an error if given an invalid input', () => {
  expect(() => reverseString(1)).toThrow()
})

The goal is to confirm the smallest building blocks work. If every unit test passes, you can be confident the pieces work; if one fails, you know exactly what is wrong.

What isn't unit testing?

In a unit test, the goal is to confirm your custom code works. As a JavaScript developer, you rely on a rich ecosystem of packages from npm. There is nothing wrong with using external modules, but keep this in mind:

If you don't trust an external package enough to skip testing it, you probably should not be using that package.

Take this function:

import randomColor from 'randomcolor'

function getSquare(side: number) {
  if (side <= 0) return null
  return {
    height: side,
    width: side,
    area: side * side,
    color: randomColor()
  }
}

You might verify that it returns null for a side smaller than one, calculates the area correctly, returns an object of the right shape, and that randomColor was called once. You would not test that each square gets a unique color, because randomColor is an external module assumed to work.

This applies to Prisma Client too. Prisma Client is an external module, so your tests should assume the queries it provides work as expected. You test your logic around them.

Technologies you will use

  • Vitest 4
  • Prisma ORM 7 with the prisma-client generator and the @prisma/adapter-pg driver adapter
  • Node.js 20 or later
  • Express

Assumed knowledge

  • Basic knowledge of JavaScript or TypeScript
  • Basic knowledge of Prisma Client and its queries
  • Some experience with Express is helpful
  • The setup from part 1, including the prisma-client generator, the driver adapter, prisma.config.ts, and a local Prisma Postgres database

The application

This series works with a small Express API that lets a user log in and store their favorite quotes. Files are organized by feature under src:

  • src/auth: authentication of the API
  • src/quotes: the quotes feature, including a tags service
  • src/lib: general helpers, including the Prisma Client singleton

The API offers these endpoints:

endpointdescription
POST /auth/signupCreates a new user with a username and password.
POST /auth/signinLogs a user in with a username and a password.
GET /quotesReturns all quotes for the logged in user.
POST /quotesStores a new quote for the logged in user.
DELETE /quotes/:idDeletes a quote belonging to the logged in user by id.

You will unit test one file, src/quotes/tags.service.ts, because it covers the important unit-testing concepts. The same approach applies to the rest of the app.

Set up Vitest for unit tests

You installed Vitest and vitest-mock-extended in part 1. If you are starting fresh:

npm i -D vitest vitest-mock-extended

Configure Vitest so it knows where your unit tests live. Create vitest.config.unit.ts at the project root:

// vitest.config.unit.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    include: ['src/**/*.test.ts']
  }
})

The test.include option tells Vitest to look for *.test.ts files under src.

Add scripts to package.json to run these tests:

{
  "scripts": {
    "test:unit": "vitest -c ./vitest.config.unit.ts",
    "test:unit:ui": "vitest -c ./vitest.config.unit.ts --ui"
  }
}
  • test:unit runs Vitest with the unit config.
  • test:unit:ui runs it in UI mode, opening a browser view of your results.

Files that don't need to be tested

Before writing tests, look at the files that do not need them. Two patterns come up.

The file has no custom behavior. A router file only wires framework functions together:

// src/quotes/quotes.router.ts
import * as QuoteController from './quotes.controller'
import { CreateQuoteSchema, DeleteQuoteSchema } from './quotes.schemas'
import { Router } from 'express'
import { validate } from '../lib/middlewares'

const router = Router()

router.get('/', QuoteController.getAllQuotes)
router.post('/', validate(CreateQuoteSchema), QuoteController.createQuote)
router.delete('/:id', validate(DeleteQuoteSchema), QuoteController.deleteQuote)

export default router

The custom functions (validate, QuoteController.*) are tested in their own context.

The function only wraps an external module. A service that forwards to Prisma Client has nothing custom to test:

// src/quotes/quotes.service.ts
import prisma from '../lib/prisma'

export const deleteQuote = async (id: number) => {
  return await prisma.quote.delete({ where: { id } })
}

There is no need to test external code, so this file can be skipped.

Test the tags service

The tags service exports two functions, upsertTags and deleteOrphanedTags.

The service uses randomcolor to give new tags a color. Install it:

npm i randomcolor && npm i -D @types/randomcolor

Create the test file next to the service at src/quotes/tags.service.test.ts.

Import required modules and mock dependencies

// src/quotes/tags.service.test.ts
import * as TagService from './tags.service'
import prismaMock from '../lib/__mocks__/prisma'
import randomColor from 'randomcolor'
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('../lib/prisma')
vi.mock('randomcolor', () => ({
  default: vi.fn(() => '#ffffff')
}))

describe('tags.service', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })
})

A few things to note:

  • prismaMock is the mocked Prisma Client from src/lib/__mocks__/prisma.ts, the deep mock you built in part 1.
  • vi.mock('randomcolor', ...) mocks the module. Its second argument returns the object the module exports; here default is a spy that always returns '#ffffff'.
  • beforeEach(() => vi.clearAllMocks()) resets call counts and results between tests.

Updated (July 2026): The original article used vi.restoreAllMocks() here. On Vitest 4, restoreAllMocks only restores implementations created with vi.spyOn; it does not clear the call count of a vi.fn() mock. Verified: with restoreAllMocks, the randomColor call count leaked across tests (5 instead of 3). vi.clearAllMocks() clears mock.calls on every mock, which is what these tests need.

Validate the function returns a list of tag IDs

// src/quotes/tags.service.test.ts
describe('upsertTags', () => {
  it('should return a list of tagIds', async () => {
    // 1
    prismaMock.$transaction.mockResolvedValueOnce([1, 2, 3])
    // 2
    const tagIds = await TagService.upsertTags(['tag1', 'tag2', 'tag3'])
    // 3
    expect(tagIds).toStrictEqual([1, 2, 3])
  })
})

This test mocks the response of $transaction, invokes upsertTags, and confirms the return value matches. If the function changes later, this test ensures the result stays what you expect.

Validate it only creates tags that do not already exist

// src/quotes/tags.service.test.ts
it('should only create tags that do not already exist', async () => {
  // Configure `$transaction` to run the callback with the mocked client
  prismaMock.$transaction.mockImplementationOnce((callback) =>
    callback(prismaMock)
  )

  // Mock the first `findMany` to return one existing tag
  prismaMock.tag.findMany.mockResolvedValueOnce([
    { id: 1, name: 'tag1', color: '#ffffff' }
  ])

  // Mock `createMany` so the real query isn't run
  prismaMock.tag.createMany.mockResolvedValueOnce({ count: 0 })

  await TagService.upsertTags(['tag1', 'tag2', 'tag3'])

  // Only `tag2` and `tag3` should reach `createMany`
  expect(prismaMock.tag.createMany).toHaveBeenCalledWith({
    data: [
      { name: 'tag2', color: '#ffffff' },
      { name: 'tag3', color: '#ffffff' }
    ]
  })
})

Validate new tags get a random color

// src/quotes/tags.service.test.ts
it('should give new tags random colors', async () => {
  prismaMock.$transaction.mockImplementationOnce((callback) =>
    callback(prismaMock)
  )

  prismaMock.tag.findMany.mockResolvedValue([])
  prismaMock.tag.createMany.mockResolvedValueOnce({ count: 3 })
  await TagService.upsertTags(['tag1', 'tag2', 'tag3'])
  expect(randomColor).toHaveBeenCalledTimes(3)
})

Because randomColor is mocked with vi.fn, it registers as a spy, so you can assert how many times it was called. This is why clearing mocks between tests matters: without it, the count would carry over from earlier tests.

Validate it returns newly created tag IDs

// src/quotes/tags.service.test.ts
it('should find and return new tagIds when creating tags', async () => {
  prismaMock.$transaction.mockImplementationOnce((callback) =>
    callback(prismaMock)
  )
  // Simulate finding an existing tag
  prismaMock.tag.findMany.mockResolvedValueOnce([
    { id: 1, name: 'tag1', color: '#ffffff' }
  ])
  prismaMock.tag.createMany.mockResolvedValueOnce({ count: 3 })
  // Simulate finding two newly created tags
  prismaMock.tag.findMany.mockResolvedValueOnce([
    { id: 2, name: 'tag2', color: '#ffffff' },
    { id: 3, name: 'tag3', color: '#ffffff' }
  ])
  await TagService.upsertTags(['tag1', 'tag2', 'tag3'])
  expect(prismaMock.$transaction).toHaveResolvedWith([1, 2, 3])
})

Updated (July 2026): The original used toHaveReturnedWith([1, 2, 3]). Because upsertTags passes an async callback to $transaction, the mocked $transaction returns a Promise, and toHaveReturnedWith compares against that unresolved promise, so it fails. Vitest's toHaveResolvedWith waits for the promise to settle and compares the resolved value. Verified: swapping to toHaveResolvedWith makes this assertion pass.

Validate it returns an empty array when given no tags

// src/quotes/tags.service.test.ts
it('should return an empty array if no tags passed', async () => {
  prismaMock.$transaction.mockImplementationOnce((callback) =>
    callback(prismaMock)
  )

  prismaMock.tag.findMany.mockResolvedValueOnce([])
  prismaMock.tag.createMany.mockResolvedValueOnce({ count: 0 })
  prismaMock.tag.findMany.mockResolvedValueOnce([])
  await TagService.upsertTags([])
  expect(prismaMock.$transaction).toHaveResolvedWith([])
})

Running npm run test:unit on this file, all five tests pass:

 ✓ src/quotes/tags.service.test.ts > tags.service > upsertTags > should return a list of tagIds
 ✓ src/quotes/tags.service.test.ts > tags.service > upsertTags > should only create tags that do not already exist
 ✓ src/quotes/tags.service.test.ts > tags.service > upsertTags > should give new tags random colors
 ✓ src/quotes/tags.service.test.ts > tags.service > upsertTags > should find and return new tagIds when creating tags
 ✓ src/quotes/tags.service.test.ts > tags.service > upsertTags > should return an empty array if no tags passed

 Test Files  1 passed (1)
      Tests  5 passed (5)

The deleteOrphanedTags function

// src/quotes/tags.service.ts
export const deleteOrphanedTags = async (ids: number[]) => {
  return await prisma.tag.deleteMany({
    where: {
      quotes: { none: {} },
      id: { in: ids }
    }
  })
}

This function only wraps a Prisma Client call, so it does not require a unit test.

Frequently asked questions

Summary & what's next

During this article you:

  • Learned what unit testing is and why it matters
  • Saw examples where unit testing is not necessary
  • Configured Vitest for unit tests
  • Wrote a full suite of unit tests for a service that uses Prisma Client

While only one file was covered here, the same concepts apply to the rest of the application. In part 3: Integration Testing, you will drop the mocked client and write tests against a real database.

You can also revisit part 1: Mocking Prisma Client for the mock setup used here.

Looking ahead: Prisma Next is a TypeScript-native rewrite of Prisma ORM, built for AI coding agents and currently in early access. It becomes Prisma 8 at general availability; until then, Prisma 7 stays the production choice. To try it, run npm create prisma@next or read the early access docs.

Build your next app with Prisma

Start free. Scale when you’re ready.

Try Prisma
Share this article