From 38497c3c0439458c89dbb181fd2f738b7a5fcf79 Mon Sep 17 00:00:00 2001 From: Martin Janse van Rensburg Date: Thu, 9 Jul 2026 21:57:20 +0200 Subject: [PATCH] Blog refresh: Ultimate Guide to Testing series (5 posts, Vitest 4 + Prisma 7.8) Reviewed by Martin on localhost and passed Step 10 adversarial review. Co-Authored-By: Claude Fable 5 --- .../testing-series-1-8eRB5p0Y8o/index.mdx | 734 ++++------ .../testing-series-2-xPhjjmIEsM/index.mdx | 769 +++------- .../testing-series-3-aBUyF8nxAn/index.mdx | 1270 +++------------- .../testing-series-4-OVXtDis201/index.mdx | 1276 +++-------------- .../testing-series-5-xWogenROXm/index.mdx | 630 ++------ 5 files changed, 998 insertions(+), 3681 deletions(-) diff --git a/apps/blog/content/blog/testing-series-1-8eRB5p0Y8o/index.mdx b/apps/blog/content/blog/testing-series-1-8eRB5p0Y8o/index.mdx index 146798dca5..5c3bc7e8c7 100644 --- a/apps/blog/content/blog/testing-series-1-8eRB5p0Y8o/index.mdx +++ b/apps/blog/content/blog/testing-series-1-8eRB5p0Y8o/index.mdx @@ -2,10 +2,11 @@ title: "The Ultimate Guide to Testing with Prisma: Mocking Prisma Client" slug: "testing-series-1-8eRB5p0Y8o" date: "2022-12-22" +updatedAt: "2026-07-09" authors: - "Sabin Adams" -metaTitle: "The Ultimate Guide to Testing with Prisma: Mocking Prisma Client" -metaDescription: "Learn about mocking and spying, why they are useful, and how they can be done with Prisma Client." +metaTitle: "Mock Prisma Client for Unit Tests (Prisma 7 + Vitest)" +metaDescription: "How to mock Prisma Client with Vitest and vitest-mock-extended so you can unit test database code without a real database. Verified on Prisma ORM 7.8." metaImagePath: "/testing-series-1-8eRB5p0Y8o/imgs/meta-4fe84c71772f90f758778c5a9f5e6db96af0efd2-1266x712.png" heroImagePath: "/testing-series-1-8eRB5p0Y8o/imgs/hero-c96c41c8c5f42d2c52751fac51563dbc8f01b52c-844x474.svg" heroImageAlt: "The Ultimate Guide to Testing with Prisma: Mocking Prisma Client" @@ -15,77 +16,44 @@ tags: - "education" --- -As your applications grow, automated testing becomes more and more important. In this article, you will learn how to mock Prisma Client so you can test functions with database interactions without hitting an actual database. - -## Table Of Contents - -- [Table Of Contents](#table-of-contents) -- [Introduction](#introduction) - - [Technologies you will use](#technologies-you-will-use) -- [Prerequisites](#prerequisites) - - [Assumed knowledge](#assumed-knowledge) - - [Development environment](#development-environment) -- [What is a mock?](#what-is-a-mock) -- [Set up a Prisma project](#set-up-a-prisma-project) -- [Set up Vitest](#set-up-vitest) -- [Why mock Prisma Client?](#why-mock-prisma-client) -- [Mock Prisma Client](#mock-prisma-client) -- [Using the mocked client](#using-the-mocked-client) - - [Mocking query responses](#mocking-query-responses) - - [Triggering and capturing errors](#triggering-and-capturing-errors) - - [Mocking transactions](#mocking-transactions) -- [Spy on methods](#spy-on-methods) -- [Why Vitest?](#why-vitest) -- [Summary & What's next](#summary--whats-next) - +To unit test code that uses Prisma ORM, you mock Prisma Client so the function under test runs against a fake client instead of a real database. This article shows how to build that mock with Vitest and the `vitest-mock-extended` library, then use it to control query responses, trigger errors, mock transactions, and spy on methods. This is part 1 of a five-part series on testing with Prisma ORM. +> **Updated (July 2026):** This article was rewritten for Prisma ORM 7 and the current JavaScript testing stack. Every command and code sample here was executed against Prisma ORM 7.8.0, `@prisma/client` 7.8.0, `@prisma/adapter-pg` 7.8.0, Vitest 4.1.10, and `vitest-mock-extended` 4.0.0 on Node.js 22. The project uses the `prisma-client` generator, a driver adapter, a `prisma.config.ts` file for the connection URL, and a local [Prisma Postgres](https://www.prisma.io/docs/postgres) database. The mocking approach itself is unchanged; the setup and a few Vitest matcher names have moved on since the original 2022 article. ## Introduction -Testing is becoming increasingly important in applications as it allows developers to be more confident in the code they write and iterate on their products more efficiently. - -Being able to work confidently and efficiently are, as one might imagine, important aspects of any developer's workflow. So... why doesn't every developer write tests for their applications? The answer to this question often is: Writing tests, especially when a database is involved, can be tricky! +Testing lets developers work confidently and iterate on their products efficiently. So why doesn't everyone write tests? A common answer: writing tests, especially when a database is involved, can be tricky. ![Testing meme](/testing-series-1-8eRB5p0Y8o/imgs/testing_meme.png) *Warning: bad advice 👆🏻* -In this series, you will learn how to perform different types of tests against various applications that interact with a database. - -This article specifically will dive into the topic of _mocking_ and walk through how to mock Prisma Client. Then, you will take a look at what you can do with the mocked client. +In this series, you will learn how to perform different types of tests against applications that interact with a database. This article dives into _mocking_ and walks through how to mock Prisma Client, then looks at what you can do with the mocked client. ### Technologies you will use -- [Vitest](https://vitest.dev/) (learn [why Vitest instead of Jest](#why-vitest)) -- [Prisma](https://www.prisma.io/) -- [Node.js](https://nodejs.org/en/) -- [SQLite](https://sqlite.org/index.html) - -## Prerequisites +- [Vitest](https://vitest.dev/) 4 +- Prisma ORM 7 with the `prisma-client` generator and the `@prisma/adapter-pg` driver adapter +- Prisma Postgres running locally +- [Node.js](https://nodejs.org/en/) 20 or later ### Assumed knowledge -The following would be helpful to have coming in to this series: +The following will help you follow along: - Basic knowledge of JavaScript or TypeScript -- Basic knowledge of Prisma Client and its functionalities - -### Development environment - -To follow along with the examples provided, you will be expected to have: - +- Basic knowledge of Prisma Client and its queries - [Node.js](https://nodejs.org) installed -- A code editor of your choice _(we recommend [VSCode](https://code.visualstudio.com/))_ ## What is a mock? -The first concept you will look at in this series is _mocking_. This term refers to the practice of creating a controlled replacement for an object that acts similarly to the real object it replaces. +The first concept in this series is _mocking_. This term refers to creating a controlled replacement for an object that behaves like the real object it replaces. -The goal of mocking is typically to allow a developer to replace any external dependencies a function may require so they can effectively write unit tests against that function. This way tests can be isolated to the function's behavior without worrying about the behavior of external modules that aren't directly related. +The goal of mocking is to replace any external dependencies a function relies on so you can write unit tests against that function in isolation. Tests then focus on the function's own behavior without depending on external modules. -> **Note**: You will take a closer look at unit tests in the next article of this series. +> **Note**: You will look at unit tests in depth in [part 2](/testing-series-2-xPhjjmIEsM) of this series. -To illustrate this, consider the following function: +Consider the following function: ```ts import { isValidEmail } from './validators' @@ -102,93 +70,145 @@ async function sendEmail(to: string, message: string) { mailer.send({ to, message }) } ``` + This function does three things: -1. Checks to make sure a valid email address is provided -2. Throws an error if an invalid address was provided +1. Checks that a valid email address is provided +2. Throws an error if the address is invalid 3. Sends an email via an imaginary `mailer` service -To write a test to validate this function behaves as expected, you would likely start by testing the scenario where the function is provided an invalid email address and verifying an error is thrown. - -The function, however, relies on two external pieces of code: `isValidEmail` and `mailer`. Because these are separate pieces of code and technically unrelated to the function you are testing, you would not want to have to worry about whether these imports function properly. Instead, these should be assumed to be functional and tested independently. +To test that this function behaves as expected, you would start by passing an invalid email address and verifying an error is thrown. -You also likely would not want an actual email to be sent during your test when `mailer.send()` is called, as that functionality is independent of the function you are testing. +The function relies on two external pieces of code: `isValidEmail` and `mailer`. Because these are separate and unrelated to the function under test, you do not want your test to depend on them, and you certainly do not want a real email sent when `mailer.send()` is called. -In situations like this, it is common practice to _mock_ those dependencies instead, replacing the real imported object with a "fake" that returns a controlled value. In doing so, you gain the ability to trigger specific states in the test's target function without having to consider the behavior of another module. +In situations like this, you _mock_ those dependencies, replacing the real object with a fake that returns a controlled value. This lets you trigger specific states in the target function without considering the behavior of another module. -This is a fairly basic scenario that illustrates how mocking can be useful, however the rest of this article will dive deeper into the different patterns and tools you can use to mock modules and use those mocks to test specific scenarios. +The rest of this article digs into the patterns and tools you can use to mock modules and use those mocks to test specific scenarios. ## Set up a Prisma project -Before jumping into writing tests, you will need a project to experiment with. To set one up you will use [`try-prisma`](https://github.com/prisma/try-prisma), a tool that allows you to quickly set up a sample project that with Prisma. - -Run the following command in a terminal: +Before writing tests, you need a project. Create a new directory, initialize it, and install the dependencies: ```sh -npx try-prisma \ - --template typescript/script \ - --path . \ - --name mocking_playground \ - --install npm +mkdir prisma-testing && cd prisma-testing +npm init -y +npm install -D prisma typescript tsx @types/node vitest vitest-mock-extended +npm install @prisma/client @prisma/adapter-pg dotenv ``` -Once that finishes, a starter project should have been set up in your current working directory in a folder named `mocking_playground`. -You will also see additional output in your terminal with instructions on next steps. Follow those instructions to enter your project and run your first Prisma migration: +The `prisma-client` generator emits modern ES modules, so mark the project as ESM in `package.json`: -```sh -cd mocking_playground -npx prisma migrate dev +```json +{ + "name": "prisma-testing", + "type": "module" +} ``` -A SQLite database has now been generated, your schema applied, and Prisma Client has been generated. You are ready to begin working in your project! -## Set up Vitest +Create a `prisma/schema.prisma` file. On Prisma ORM 7, the generator is `prisma-client` (not the legacy `prisma-client-js`), and it writes the client to an output path you own. The connection URL no longer lives in the `datasource` block; only the provider does: + +```prisma +// prisma/schema.prisma +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" +} + +datasource db { + provider = "postgresql" +} -In order to create tests and mocks, you will need a testing framework. In this series, you will use the increasingly popular [Vitest](https://vitest.dev/) testing framework which provides a set of tooling that allows you to build and run tests, as well as create mocks of modules. +model User { + id Int @id @default(autoincrement()) + email String? @unique + name String? + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + content String? + published Boolean @default(false) + author User @relation(fields: [authorId], references: [id]) + authorId Int +} +``` -> **Note**: Vitest also does a ton of other super cool things! Give their [docs](https://vitest.dev/guide/) a look if you're curious. +The connection URL is supplied through a `prisma.config.ts` file at the project root. Prisma 7 does not load `.env` automatically, so import `dotenv/config` here: + +```ts +// prisma.config.ts +import 'dotenv/config' +import { defineConfig } from 'prisma/config' + +export default defineConfig({ + schema: 'prisma/schema.prisma', + datasource: { + url: process.env.DATABASE_URL! + } +}) +``` -Run this command in your project to install the Vitest framework and its CLI tools: +Now start a local Prisma Postgres database. This command runs a local server (powered by PGlite) and prints a connection string: ```sh -npm i -D vitest +npx prisma dev -n testing +``` + +```text +✔ Your local Prisma Postgres server testing is now running 👍 + +🔌 To connect with Prisma ORM use the following connection strings: + + DATABASE_URL="postgres://postgres:postgres@localhost:51254/template1?sslmode=disable&connection_limit=10&connect_timeout=0&max_idle_connection_lifetime=0&pool_timeout=0&socket_timeout=0" +``` + +Leave that process running. In a second terminal, create a `.env` file with the `DATABASE_URL` value it printed: + +```shell +# .env +DATABASE_URL="postgres://postgres:postgres@localhost:51254/template1?sslmode=disable&connection_limit=10&connect_timeout=0&max_idle_connection_lifetime=0&pool_timeout=0&socket_timeout=0" ``` -Next, create a new folder in the root of your project named `test` where all of your tests will live: + +Push the schema to the database, then generate the client: ```sh -mkdir test +npx prisma db push +npx prisma generate +``` + +```text +🚀 Your database is now in sync with your Prisma schema. Done in 71ms + +✔ Generated Prisma Client (7.8.0) to ./src/generated/prisma in 43ms ``` -> **Note**: It is not required by Vitest to put your tests in a `/test` folder. Vitest will by default detect test files based on these [naming conventions](https://vitest.dev/config/#include). -Finally, in `package.json`, add a new script named `test` that simply runs the command `vitest`: +> **Note**: On Prisma 7, `prisma db push` and `prisma migrate dev` no longer regenerate the client. After any schema change, run `npx prisma generate` yourself. This was verified: adding a model and running `db push` left the generated client unchanged until `generate` was run. + +## Set up Vitest + +You installed [Vitest](https://vitest.dev/) above. Add a `test` script to `package.json`: ```json { - "name": "script", - "license": "MIT", + "name": "prisma-testing", + "type": "module", "scripts": { - "dev": "ts-node ./script.ts", -+ "test": "vitest" - }, - "dependencies": { - "@prisma/client": "4.7.1", - "@types/node": "18.11.11" - }, - "devDependencies": { - "prisma": "4.7.1", - "ts-node": "10.9.1", - "typescript": "4.9.3", - "vitest": "^0.25.5" + "test": "vitest" } } ``` -You can now use `npm run test` to run your tests. You can also run `npm t` for short. Currently, your tests will fail because there are no test files. -Create a new file inside of the `/test` directory named `sample.test.ts`: +Create a `test` folder for your tests: ```sh -touch test/sample.test.ts +mkdir test ``` -Add the following test so that you can verify Vitest is set up correctly: + +> **Note**: Vitest does not require a `test` folder. It detects test files by [naming convention](https://vitest.dev/config/#include). + +Add a `test/sample.test.ts` file to confirm Vitest works: ```ts // test/sample.test.ts @@ -198,60 +218,55 @@ test('1 === 1', () => { expect(1).toBe(1) }) ``` -Now that there is a valid test, running `npm t` should result in a success! Vitest is set up and ready to be put to use. -## Why mock Prisma Client? +Run `npm test`. The test passes, so Vitest is ready. -The best way to illustrate why mocking Prisma Client is useful in unit testing is to write a function that uses Prisma Client and write a test for that function that does not use a mocked client. +## Why mock Prisma Client? -In the root of your project, create a new folder named `libs`. Then create a file within that folder named `prisma.ts`: +The clearest way to show why mocking Prisma Client is useful is to write a function that uses it and test that function without a mock. -```sh -mkdir libs -touch libs/prisma.ts -``` -Add the following snippet to that new file: +Create `src/lib/prisma.ts`. On Prisma 7 you instantiate the client with a driver adapter, and you import the client from the path the generator wrote to (not from `@prisma/client`). Because env is read here, import `dotenv/config`: ```ts -// libs/prisma.ts -import { PrismaClient } from '@prisma/client' +// src/lib/prisma.ts +import 'dotenv/config' +import { PrismaPg } from '@prisma/adapter-pg' +import { PrismaClient } from '../generated/prisma/client' + +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! }) +const prisma = new PrismaClient({ adapter }) -const prisma = new PrismaClient() export default prisma ``` -The code above instantiates Prisma Client and exports it as a singleton instance. This is the "real" Prisma Client instance. -Now that there is a usable instance of Prisma Client available, write a function that makes use of it. - -Replace the contents of `script.ts` with the following: +Now write a function that uses that client. Create `src/script.ts`: ```ts -// script.ts -import { Prisma } from '@prisma/client' -import prisma from './libs/prisma' +// src/script.ts +import { Prisma } from './generated/prisma/client' +import prisma from './lib/prisma' // 1 export const createUser = async (user: Prisma.UserCreateInput) => { // 2 & 3 return await prisma.user.create({ - data: user, + data: user }) } ``` -The `createUser` function does the following: -1. Takes in a `user` argument -2. Passes `user` along to the `prisma.user.create` function -3. Returns the response, which should be the new user object +The `createUser` function: -Next you will write a test for that new function. This test will ensure the `createUser` returns the expected data when provided a valid user: the new user. +1. Takes a `user` argument +2. Passes `user` to `prisma.user.create` +3. Returns the response, which is the new user object -Update `test/sample.test.ts` so that it matches the snippet below: +Write a test for it that checks `createUser` returns the created user. Update `test/sample.test.ts`: ```ts -//test/sample.test.ts +// test/sample.test.ts import { expect, test } from 'vitest' -import { createUser } from '../script' +import { createUser } from '../src/script' test('createUser should return the generated user', async () => { const newUser = { email: 'user@prisma.io', name: 'Prisma Fan' } @@ -259,38 +274,33 @@ test('createUser should return the generated user', async () => { expect(user).toStrictEqual({ ...newUser, id: 1 }) }) ``` -> **Note**: The test above **is not** using a mocked Prisma Client. It is using the real client instance to demonstrate the problem you may run into when testing against a real database. -Assuming your database had not yet contained any users records, this test should pass the first time you run it. There are a few problems though: +> **Note**: This test is **not** using a mocked Prisma Client. It uses the real client to demonstrate the problem. -- The next time you run this test, the `id` of the created user will not be `1`, causing the test to fail. -- The `email` field has a `@unique` attribute in your Prisma schema, signifying that column has a unique index in the database. This will cause an error to occur on subsequent runs of the test. -- This test assumes you are running against a development database and requires a database to be available. Every time you run this test a record will be added to your database. +If your database has no users yet, this test passes the first time. There are a few problems: -In situations such as unit testing which focus on a single function, the best practice is to assume your database operations will behave correctly and use a mocked version of your client or driver instead, allowing you to focus on testing the specific behavior of the function you are targeting. +- The next run creates a user with `id` `2`, so the assertion on `id: 1` fails. +- The `email` field has a `@unique` attribute, so a second run throws a unique constraint error. +- The test requires a running database and writes a record on every run. -> **Note**: There are scenarios where you may want to test against a database and actually perform operations on it. Integration and end-to-end tests are good example of these cases. These tests may rely on multiple database operations occurring across multiple functions and areas of your application. +For unit testing, which focuses on a single function, the best practice is to assume your database operations behave correctly and use a mocked client instead. That lets you focus on the behavior of the function you are targeting. +> **Note**: There are cases where you want to test against a real database, such as integration and end-to-end tests. Those are covered in [part 3](/testing-series-3-aBUyF8nxAn) and [part 4](/testing-series-4-OVXtDis201). ## Mock Prisma Client -For the reasons outlined in the previous section, it is considered best practice to create a mock of your client to properly unit test your functions that use Prisma Client. This mock will replace the imported module that your function would normally use. +To properly unit test functions that use Prisma Client, create a mock of the client. This mock replaces the imported module the function normally uses. -To accomplish this, you will make use of Vitest's mocking tools and an external library named [`vitest-mock-extended`](https://github.com/eratio08/vitest-mock-extended). +You will use Vitest's mocking tools and a library called [`vitest-mock-extended`](https://github.com/eratio08/vitest-mock-extended), which provides a deep-mocking helper. You already installed it above. -First off, install `vitest-mock-extended` in your project: - -```sh -npm i -D vitest-mock-extended -``` -Next, head over to the `test/sample.test.ts` file and make the following changes to let Vitest know it should mock the `libs/prisma.ts` module: +Head to `test/sample.test.ts` and tell Vitest to mock the `src/lib/prisma.ts` module: ```ts // test/sample.test.ts -+import { expect, test, vi } from 'vitest' // 👈🏻 Added the `vi` import -import { createUser } from '../script' +import { expect, test, vi } from 'vitest' // 👈🏻 added the `vi` import +import { createUser } from '../src/script' -+vi.mock('../libs/prisma') +vi.mock('../src/lib/prisma') test('createUser should return the generated user', async () => { const newUser = { email: 'user@prisma.io', name: 'Prisma Fan' } @@ -298,36 +308,27 @@ test('createUser should return the generated user', async () => { expect(user).toStrictEqual({ ...newUser, id: 1 }) }) ``` -The `mock` function available in the `vi` object lets Vitest know it should mock the module found at a provided file path. There are a few different ways the `mock` function can decide how to mock the target module, as described in the [documentation](https://vitest.dev/api/#vi-mock). -Currently, Vitest will attempt to mock the module found at `'../libs/prisma'`, however it will not be able to automatically mock the "deep", or "nested", properties of the `prisma` object. For example, `prisma.user.create()` will not be mocked properly as it is a deeply nested property of the Prisma Client instance. This causes the tests to fail as the function will still be run as it normally against the real database. +The `vi.mock` function tells Vitest to mock the module at the given path. There are several ways it decides how to mock a module, described in the [documentation](https://vitest.dev/api/vi.html#vi-mock). -To solve this problem, you need to let Vitest know how exactly you want that module to be mocked and provide it the value that should be returned when the mocked module is imported, which should include mocked versions of the deeply nested properties. +By default, Vitest cannot automatically mock the deeply nested properties of the `prisma` object. For example, `prisma.user.create()` will not be mocked correctly because it is a nested property. To solve this, provide a manual mock that returns a deep mock of the client. -Create a new folder within the `libs` directory named `__mocks__`: +Create a `__mocks__` folder next to `src/lib/prisma.ts`: ```sh -mkdir libs/__mocks__ +mkdir src/lib/__mocks__ ``` -The folder name `__mocks__` is a common convention in testing frameworks where you may place any _manually created_ mocks of modules. The `__mocks__` folder must be directly adjacent to the module you are mocking, which is why we created the folder next to the `libs/prisma.ts` file. -Within that new folder, create a file named `prisma.ts`: +The `__mocks__` folder is a common testing convention for manually created mocks. It must sit directly adjacent to the module it mocks, which is why it goes next to `src/lib/prisma.ts`. -```sh -touch libs/__mocks__/prisma.ts -``` -Notice this file has the same name as the "real" file, `prisma.ts`. By following this convention, Vitest will know when it mocks the module via `vi.mock` that it should use that file to find the mocked version of the client. - -With that structure in place, you will now create the manual mock. - -In the new `libs/__mocks__/prisma.ts` file, add the following: +Inside it, create a file with the same name as the real module, `prisma.ts`: ```ts -// libs/__mocks__/prisma.ts +// src/lib/__mocks__/prisma.ts // 1 -import { PrismaClient } from '@prisma/client' import { beforeEach } from 'vitest' import { mockDeep, mockReset } from 'vitest-mock-extended' +import type { PrismaClient } from '../../generated/prisma/client' // 2 beforeEach(() => { @@ -338,237 +339,177 @@ beforeEach(() => { const prisma = mockDeep() export default prisma ``` -The snippet above does the following: - -1. Imports all of the tools needed to create the mocked client. -2. Lets Vitest know that between each individual test the mock should be reset to its original state. -3. Creates and exports a "deep mock" of Prisma Client using the `vitest-mock-extended` library's `mockDeep` function which ensures all properties of the object, even deeply nested ones, are mocked. -> **Note**: Essentially, `mockDeep` will set every Prisma Client function's value to the Vitest helper function: [`vi.fn()`](https://vitest.dev/api/#vi-fn). +The snippet above: -At this point, if you run your test with `npm t` again you should see you no longer receiving the same error as before! But there is still a problem... +1. Imports the tools needed to create the mocked client, using a type-only import of `PrismaClient` from the generated path. +2. Resets the mock to its original state between each test. +3. Creates and exports a deep mock of Prisma Client with `mockDeep`, which mocks all properties, including deeply nested ones. -![Failed test](/testing-series-1-8eRB5p0Y8o/imgs/undefined.png) +> **Note**: `mockDeep` sets every Prisma Client function to the Vitest helper [`vi.fn()`](https://vitest.dev/api/vi.html#vi-fn). -*The query returns `undefined`* - -This error actually occurs because the mock has been put in place correctly. Your `prisma.user.create` invocation in `script.ts` is no longer hitting the database. Currently, that function essentially does nothing and returns `undefined`. - -You need to tell Vitest what `prisma.user.create` should do by _mocking_ its behavior. Now that you have a proper mocked version of Prisma Client, this requires a simple change to your test. - -In `test/sample.test.ts` file, add the following to tell Vitest how that function should behave during the course of that individual test: +Because the mock is now in place, `prisma.user.create` no longer hits the database. Right now it returns `undefined`, so the test fails. Tell Vitest what `prisma.user.create` should return by mocking its behavior in the test: ```ts // test/sample.test.ts import { expect, test, vi } from 'vitest' -import { createUser } from '../script' -+import prisma from '../libs/__mocks__/prisma' +import { createUser } from '../src/script' +import prisma from '../src/lib/__mocks__/prisma' // 👈🏻 import the mocked client -vi.mock('../libs/prisma') +vi.mock('../src/lib/prisma') test('createUser should return the generated user', async () => { const newUser = { email: 'user@prisma.io', name: 'Prisma Fan' } -+ prisma.user.create.mockResolvedValue({ ...newUser, id: 1 }) + prisma.user.create.mockResolvedValue({ ...newUser, id: 1 }) // 👈🏻 mock the response const user = await createUser(newUser) expect(user).toStrictEqual({ ...newUser, id: 1 }) }) ``` -Above, the "fake" client was imported as it exports the deep mock of Prisma Client. - -On this object you will notice a new set of functions attached to each Prisma Client property and function: -![Mock Functions](/testing-series-1-8eRB5p0Y8o/imgs/mock-functions.png) - -The one used in the snippet above, `mockResolvedValue`, replaces the normal `prisma.user.create` function with a function that returns the provided value. For the course of that single test, that function will behave as if you performed the following assignment: +`mockResolvedValue` replaces `prisma.user.create` with a function that resolves to the value you provide. For the duration of that test, the call behaves as if you wrote: ```ts -prisma.user.create = async (data: Prisma.UserCreateArgs) => ({ - ...newUser, - id: 1 -}) +prisma.user.create = async () => ({ ...newUser, id: 1 }) ``` -> **Note**: Later in this article you will dive in to some of the helpful functions available to your mocked Prisma Client and how you might use them. - -You can now run functions that use Prisma Client by mocking the client's behaviors beforehand to ensure a desired outcome. This way, rather than worrying about the individual queries, you can focus on the function's actual business logic. - -If you now run your tests again, you should finally see that all of your tests have passed! ✅ -## Using the mocked client - -So you've got a mocked Prisma Client instance and have the ability to manipulate the client to generate the query results you need to test specific scenarios in your functions... what next? +Now you can run functions that use Prisma Client by mocking the client's behavior beforehand. Rather than worrying about individual queries, you focus on the function's business logic. -The remainder of this article will dive into many of the functions your mocked client and Vitest have available and how they might be used in different scenarios to enable your testing experience. +Running the tests now, they pass: -> **Note**: The examples below will not be viable, full-blown unit tests. Rather, they will be functional samples of the tools available via your mocked client. The next article in this series will cover unit testing in-depth. +```text + ✓ test/sample.test.ts (1 test) -### Mocking query responses + Test Files 1 passed (1) + Tests 1 passed (1) +``` -One of the most common things you will use your mocked client for is mocking the responses of queries. You already mocked the response of the `create` method previously in this article, however there are multiple ways to do this that each have their own use-cases. +## Using the mocked client -Take this scenario, for example: +You have a mocked Prisma Client and the ability to control query results. The rest of this article covers the functions your mocked client and Vitest offer. +> **Note**: The examples below are functional samples of the tools available, not full unit tests. Part 2 covers unit testing in depth. +### Mocking query responses -```ts -// test/sample.test.ts -// ... -import { getPosts } from '../script' +One of the most common uses of the mocked client is mocking query responses. You already mocked `create`; there are multiple ways to do this, each with its own use case. -test('getPosts should return an object with published & un-published posts separated', async () => { - const mockPublishedPost = { id: 1, content: 'content', published: true, title: 'title', authorId: 1} - prisma.post.findMany.mockResolvedValue([mockPublishedPost]) +Take this scenario: - const posts = await getPosts() - expect(posts).toStrictEqual({ - published: [mockPublishedPost], - unpublished: [mockPublishedPost] - }) -}) -``` ```ts -// script.ts -// ... +// src/script.ts export const getPosts = async () => { - const published = await prisma.post.findMany({ where: { published: true }}) - const unpublished = await prisma.post.findMany({ where: { published: false }}) + const published = await prisma.post.findMany({ where: { published: true } }) + const unpublished = await prisma.post.findMany({ where: { published: false } }) return { published, unpublished } } ``` - -> **Note**: The usage of [`toStrictEqual`](https://vitest.dev/api/#tostrictequal) here is important. When comparing objects, `toStrictEqual` ensures the objects have the same structure and type. - -Although this test passes successfully, it doesn't make much sense. When `prisma.post.findMany.mockResolvedValue` is invoked, the value provided to that function is used as the response of `prisma.post.findMany` for the remainder of the test. More specifically, until the `mockReset` function is called in `libs/__mocks__/prisma.ts`. - -As a result, the `unpublished` and `published` arrays will contain the exact same values, including the `true` value in the `published` property. - -In order to generate a more realistic response in this scenario, you can make use of another function: [`mockResolvedValueOnce`](https://vitest.dev/api/#mockresolvedvalueonce). This function can be called multiple times to mock the responses of a function and the responses of subsequent invocations. - -In your `getPosts` function, you can use `mockResolvedValueOnce` to mock the first and second responses that function should return. - - +A single `mockResolvedValue` would return the same array for both `findMany` calls, which is unrealistic. Use [`mockResolvedValueOnce`](https://vitest.dev/api/mock.html#mockresolvedvalueonce), which can be chained to mock the first and second responses independently: ```ts // test/sample.test.ts -// ... -import { getPosts } from '../script' +import { getPosts } from '../src/script' -test('getPosts should return an object with published & un-published posts separated', async () => { - const mockPublishedPost = { id: 1, content: 'content', published: true, title: 'title', authorId: 1} +test('getPosts should separate published & un-published posts', async () => { + const mockPublishedPost = { + id: 1, + content: 'content', + published: true, + title: 'title', + authorId: 1 + } prisma.post.findMany .mockResolvedValueOnce([mockPublishedPost]) - .mockResolvedValueOnce([{...mockPublishedPost, published: false}]) - + .mockResolvedValueOnce([{ ...mockPublishedPost, published: false }]) + const posts = await getPosts() expect(posts).toStrictEqual({ published: [mockPublishedPost], - unpublished: [{...mockPublishedPost, published: false}] + unpublished: [{ ...mockPublishedPost, published: false }] }) }) ``` -```ts -// script.ts -// ... -export const getPosts = async () => { - const published = await prisma.post.findMany({ where: { published: true }}) - const unpublished = await prisma.post.findMany({ where: { published: false }}) - - return { published, unpublished } -} -``` - -> **Note**: Many functions available via Vitest have a `mockXValueOnce` method along with `mockXValue`. Refer to the [documentation](https://vitest.dev/api/#api-reference) for more details. +> **Note**: [`toStrictEqual`](https://vitest.dev/api/expect.html#tostrictequal) checks both structure and type when comparing objects. ### Triggering and capturing errors -Another scenario you may want to test for is a case where a query fails and returns or throws an error. A great example of where this may be useful is Prisma Client's [`findUniqueOrThrow`](https://www.prisma.io/docs/orm/reference/prisma-client-reference#finduniqueorthrow) function. - -This function searches for a unique record but throws an error if a record is not found. Because your Prisma Client's functions are mocked, however, the `findUniqueOrThrow` function no longer behaves that way. You must manually trigger the errored state. An example of how you might test for this behavior is shown below: - +You may want to test a case where a query throws. A good example is Prisma Client's [`findUniqueOrThrow`](https://www.prisma.io/docs/orm/reference/prisma-client-reference#finduniqueorthrow), which throws if no record is found. Because the client is mocked, you trigger the errored state yourself: +```ts +// src/script.ts +export const getPostByID = async (id: number) => { + try { + return await prisma.post.findUniqueOrThrow({ where: { id } }) + } catch (e: any) { + return e.message + } +} +``` ```ts // test/sample.test.ts -// ... -import { getPostByID } from '../script' +import { getPostByID } from '../src/script' -test('getPostByID should throw an error when no ID found', async () => { - prisma.post.findUniqueOrThrow.mockImplementation(() => { +test('getPostByID returns the error message when no post is found', async () => { + prisma.post.findUniqueOrThrow.mockImplementation((() => { throw new Error('There was an error.') - }) + }) as any) const response = await getPostByID(200) expect(response).toBe('There was an error.') }) ``` + +[`mockImplementation`](https://vitest.dev/api/mock.html#mockimplementation) replaces the mocked function's behavior. Here it throws an error, giving you fine-grained control over the output in error states. + +If the function under test is meant to throw rather than catch, you can test that too: + ```ts -// script.ts -// ... +// src/script.ts (throwing variant) export const getPostByID = async (id: number) => { - try { - return await prisma.post.findUniqueOrThrow({ where: { id }}) - } catch ( e: any ) { - return e.message - } + return await prisma.post.findUniqueOrThrow({ where: { id } }) } ``` - -The [`mockImplementation`](https://vitest.dev/api/#mockimplementation) allows you to provide a function that replaces the behavior of the mocked function. In the case above, the replacement function simply throws an error. - -While this may seem a bit tedious at first glance, the need to manually define the behavior of a function in this case is actually an added benefit. This allows you to have fine-grain control of what the output of your function will be in different states, even errored ones. - -Along the same lines as above, if the method you are testing is intended to throw an actual error rather than return some message related to the error, you can also test for that! - - - ```ts -// test/sample.test.ts -// ... -import { getPostByID } from '../script' - +// test/errors.test.ts test('getPostByID should throw an error', async () => { - prisma.post.findUniqueOrThrow.mockImplementation(() => { + prisma.post.findUniqueOrThrow.mockImplementation((() => { throw new Error('There was an error.') - }) - + }) as any) + await expect(getPostByID(1)).rejects.toThrow() await expect(getPostByID(1)).rejects.toThrowError('There was an error') }) ``` -```ts -// script.ts -// ... -export const getPostByID = async (id: number) => { - return await prisma.post.findUniqueOrThrow({ where: { id }}) -} -``` - -By using the [`rejects`](https://vitest.dev/api/#rejects) keyword on the response of the `expect` function, Vitest knows to resolve the `Promise` given to `expect` and look for an errored response. Once the `Promise` resolves, the `toThrow` and [`toThrowError`](https://vitest.dev/api/#tothrowerror) functions allow you to check for specific details about the error. +The [`rejects`](https://vitest.dev/api/expect.html#rejects) modifier resolves the promise given to `expect` and checks for a rejection. `toThrow` and `toThrowError` then check details about the error. ### Mocking transactions -Another piece of Prisma Client you may need to mock is a [`$transaction`](https://www.prisma.io/docs/orm/prisma-client/queries/transactions#the-transaction-api). - -There are different types of transactions: [sequential operations](https://www.prisma.io/docs/orm/prisma-client/queries/transactions#sequential-prisma-client-operations) and [interactive transactions](https://www.prisma.io/docs/orm/prisma-client/queries/transactions#interactive-transactions). The way you mock these will depend greatly on the goal of your test and the context in which you are using the `$transaction`. There are, however, two general ways in which you will mock this function. - -For both sequential operations and interactive transactions, the result of the completed transaction is eventually returned from the `$transaction` function. If your test only cares about the result of the transaction, your test will look very similar to the tests above where you mocked a function's response. +Another part of Prisma Client you may need to mock is a [`$transaction`](https://www.prisma.io/docs/orm/prisma-client/queries/transactions#the-transaction-api). There are two forms: [sequential operations](https://www.prisma.io/docs/orm/prisma-client/queries/transactions#sequential-prisma-client-operations) and [interactive transactions](https://www.prisma.io/docs/orm/prisma-client/queries/transactions#interactive-transactions). How you mock them depends on your test's goal. -An example might look something like this: +If your test only cares about the result of a sequential transaction, mock the return value of `$transaction` directly: +```ts +// src/script-tx.ts +export const addPost = async (data: Prisma.PostCreateInput) => { + const [newPost, count] = await prisma.$transaction([ + prisma.post.create({ data }), + prisma.post.count() + ]) + return { newPost, count } +} +``` ```ts -// test/sample.test.ts -// ... -import { addPost } from '../script' - -test('addPost should return an object containing the new post and the total count', async () => { +// test/transactions.test.ts +test('addPost (sequential) returns the new post and the total count', async () => { // 1 const mockPost = { authorId: 1, @@ -578,7 +519,7 @@ test('addPost should return an object containing the new post and the total coun } // 2 - const mockResponse = [ {...mockPost, id: 1 }, 100 ] + const mockResponse = [{ ...mockPost, id: 1 }, 100] prisma.$transaction.mockResolvedValue(mockResponse) // 3 @@ -591,47 +532,39 @@ test('addPost should return an object containing the new post and the total coun }) }) ``` -```ts -// script.ts -// ... -export const addPost = async (data: Prisma.PostCreateInput) => { - const [ newPost, count ] = await prisma.$transaction([ - prisma.post.create({ data }), - prisma.post.count() - ]) - - return { newPost, count } -} -``` - In the test above you: -1. Mocked out the data for the post you intended to create. -2. Mocked what the response from `$transaction` should look like. -3. Invoked the function after the Prisma Client methods had been mocked. -4. Ensured the returned value from your function matched what you would have expected. +1. Mocked the data for the post you intend to create. +2. Mocked what `$transaction` should return. +3. Invoked the function after the mocks were set up. +4. Verified the returned value matched what you expected. -By mocking the response of the `$transaction` function itself, you did not have to worry about what went on within the transaction's sequential actions (or the interactive transaction if that were the case). +By mocking the response of `$transaction` itself, you did not have to consider what happened inside the transaction. -What if you want to test an interactive transaction that has important business logic you need to validate? This method would not work as it completely forgoes the inner workings of the transaction. +To test an interactive transaction with business logic you need to validate, mock the individual calls and the `$transaction` implementation: -To test an interactive transaction with important business logic, you may write a test that looks like the following: +```ts +// src/script-itx.ts +export const addPost = async (data: Prisma.PostCreateInput) => { + return await prisma.$transaction(async (tx) => { + if (!('published' in data)) { + data['published'] = true + } + const newPost = await tx.post.create({ data }) + const count = await tx.post.count() + return { newPost, count } + }) +} +``` ```ts -// test/sample.test.ts -// ... -import { addPost } from '../script' - -test('addPost should return an object containing the new post and the total count', async () => { +// test/transactions.test.ts +test('addPost (interactive) returns the new post and the total count', async () => { // 1 - const mockPost = { - authorId: 1, - title: 'title', - content: 'content' - } + const mockPost = { authorId: 1, title: 'title', content: 'content' } const mockResponse = { newPost: { ...mockPost, id: 1, published: true }, count: 100 @@ -652,55 +585,42 @@ test('addPost should return an object containing the new post and the total coun expect(data).toStrictEqual(mockResponse) }) ``` -```ts -// script.ts -// ... -export const addPost = async (data: Prisma.PostCreateInput) => { - return await prisma.$transaction(async (tx) => { - if (!('published' in data)) { - data['published'] = true - } - - const newPost = await tx.post.create({ data }) - const count = await tx.post.count() - - return { newPost, count } - }) -} -``` +Here you: -This test is a little bit more involved, as there are a lot of different moving pieces to consider. - -Here is what happens: - -1. The post and response objects are mocked. -2. The responses of the `create` and `count` methods are mocked. -3. The `$transaction` function's implementation is mocked so that you can provide the mocked Prisma Client to the interactive transaction function rather than the actual client instance. -4. The `addPost` method is invoked. -5. The values of the response are validated to ensure the business logic within the interactive transaction worked. More specifically, it ensures the new post's `published` flag is set to `true`. - +1. Mock the post and response objects. +2. Mock the responses of `create` and `count`. +3. Mock the `$transaction` implementation so it passes the mocked client to the interactive callback rather than a real client. +4. Invoke `addPost`. +5. Validate the response, confirming the business logic set `published` to `true`. ## Spy on methods -The last concept you will explore is _spying_. Vitest, via a package named [TinySpy](https://github.com/tinylibs/tinyspy), gives you the ability to _spy_ on a function. Spying allows you to observe a function during the course of the code's execution and determine things such as: how many times it was invoked, what parameters were passed to it, the value it returned, and more. - -> **Note**: Spying on a function allows you to observe details about the function as your code is executed without modifying the target function or its behavior. - -You may spy on an un-mocked function using `vi.spyOn()`, however a mocked function with `vi.fn()` has all of the spying functionalities available to it by default. Because Prisma Client has been mocked, every function should be capable of being spied on. +The last concept is _spying_. Vitest lets you spy on a function to observe how many times it was called, what arguments it received, and what it returned, without changing its behavior. -![Spy functions.](/testing-series-1-8eRB5p0Y8o/imgs/spy-functions.png) - -Below is a quick example of what a test might look like using a _spy_: +A mocked function created with `vi.fn()` has all spying features by default. Because Prisma Client is mocked, every function can be spied on: +```ts +// src/script.ts +export const updateUser = async ( + id: number, + data: Prisma.UserUpdateInput, + clearPosts: boolean +) => { + const user = await prisma.user.update({ where: { id }, data }) + if (clearPosts) { + await prisma.post.deleteMany({ where: { authorId: id } }) + } + return user +} +``` ```ts // test/sample.test.ts -// ... -import { updateUser } from '../script' +import { updateUser } from '../src/script' -test('updateUser should delete user posts if clearPosts flag is true', async () => { +test('updateUser should delete user posts if clearPosts is true', async () => { prisma.user.update.mockResolvedValue({ id: 1, email: 'adams@prisma.io', @@ -715,61 +635,43 @@ test('updateUser should delete user posts if clearPosts flag is true', async () }) }) ``` -```ts -// script.ts -// ... -export const updateUser = async ( - id: number, - data: Prisma.UserUpdateInput, - clearPosts: boolean -) => { - const user = await prisma.user.update({ - where: { id }, - data - }) - if (clearPosts) { - await prisma.post.deleteMany({ where: { authorId: id }}) - } - return user -} -``` +Spy functions are useful when you want to confirm certain code paths run based on the inputs. +Running the full set of mock and spy tests, they all pass: -These _spy_ functions are especially useful when you are attempting to ensure certain scenarios are triggered based on various inputs. +```text + Test Files 3 passed (3) + Tests 7 passed (7) +``` ## Why Vitest? -You may be curious about why this article focuses on Vitest as a testing framework rather than a more established and popular framework like [Jest](https://jestjs.io/). - -The reasoning behind this decision has to do with the different tools' compatibility with Node.js, specifically when dealing with `Error` objects. [Matteo Collina](https://twitter.com/matteocollina), a member of the Node.js Technical Steering Committee among other awesome achievements, describes this issue very well on a recent livestream of his. - - - - -The problem in a nutshell is that Jest cannot out of the box determine whether an error is an instance of the `Error` class. - -This may cause various unexpected problems as you write tests for different cases in your application. - -### How are they different? - -Fortunately, for the most part every testing framework is very similar and the concepts transfer fairly seamlessly. For example, if you are accustomed to working with Jest and are considering a move to something like Vitest or `node-tap` (another testing framework), the knowledge you already have will be very transferrable to the new technology. +This series uses Vitest rather than Jest for a few practical reasons. Vitest runs your tests through the same Vite pipeline your app already uses, so ESM and TypeScript work without extra configuration, which matters because the Prisma 7 `prisma-client` generator emits ES modules. Vitest's API mirrors Jest's, so the concepts here transfer if your team uses Jest elsewhere. Jest remains a capable choice; the guidance in this series applies to either with minor changes to configuration and imports. -Very minor adjustments will be required: things like function naming conventions and configuration. - -### Should you ever use Jest? +## Frequently asked questions -Yes! Jest is a fantastic tool written by very capable people. While Vitest may be the "best tool for the job" when testing a backend application in Node.js, Jest is still more than capable for testing frontend JavaScript applications. + + +Create a manual mock in a `__mocks__` folder next to your Prisma Client module. In that file, export a deep mock built with `mockDeep()` from `vitest-mock-extended`, and reset it between tests with `mockReset`. In your test file, call `vi.mock('')` so Vitest swaps the real client for the mock. Because `mockDeep` mocks nested properties, calls like `prisma.user.create()` become controllable spy functions. This was verified on Vitest 4.1.10 and `vitest-mock-extended` 4.0.0. + + +No. Unit tests use a mocked client, so no database connection is opened. You mock each query's return value with helpers like `mockResolvedValue`. You only need a real database for integration and end-to-end tests, which are covered later in this series. + + +No. On Prisma ORM 7, `prisma db push` and `prisma migrate dev` no longer regenerate Prisma Client. After changing your schema, run `npx prisma generate` yourself. This was verified by adding a model, running `db push`, and confirming the generated client stayed unchanged until `generate` was run. + + -## Summary & What's next +## Summary & what's next -In this article, you focused on the concepts of _mocking_ and _spying_, both of which play a major role in unit testing an application. Specifically, you explored: +In this article, you focused on _mocking_ and _spying_, which play a major role in unit testing. Specifically, you explored: - What mocking is and why it is useful -- How to set up a project with Vitest and Prisma configured -- How to mock Prisma Client -- How to use a mocked Prisma Client instance +- How to set up a project with Vitest and Prisma ORM 7 configured +- How to mock Prisma Client with a deep mock +- How to use a mocked Prisma Client instance to control responses, trigger errors, mock transactions, and spy on methods -With this knowledge and context into the world of testing, you now have the tool set required to unit test an application. In the next article of this series you will do exactly that! +With this foundation, you have the tools to unit test an application. In [part 2: Unit Testing](/testing-series-2-xPhjjmIEsM), you will put them to use against a real service. -We hope you'll join along in the next parts of this series as we explore the various ways you can test your applications that use Prisma Client. +Looking ahead: [Prisma Next](https://www.prisma.io/docs/orm) 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](https://pris.ly/pn-ea). diff --git a/apps/blog/content/blog/testing-series-2-xPhjjmIEsM/index.mdx b/apps/blog/content/blog/testing-series-2-xPhjjmIEsM/index.mdx index e06664185f..fa2676b5e8 100644 --- a/apps/blog/content/blog/testing-series-2-xPhjjmIEsM/index.mdx +++ b/apps/blog/content/blog/testing-series-2-xPhjjmIEsM/index.mdx @@ -2,10 +2,11 @@ title: "The Ultimate Guide to Testing with Prisma: Unit Testing" slug: "testing-series-2-xPhjjmIEsM" date: "2023-01-31" +updatedAt: "2026-07-09" authors: - "Sabin Adams" -metaTitle: "The Ultimate Guide to Testing with Prisma: Unit Testing" -metaDescription: "Learn about what unit testing is and how to approach it in an application using Prisma Client." +metaTitle: "Unit Testing Prisma Code with Vitest (Prisma 7)" +metaDescription: "How to unit test functions that use Prisma ORM with Vitest: what to test, what to skip, and how to use a mocked client. Verified on Prisma 7.8 and Vitest 4." metaImagePath: "/testing-series-2-xPhjjmIEsM/imgs/meta-5b092626852ebeccdc7962534c6e45d87869900d-1266x712.png" heroImagePath: "/testing-series-2-xPhjjmIEsM/imgs/hero-c9711528e68bdd2e62b432a8fad0f7e6f4859ede-844x474.svg" heroImageAlt: "The Ultimate Guide to Testing with Prisma: Unit Testing" @@ -15,107 +16,57 @@ tags: - "education" --- -Unit testing involves testing individual, isolated units of code to ensure they work as expected. In this article, you will learn how to identify areas of your codebase that should be unit tested, how to write those tests, and how to handle tests against functions using Prisma Client. - -## Table Of Contents - -- [Table Of Contents](#table-of-contents) -- [Introduction](#introduction) - - [What is unit testing?](#what-is-unit-testing) - - [What isn't unit testing?](#what-isnt-unit-testing) - - [Technologies you will use](#technologies-you-will-use) -- [Prerequisites](#prerequisites) - - [Assumed knowledge](#assumed-knowledge) - - [Development environment](#development-environment) -- [Explore the API](#explore-the-api) -- [Set up Vitest](#set-up-vitest) -- [Files that don't need to be tested](#files-that-dont-need-to-be-tested) -- [What you will test](#what-you-will-test) -- [Test the tags service](#test-the-tags-service) - - [Test the `upsertTags` function](#test-the-upserttags-function) - - [Test the `deleteOrphanedTags` function](#test-the-deleteorphanedtags-function) -- [Summary & What's next](#summary--whats-next) - +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 methods of ensuring the individual units of code (e.g. a _function_) in your applications function as you'd expect them to. - -It can be very difficult for someone new to testing to understand what unit testing is. Not only do they have to understand how their application works, how to write tests, and how to prepare a testing environment, but they also have to understand what they should be testing for! - -For that reason, developers often take this approach to testing: - - - - -> **Note**: Shoutout to [@RoxCodes](https://twitter.com/RoxCodes) for his honesty 😉 +Unit testing is one of the primary ways to ensure the individual units of code (a _function_) in your application work as expected. -In this series, you will be working with a fully functional application. The only thing missing in its codebase is a suite of tests to validate it works as intended. +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. -Over the course of this series you will consider various areas of the code and walk through what should be tested, why it needs to be tested, and how to write those tests. This will include *unit testing*, *integration testing*, *end-to-end testing*, as well as setting up *Continuous Integration*(CI) and *Continuous Development*(CD) workflows that run those tests. - -In this article specifically, you will zoom into specific areas of the code and write unit tests against them to ensure the individual building blocks of those areas are working properly. +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* is a type of testing that involves writing tests against small, isolated pieces of code. Unit tests target small units of code to ensure they work as expected in various situations. - -Typically, a unit test will target an individual `function` as functions are usually the smallest singular units of code in a JavaScript application. +_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 the function below as an example: +Take this function: ```ts function reverseString(str) { return str.split('').reverse().join('') } ``` -This function, while simple, is a good candidate for a unit test. It contains a singular set of functionality wrapped into one function. To ensure this function works properly, you might provide it the string `'abcde'` and make sure the string `'edcba'` is returned. - -The associated _suite_ of tests, or set of tests, could look like the following: - +To confirm it works, you might pass `'abcde'` and expect `'edcba'`. The suite could look like: - ```ts - import { it } from 'vitest' - import { reverseString } from './utils' - it('reverses a string', () => { - const string = reverseString('abcde') - expect(string).toBe('edcba') - }) - ``` - - ```ts - import { it } from 'vitest' - import { reverseString } from './utils' - it('throws an error if given an invalid input', () => { - expect(() => reverseString(1)).toThrow() - }) - ``` - +```ts +import { expect, it } from 'vitest' +import { reverseString } from './utils' +it('reverses a string', () => { + expect(reverseString('abcde')).toBe('edcba') +}) -As you may have noticed above, the goal of a unit test is simply to ensure the smallest _building blocks_ of your application work properly. In doing this, you build the confidence that as you begin to combine those building blocks the resulting behavior is predictable. +it('throws an error if given an invalid input', () => { + expect(() => reverseString(1)).toThrow() +}) +``` -![Test graphic](/testing-series-2-xPhjjmIEsM/imgs/test-graphic.svg) +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. -The reason this is so important is illustrated above. When running your unit tests, if all tests pass you can be sure every building block works and, as a result, your application works as intended. If even one test fails, however, you can assume your application is not working as intended and you will know based on the failed test(s) exactly what is wrong. ### What isn't unit testing? -In a unit test, the goal is to make sure your custom code works as intended. The important thing to note from the previous sentence is the phrase _"custom code"_. - -As a JavaScript developer, you have access to a rich ecosystem of community-built modules and packages via [npm](https://www.npmjs.com/). Using external libraries allows you to save a lot of time where you might otherwise re-invent the wheel. - -While there is nothing wrong with using an external module, there are some considerations to make when thinking about testing functions that use those modules. Most importantly, it is important to keep this in mind: +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](https://www.npmjs.com/). 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. -> If you don't trust an external package and feel you should write tests against it, you probably should not be using that particular package. - - -Take the following function as an example: +Take this function: ```ts -import randomColor from 'randomColor' +import randomColor from 'randomcolor' function getSquare(side: number) { if (side <= 0) return null @@ -127,230 +78,94 @@ function getSquare(side: number) { } } ``` -This function takes in the length of one side of a square and returns an object containing a more defined square, including a unique color for the square. - -You may want to verify the following when writing unit tests for the function above: -- The function returns `null` when a number smaller than one is provided -- The function calculates the area properly -- The function returns an object of the correct shape with the correct values -- The `randomColor` function was invoked once +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. -Notice there is no mention of a test to make sure each square actually gets a unique color. This is because `randomColor` is assumed to work properly as it is an external module. +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. ->**Note**: Whether `randomColor` was provided via an npm package or even a custom-built function in another file, it should be assumed to work correctly in this context. If `randomColor` was a function you wrote in another file, it should be tested in its own isolated context. Think 'building blocks'! - -This concept is important because it also applies to Prisma Client. When using Prisma in your application, Prisma Client is an _external module_. Because of this, any tests should assume the functions provided by your client work as expected. ### Technologies you will use -- [Vitest](https://vitest.dev/) -- [Prisma](https://www.prisma.io/) -- [Node.js](https://nodejs.org/en/) +- [Vitest](https://vitest.dev/) 4 +- Prisma ORM 7 with the `prisma-client` generator and the `@prisma/adapter-pg` driver adapter +- [Node.js](https://nodejs.org/en/) 20 or later - [Express](https://expressjs.com/) -## Prerequisites - ### Assumed knowledge -The following would be helpful to have coming in to this series: - - Basic knowledge of JavaScript or TypeScript -- Basic knowledge of Prisma Client and its functionalities -- Some experience with Express would be nice - -### Development environment - -To follow along with the examples provided, you will be expected to have: - -- [Node.js](https://nodejs.org) installed -- A code editor of your choice _(we recommend [VSCode](https://code.visualstudio.com/))_ -- [Git](https://github.com/git-guides/install-git) installed - -This series will make heavy use of this [GitHub repository](https://github.com/sabinadams/express_sample_app). Make sure to clone the repository and check out the `main` branch. - - - -### Clone the repository - -In your terminal head over to a directory where you store your projects. In that directory run the following command: - -```sh -git clone git@github.com:sabinadams/express_sample_app.git -``` -The command above will clone the project into a folder named `express_sample_app`. The default branch for that repository is `main`, so at this point you should be ready to go! - - - -Once you have cloned the repository, there are a few steps to take to set the project up. - -First, navigate into the project and install the `node_modules`: - -```sh -cd express_sample_app -npm i -``` -Next, create a `.env` file at the root of the project: - -```sh -touch .env -``` -This file should contain a variable named `API_SECRET` whose value you can set to any `string` you want as well as one named `DATABASE_URL` which can be left empty for now: - -```shell -# .env -API_SECRET="supersecretstring" -DATABASE_URL="" -``` -In `.env` the `API_SECRET` variable provides a _secret key_ used by the authentication services to encrypt your passwords. In a real-world application this value should be replaced with a long random string with numeric and alphabetic characters. - -The `DATABASE_URL`, as the name suggests, contains the URL to your database. You currently do not have or need a real database. - -Last, you will need to generate Prisma Client based on your Prisma schema: - -```sh -npx prisma generate -``` -## Explore the API - -Now that you have a general idea of what unit testing is and isn't, take a look at the application you will be testing in this series. +- Basic knowledge of Prisma Client and its queries +- Some experience with Express is helpful +- The setup from [part 1](/testing-series-1-8eRB5p0Y8o), including the `prisma-client` generator, the driver adapter, `prisma.config.ts`, and a local [Prisma Postgres](https://www.prisma.io/docs/postgres) database -The project you cloned from Github contains a fully functional Express API. This API allows a user to log in, store and organize their favorite quotes. +## The application -The application's files are organized by feature into folders within the `src` directory. +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`: -Within `src` there are three main folders: +- `src/auth`: authentication of the API +- `src/quotes`: the quotes feature, including a tags service +- `src/lib`: general helpers, including the Prisma Client singleton -- `/auth`: Contains all files directly related to the authentication of the API -- `/quotes`: Contains all files directly related to the quotes feature of the API -- `/lib`: Contains any general helper files - -The API itself offers the following endpoints: +The API offers these endpoints: | endpoint | description | | -------- | ----------- | | `POST /auth/signup`| Creates a new user with a username and password. | | `POST /auth/signin`| Logs a user in with a username and a password. | -| `GET /quotes`| Returns all quotes related to the logged in user. | -| `POST /quotes`| Stores a new quote related to the logged in user. | +| `GET /quotes`| Returns all quotes for the logged in user. | +| `POST /quotes`| Stores a new quote for the logged in user. | | `DELETE /quotes/:id`| Deletes a quote belonging to the logged in user by id. | -Feel free to take some time to explore the files in this project and get a feel for how the API works. +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. -With a general understanding of what unit testing is and how the application works, you are now ready to start the process of writing tests to verify the application does what is intended. +## Set up Vitest for unit tests -> **Note**: In a real-world setting, these tests would help ensure that as the application evolves and changes, existing functionality remains intact. Tests would likely be written as you develop the application rather than after the application is complete. - -## Set up Vitest - -In order to begin testing, you will need to have a testing framework set up. In this series you are using [Vitest](https://vitest.dev/). - -Begin by installing [`vitest`](https://github.com/vitest-dev/vitest) and [`vitest-mock-extended`](https://github.com/eratio08/vitest-mock-extended) with the following command: +You installed Vitest and `vitest-mock-extended` in [part 1](/testing-series-1-8eRB5p0Y8o#mock-prisma-client). If you are starting fresh: ```sh npm i -D vitest vitest-mock-extended ``` -> **Note**: For information regarding the two packages installed above, be sure to read the [first article](/testing-series-1-8eRB5p0Y8o#mock-prisma-client) in this series. - -Next, you will need to configure Vitest so it knows where your unit tests are and how to resolve any modules you may need to import into those tests. -Create a new file at the root of your project named `vitest.config.unit.ts`: - -```sh -touch vitest.config.unit.ts -``` -This file will define and export the configuration for your unit tests using the [`defineConfig`](https://vitest.dev/config/#configuration) function provided by Vitest: +Configure Vitest so it knows where your unit tests live. Create `vitest.config.unit.ts` at the project root: ```ts // vitest.config.unit.ts - import { defineConfig } from 'vitest/config' export default defineConfig({ test: { include: ['src/**/*.test.ts'] - }, - resolve: { - alias: { - auth: '/src/auth', - quotes: '/src/quotes', - lib: '/src/lib' - } } }) ``` -Above you configured two options for Vitest: - -- The `test.include` option tells Vitest to look for tests within any files in the `src` directory that match the naming convention `*.test.ts`. -- The `resolve.alias` configuration sets up file path aliases. This allows you to shorten file import paths, e.g.: `src/auth/auth.service` becomes `auth/auth.service`. -Lastly, in order to more easily run your tests you will configure scripts in `package.json` to run the Vitest CLI commands. +The `test.include` option tells Vitest to look for `*.test.ts` files under `src`. -Add the following to the `scripts` section of `package.json`: +Add scripts to `package.json` to run these tests: ```json { - // ... "scripts": { - "dev": "ts-node src/index.ts", - "lint": "eslint . --ext .ts,.tsx --fix", - "lint:check": "eslint . --ext .ts,.tsx --max-warnings 0", - "format": "prettier --write .", - "format:check": "prettier --check .", - "checks": "npm run format:check && npm run lint:check", - "checks:fix": "npm run format && npm run lint", - "prepare": "husky install", -+ "test:unit": "vitest -c ./vitest.config.unit.ts", -+ "test:unit:ui": "vitest -c ./vitest.config.unit.ts --ui" - }, - // ... + "test:unit": "vitest -c ./vitest.config.unit.ts", + "test:unit:ui": "vitest -c ./vitest.config.unit.ts --ui" + } } ``` -Above two new scripts were added: - -- `test:unit`: This runs the `vitest` CLI command using the configuration file you created above. -- `test:unit:ui`: This runs the `vitest` CLI command using the configuration file you created above in _ui mode_. This opens up a GUI in your browser with tools to search, filter, and view the results of your tests. -To run these commands, you can execute the following in your terminal at the root of your project: - -```sh -npm run test:unit -npm run test:unit:ui -``` -> **Note**: If you run either of these commands right now, you will find the command fails. That is because there are no tests to run! - -At this point, Vitest is configured and you are ready to begin thinking about writing your unit tests. +- `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 jumping right in to writing tests, you will first take a look at the files that _don't_ need to be tested and think about why. - -Below is a list of files that do not need to be tested: - -- `src/index.ts` -- `src/auth/auth.router.ts` -- `src/auth/auth.schemas.ts` -- `src/quotes/quotes.router.ts` -- `src/quotes/quotes.schemas.ts` -- `src/quotes/quotes.service.ts` -- `src/lib/prisma.ts` -- `src/lib/createServer.ts` - -These files do not have any custom behaviors that require a unit test. - -In the next two sections you will take a look at the two primary scenarios in these files that cause them to not require tests. - -### The file doesn't have custom behavior - -Look at the following examples from the application: - +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: ```ts // 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' +import { validate } from '../lib/middlewares' const router = Router() @@ -360,96 +175,52 @@ router.delete('/:id', validate(DeleteQuoteSchema), QuoteController.deleteQuote) export default router ``` -```ts -// src/auth/auth.schemas.ts -import { z } from 'zod' -export const SignupSchema = z.object({ - body: z.object({ - username: z.string(), - password: z.string() - }) -}) -export type SignupSchema = z.infer['body'] -export const SigninSchema = SignupSchema -export type SigninSchema = SignupSchema -``` - - -In `src/quotes/quotes.router.ts`, the only things actually happening are invocations of functions provided by the Express framework. There are a few custom functions (`validate` and `QuoteController.*`) in play, however those are defined in separate files and will be tested in their own context. -The second file, `src/auth/auth.schemas.ts`, is very similar. While this file is important to the application, there really isn't anything here to test. The code simply exports schemas defined using the external module [`zod`](https://github.com/colinhacks/zod). +The custom functions (`validate`, `QuoteController.*`) are tested in their own context. -### The functions only invokes an external module - -Another scenario that is important to point out is the one in `src/quotes/quotes.service.ts`: +**The function only wraps an external module.** A service that forwards to Prisma Client has nothing custom to test: ```ts // src/quotes/quotes.service.ts -import prisma from 'lib/prisma' - -// ... +import prisma from '../lib/prisma' export const deleteQuote = async (id: number) => { - return await prisma.quote.delete({ - where: { id } - }) + return await prisma.quote.delete({ where: { id } }) } ``` -This service exports two functions. Both functions wrap a Prisma Client function invocation and return the results. - -As was mentioned previously in this article, there is no need to test external code. For that reason this file can be skipped. - -If you take a look at the remaining files from the list above that do not need tests, you will find each one does not need tests for one of the reasons outlined here. - -## What you will test - -The remaining `.ts` files in the project all contain functionality that should be unit tested. The complete list of files that require tests is as follows: -- `src/auth/auth.controller.ts` -- `src/auth/auth.service.ts` -- `src/lib/middlewares.ts` -- `src/lib/utility-classes.ts` -- `src/quotes/quotes.controller.ts` -- `src/quotes/tags.service.ts` - -Each function in each of these files should be given its own suite of tests that verify it behave correctly. - -As you might imagine, this can result in a lot of tests! To put this into numbers, the Express API contains thirteen different functions that need to be tested and each will likely have a suite of more than two tests. This means at the very least there will be twenty-six tests to write! - -In order to keep this article to a manageable length, you will write the tests for a single file, `src/quotes/tags.service.ts` as this file's tests cover all of the important unit-testing concepts this article hopes to cover. - -> **Note**: If you are curious about what the entire set of tests would look like for this API, the [`unit-tests`](https://github.com/sabinadams/express_sample_app/tree/unit-tests) branch of the Github repository has a complete set of tests for every function. +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 tags service exports two functions, `upsertTags` and `deleteOrphanedTags`. - + ```ts -import prisma from 'lib/prisma' +import prisma from '../lib/prisma' import randomColor from 'randomcolor' export const upsertTags = async (tags: string[]) => { - return await prisma.$transaction(async tx => { + return await prisma.$transaction(async (tx) => { const existingTags = await tx.tag.findMany({ select: { id: true, name: true }, where: { name: { in: tags } } }) - const existingNames = existingTags.map(tag => tag.name) - const existingIDs = existingTags.map(tag => tag.id) + const existingNames = existingTags.map((tag) => tag.name) + const existingIDs = existingTags.map((tag) => tag.id) const createdCount = await tx.tag.createMany({ data: tags - .filter(tag => !existingNames.includes(tag)) - .map(tag => ({ + .filter((tag) => !existingNames.includes(tag)) + .map((tag) => ({ name: tag, color: randomColor({ luminosity: 'light' }) })) }) - const tagIds = existingTags.map(tag => tag.id) + const tagIds = existingTags.map((tag) => tag.id) if (createdCount.count) { const createdTags = await tx.tag.findMany({ @@ -460,7 +231,7 @@ export const upsertTags = async (tags: string[]) => { } }) - const createdIds = createdTags.map(tag => tag.id) + const createdIds = createdTags.map((tag) => tag.id) tagIds.push(...createdIds) } @@ -480,326 +251,115 @@ export const deleteOrphanedTags = async (ids: number[]) => { -To start, create a new file in the same directory as `tags.service.ts` named `tags.service.test.ts`: +The service uses `randomcolor` to give new tags a color. Install it: ```sh -mkdir src/quotes/tests -touch src/quotes/tests/tags.service.test.ts +npm i randomcolor && npm i -D @types/randomcolor ``` -> **Note**: There are many ways to organize your tests. In this series, the tests will be written in a file right next to the target of the test, also known as colocating your tests. - -If you are using [VSCode](https://code.visualstudio.com/) and have v1.64 or later you have access to a cool feature that cleans up your project's file tree when colocating tests and their targets. - -Within VSCode, head to **Code > Preferences > Settings** in the option bar at the top of the screen. - -Within the settings page, search for the file nesting setting by typing in `file nesting`. Enable the setting below: -![File nesting option in VSCode](/testing-series-2-xPhjjmIEsM/imgs/file-nesting-option.png) +Create the test file next to the service at `src/quotes/tags.service.test.ts`. -Next, scrolling a bit further down in those settings you will see a **Explorer > File Nesting: Patterns** section. - -If an item named **\*.ts** does not exist, create one. Then update the **\*.ts** item's value to `${capture}.*.ts`: - -![File nesting setting in VSCode](/testing-series-2-xPhjjmIEsM/imgs/file-nesting-config.png) - -This lets VSCode to nest any files under the main file named `${capture}.ts`. To better illustrate, see the following example: - -![Nested files](/testing-series-2-xPhjjmIEsM/imgs/nested-files.png) - -Above you can see a file named `quotes.controller.ts`. Nested under that file is `quotes.controller.test.ts`. While not strictly necessary, this setting may help clean up your file tree a bit when colocating your unit tests. - -### Import required modules - -At the top of the new `tags.service.test.ts` file you will need to import a few things that will allow you to write your tests: +### Import required modules and mock dependencies ```ts // src/quotes/tags.service.test.ts -import * as TagService from '../tags.service' -import prismaMock from 'lib/__mocks__/prisma' +import * as TagService from './tags.service' +import prismaMock from '../lib/__mocks__/prisma' import randomColor from 'randomcolor' -import { describe } from 'vitest' -``` -Below is what each of these imports will be used for: - -- `TagsService`: This is the service you are writing tests against. You need to import it so you can invoke its functions. -- `prismaMock`: This is the mocked version of Prisma Client provided at `lib/__mocks__/prisma`. -- `randomColor`: The library used within the `upsertTags` function to generate random colors. -- `describe`: A function provided by `vitest` that allows you to describe a suite of tests. - -Important to note is the `prismaMock` import. This is the mocked Prisma Client instance which allows you to perform prisma queries without actually hitting a database. Because it is mocked, you can also manipulate the query responses and spy on its methods. - ->**Note**: If you are unsure about what the `prismaMock` import is and how it works, be sure to read the [previous article](/testing-series-1-8eRB5p0Y8o) in this series where this module's role is explained. - -### Describe the test suite - -You can now _describe_ this particular set of tests using the [`describe`](https://vitest.dev/api/#describe) function provided by Vitest: - -```ts -// src/quotes/tags.service.test.ts -// ... -describe('tags.service', () => {}) -``` -This will group the tests within this file into one section when outputting the test results making it easier to see which suites passed and failed. - -### Mock any modules used by the target file - -The last thing to do before writing the actual test suite is mock the external modules used within the `tags.service.ts` file. This will give you the ability to control the output of those modules as well as ensure your tests are not polluted by external code. - -Within this service there are two modules to mock: `PrismaClient` and `randomColor`. - -Mock those modules by adding the following: - -```ts -// src/quotes/tags.service.test.ts -// ... -// Added `beforeEach` and `vi` 👇🏻 -import { describe, beforeEach, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('lib/prisma') +vi.mock('../lib/prisma') vi.mock('randomcolor', () => ({ default: vi.fn(() => '#ffffff') })) describe('tags.service', () => { beforeEach(() => { - vi.restoreAllMocks() + vi.clearAllMocks() }) }) ``` -Above, the `lib/prisma` module was mocked using Vitest's automatic mock detection algorithm which looks in the same directory as the "real" Prisma module for a folder named `__mocks__` and a `__mocks__/prisma.ts` file. This file's exports are used as the mocked module in place of the real module's exports. - -The `randomColor` mock is a bit different as the module exports only a default value, which is a function. The second parameter of `vi.mock` is a function that returns the object the module should return when imported. The snippet above adds a `default` key to this object and sets its value to a spyable function with a static return value of `'#ffffff'`. -Within the test suite's context, [`beforeEach`](https://vitest.dev/api/#beforeeach) and [`vi.restoreAllMocks`](https://jestjs.io/docs/jest-object#jestrestoreallmocks) are used to ensure that between every individual test the mocks are restored to their original state. This is important as in some tests you will modify the behavior of a mock for that specific test. +A few things to note: -> **Note**: If you are unsure about how these mocks work, be sure to refer to the [previous article](/testing-series-1-8eRB5p0Y8o) in this series where mocking was covered. +- `prismaMock` is the mocked Prisma Client from `src/lib/__mocks__/prisma.ts`, the deep mock you built in [part 1](/testing-series-1-8eRB5p0Y8o). +- `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. -Whenever these modules are imported within `TagsService`, the mocked versions will now be imported instead. +> **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. -### Test the `upsertTags` function - -The `upsertTags` function takes in an array of tag names and creates a new tag for each name. It will not create a tag, however, if an existing tag in the database has the same name. The returned value of the function is the array of tag IDs associated with all of the tag names provided to the functions, both new and existing. - -Right below the `beforeEach` invocation within the test suite, add another `describe` to describe the suite of tests relating to the `upsertTags` function. Again, this is done to group the output of the tests making it easy to see which tests related to this specific function passed. +### Validate the function returns a list of tag IDs ```ts // src/quotes/tags.service.test.ts -//... - -describe('tags.service', () => { - beforeEach(() => { - vi.restoreAllMocks() +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]) }) -+ describe('upsertTags', () => {}) }) ``` -Now it is time to decide what the tests you write should cover. Taking a look at the `upsertTags` function, consider what specific behaviors it has. Each desired behavior should be tested. -Below, comments have been added showing each behavior that should be tested in this function. The comments are numbered, indicating which order the tests will be written in: - -```ts -// src/quotes/tags.service.ts -// ... -export const upsertTags = async (tags: string[]) => { - return await prisma.$transaction(async tx => { - const existingTags = await tx.tag.findMany({ - select: { id: true, name: true }, - where: { name: { in: tags } } - }) +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. - const existingNames = existingTags.map(tag => tag.name) - const existingIDs = existingTags.map(tag => tag.id) - - // 2. should only create tags that do not already exist - const createdCount = await tx.tag.createMany({ - data: tags - .filter(tag => !existingNames.includes(tag)) - .map(tag => ({ - name: tag, - // 3. should give new tags random colors - color: randomColor({ luminosity: 'light' }) - })) - }) - - const tagIds = existingTags.map(tag => tag.id) - - // 4. should find and return new tag IDs - if (createdCount.count) { - const createdTags = await tx.tag.findMany({ - select: { id: true }, - where: { - name: { in: tags }, - id: { notIn: existingIDs } - } - }) - - const createdIds = createdTags.map(tag => tag.id) - tagIds.push(...createdIds) - } - - // 1. should return a list of tag IDs - // 5. should return an empty array if no tags are passed - return tagIds - }) -} -``` -With a list of scenarios to test ready, you can now begin to write tests for each of them. - -#### Validate the function returns a list of tag IDs - -The first test will ensure that the returned value of the function is an array of tag IDs. Within the `describe` block for this function, add the new test: +### Validate it only creates tags that do not already exist ```ts // src/quotes/tags.service.test.ts -// ... -// Added `it` 👇🏻 -import { beforeEach, describe, expect, it, vi } from 'vitest' -// ... -describe('tags.service', () => { - beforeEach(() => { - vi.restoreAllMocks() - }) - 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]) - }) - }) -}) -``` -The test above does the following: - -1. Mocks the response of Prisma Client's `$transaction` function -2. Invokes the `upsertTags` function -3. Ensures the response of the function is equal to the expected mocked response of `$transaction` - -This test is important as it specifically tests for the desired outcome of the function. If this function were to change in the future, this test ensures that the results of the function remain what is expected. - -> **Note**: If you are unsure what a specific method provided by Vitest does, please refer to Vitest's [documentation](https://vitest.dev/api/expect.html). - -If you now run `npm run test:unit` you should see your test pass successfully. - -#### Validate the function only creates tags that do not already exist - -The next test planned above will verify that the function does not create duplicate tags in the database. - -The function is provided a list of strings that represent tag names. The function first checks for existing tags with those names and based on the results filters creates only new tags. +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) + ) -The test should: + // Mock the first `findMany` to return one existing tag + prismaMock.tag.findMany.mockResolvedValueOnce([ + { id: 1, name: 'tag1', color: '#ffffff' } + ]) -- Mock the first invocation of `prisma.tag.findMany` to return a single tag. This signifies that one existing tag was found based on the names provided to the function. -- Invoke `upsertTags` with three tag names. One name should be `tag1`, the name of the mocked existing tag. -- Ensure `prisma.tag.createMany` was provided only the two tags that did not match `tag1`. + // Mock `createMany` so the real query isn't run + prismaMock.tag.createMany.mockResolvedValueOnce({ count: 0 }) -Add the following test below the previous test within the `describe` block for the `upsertTags` function: + await TagService.upsertTags(['tag1', 'tag2', 'tag3']) -```ts -// src/quotes/tags.service.test.ts -// ... -describe('tags.service', () => { - beforeEach(() => { - vi.restoreAllMocks() - }) - describe('upsertTags', () => { - // ... - it('should only create tags that do not already exist', async () => { - // Mock the `$transaction` function and configure it to use the mocked Prisma Client - prismaMock.$transaction.mockImplementationOnce( - callback => callback(prismaMock) - ) - - // Mock the first invocation of `findMany` - prismaMock.tag.findMany.mockResolvedValueOnce([ - { id: 1, name: 'tag1', color: '#ffffff' } - ]) - - // Mock the resolved value of `createMany` to avoid invoking the real function - prismaMock.tag.createMany.mockResolvedValueOnce({ count: 0 }) - - // Invoke `upsertTags` with three tags, including `'tag1'` - await TagService.upsertTags(['tag1', 'tag2', 'tag3']) - - // Ensure that only `'tag2'` and `'tag3'` are provided to `createMany` - expect(prismaMock.tag.createMany).toHaveBeenCalledWith({ - data: [ - { name: 'tag2', color: '#ffffff' }, - { name: 'tag3', color: '#ffffff' } - ] - }) - }) + // Only `tag2` and `tag3` should reach `createMany` + expect(prismaMock.tag.createMany).toHaveBeenCalledWith({ + data: [ + { name: 'tag2', color: '#ffffff' }, + { name: 'tag3', color: '#ffffff' } + ] }) }) ``` -Running `npm run test:unit` again should now show both of your passing tests. - -#### Validate the function gives new tags a random color - -In this next test you will need to verify that whenever a new tag is created, it is provided a new random color. -In order to do this, write a basic test that inserts three new tags. After the `upsertTags` function is invoked, you can then ensure that the `randomColor` function was invoked three times. - -The snippet below shows what this test should look like. Add the new test below the previous test you wrote within the `describe` block for the `upsertTags` function: +### Validate new tags get a random color ```ts // src/quotes/tags.service.test.ts -// ... -describe('tags.service', () => { - beforeEach(() => { - vi.restoreAllMocks() - }) - describe('upsertTags', () => { - // ... - it('should give new tags random colors', async () => { - // Again, configuring the `$transaction` function to use the mocked client - prismaMock.$transaction.mockImplementationOnce(callback => - callback(prismaMock) - ) - - // Ensure there are no existing tags found - prismaMock.tag.findMany.mockResolvedValue([]) - // Mock the resolved value of `createMany` so the real function isn't invoked - prismaMock.tag.createMany.mockResolvedValueOnce({ count: 3 }) - // Invoke the function with three new tags - await TagService.upsertTags(['tag1', 'tag2', 'tag3']) - // Validate the `randomColor` function was called three times - expect(randomColor).toHaveBeenCalledTimes(3) - }) - }) +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) }) ``` -The `npm run test:unit` command should result in three successful tests. - -You may be wondering how the test above was able to check how many times `randomColor` was invoked. - -Remember, within the context of this file the `randomColor` module was mocked and its default export was configured to be a `vi.fn` that provides a function that returns a static string value. - -Because `vi.fn` was used, the mocked function is now registered within Vitest as a function you can _spy_ on. - -As as result, you have access to special properties such as a count of how many times the function was invoked during the current test. - -#### Validate the function includes the newly created tag IDs in its returned array - -In this test, you will need to verify that the function returns the tag IDs associated with every tag name provided to the function. This means it should return existing tag IDs and the IDs of any newly created tags. -This test should: +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. -1. Cause the first invocation of `tag.findMany` to return a tag to simulate finding an existing tag -2. Mock the response of `tag.createMany` -3. Cause the second invocation of `tag.findMany` to return two tags, signifying it found the two newly created tags -4. Invoke the `upsertTags` function with three tags -5. Ensure all three IDs are returned - -Add the following test to accomplish this: +### Validate it returns newly created tag IDs ```ts // src/quotes/tags.service.test.ts -// ... it('should find and return new tagIds when creating tags', async () => { - prismaMock.$transaction.mockImplementationOnce(callback => + prismaMock.$transaction.mockImplementationOnce((callback) => callback(prismaMock) ) // Simulate finding an existing tag @@ -812,52 +372,44 @@ it('should find and return new tagIds when creating tags', async () => { { id: 2, name: 'tag2', color: '#ffffff' }, { id: 3, name: 'tag3', color: '#ffffff' } ]) - // Invoke the function with three tags await TagService.upsertTags(['tag1', 'tag2', 'tag3']) - // Expect the transaction to have returned with all of the ids - expect(prismaMock.$transaction).toHaveReturnedWith([1, 2, 3]) + expect(prismaMock.$transaction).toHaveResolvedWith([1, 2, 3]) }) ``` -Verify the test above works by running `npm run test:unit`. - -#### Validate the function returns an empty array when not provided any tag names -As you might expect, if no tag names are provided to this function it should not be able to return any tag IDs. +> **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`](https://vitest.dev/api/expect.html#tohaveresolvedwith) waits for the promise to settle and compares the resolved value. Verified: swapping to `toHaveResolvedWith` makes this assertion pass. -In this test, verify that this behavior is working by adding the following: +### Validate it returns an empty array when given no tags ```ts // src/quotes/tags.service.test.ts -// ... it('should return an empty array if no tags passed', async () => { - prismaMock.$transaction.mockImplementationOnce(callback => + prismaMock.$transaction.mockImplementationOnce((callback) => callback(prismaMock) ) - // Ensure that all `findMany` and `createMany` invocations return empty results prismaMock.tag.findMany.mockResolvedValueOnce([]) prismaMock.tag.createMany.mockResolvedValueOnce({ count: 0 }) prismaMock.tag.findMany.mockResolvedValueOnce([]) - // Invoke `upsertTags` with no tag names await TagService.upsertTags([]) - // Ensure an empty array is returned - expect(prismaMock.$transaction).toHaveReturnedWith([]) + expect(prismaMock.$transaction).toHaveResolvedWith([]) }) ``` -With that, all of the scenarios that were determined for this function have been tested! -If you run your tests using either of the scripts you added to `package.json` you should see that all of the tests run and pass successfully! +Running `npm run test:unit` on this file, all five tests pass: -```sh -npm run test:unit:ui -``` -> **Note**: If you had not yet run this command you may be prompted to install the `@vitest/ui` package and re-run the command. +```text + ✓ 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 -![Successful suite of tests](/testing-series-2-xPhjjmIEsM/imgs/success.png) - -### Test the `deleteOrphanedTags` function + Test Files 1 passed (1) + Tests 5 passed (5) +``` -This function is a very different scenario than the previous function. +### The deleteOrphanedTags function ```ts // src/quotes/tags.service.ts @@ -870,19 +422,34 @@ export const deleteOrphanedTags = async (ids: number[]) => { }) } ``` -As you may have already determined, this function simply wraps an invocation of a Prisma Client function. Because of that... you guessed it! This function does not actually require a test! -## Summary & What's next +This function only wraps a Prisma Client call, so it does not require a unit test. + +## Frequently asked questions + + + +Use `vi.clearAllMocks()`. It clears `mock.calls` and `mock.results` on every mock. `vi.resetAllMocks()` does that and also resets implementations. `vi.restoreAllMocks()` only restores implementations created with `vi.spyOn` and does not clear call counts. This was verified on Vitest 4.1.10: a `vi.fn()` call count leaked across tests under `restoreAllMocks` but was reset correctly under `clearAllMocks`. + + +When the function under test passes an `async` callback to `$transaction`, the mocked `$transaction` returns a `Promise`, not the resolved array. `toHaveReturnedWith` compares against the return value synchronously, so it sees the unresolved promise and fails. Use `toHaveResolvedWith` instead, which waits for the promise to settle and compares the resolved value. Verified on Vitest 4.1.10. + + +Files with no custom logic: routers that only wire up framework calls, schema definitions, and services that only forward a single call to Prisma Client. You do not test external modules, and Prisma Client is an external module. Focus unit tests on functions that contain your own branching, transformation, or validation logic. + + + +## Summary & what's next -During the course of this article you: +During this article you: -- Learned what unit testing is and why it is important to your applications -- Saw a few examples of situations where unit testing is not strictly necessary -- Set up Vitest -- Learned a few tricks to make life easier when writing tests -- Tried your hand at writing unit tests for a service in the API +- 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 from the quotes API was covered in this article, the concepts and methods used to test the tags service also apply to the rest of the application. I would encourage you to write tests for the remainder of the API to practice! +While only one file was covered here, the same concepts apply to the rest of the application. In [part 3: Integration Testing](/testing-series-3-aBUyF8nxAn), you will drop the mocked client and write tests against a real database. -In the next part of this series, you will dive into _integration testing_ and write integration tests for this same application. +You can also revisit [part 1: Mocking Prisma Client](/testing-series-1-8eRB5p0Y8o) 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](https://pris.ly/pn-ea). diff --git a/apps/blog/content/blog/testing-series-3-aBUyF8nxAn/index.mdx b/apps/blog/content/blog/testing-series-3-aBUyF8nxAn/index.mdx index 0af07175ab..b595e1cacf 100644 --- a/apps/blog/content/blog/testing-series-3-aBUyF8nxAn/index.mdx +++ b/apps/blog/content/blog/testing-series-3-aBUyF8nxAn/index.mdx @@ -2,10 +2,11 @@ title: "The Ultimate Guide to Testing with Prisma: Integration Testing" slug: "testing-series-3-aBUyF8nxAn" date: "2023-02-14" +updatedAt: "2026-07-09" authors: - "Sabin Adams" -metaTitle: "The Ultimate Guide to Testing with Prisma: Integration Testing" -metaDescription: "Learn about how to plan, set up and write integration tests for your API." +metaTitle: "Integration Testing Prisma with Vitest & Postgres" +metaDescription: "How to integration test a Prisma ORM API against a real database with Vitest and Supertest, using a local Prisma Postgres. Verified on Prisma 7.8, Vitest 4." heroImagePath: "/testing-series-3-aBUyF8nxAn/imgs/hero-d60da664991c33bbb498244fe0303813fad6a657-844x474.svg" heroImageAlt: "The Ultimate Guide to Testing with Prisma: Integration Testing" series: testing-with-prisma @@ -14,217 +15,82 @@ tags: - "education" --- -Integration testing allows you to ensure the various components of your application work properly together. In this article you will take a look at how to set up your testing environment and how to write integration tests. - -## Table Of Contents - -- [Table Of Contents](#table-of-contents) -- [Introduction](#introduction) - - [What is integration testing?](#what-is-integration-testing) - - [Technologies you will use](#technologies-you-will-use) -- [Prerequisites](#prerequisites) - - [Assumed knowledge](#assumed-knowledge) - - [Development environment](#development-environment) -- [Set up Postgres in a Docker container](#set-up-postgres-in-a-docker-container) -- [Add a Vitest configuration file for integration tests](#add-a-vitest-configuration-file-for-integration-tests) -- [Update the unit testing configuration](#update-the-unit-testing-configuration) -- [Write a script to spin up the test environment](#write-a-script-to-spin-up-the-test-environment) -- [Configure your npm scripts](#configure-your-npm-scripts) -- [Write the integration tests](#write-the-integration-tests) - - [Write the tests for `/auth/signup`](#write-the-tests-for-authsignup) - - [Write the tests for `/auth/signin`](#write-the-tests-for-authsignin) -- [Summary & What's next](#summary--whats-next) - +To integration test an app that uses Prisma ORM, you run the tests against a real database, reset that database between tests, and drive the API the way a client would. This article sets up an integration testing environment with Vitest, Supertest, and a local [Prisma Postgres](https://www.prisma.io/docs/postgres) database, then writes integration tests for an Express API. This is part 3 of a five-part series on testing with Prisma ORM. +> **Updated (July 2026):** This article was rewritten for Prisma ORM 7 and the current stack. 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, `supertest` 7.2.2, `bcrypt` 6.0.0, and `jsonwebtoken` 9.0.3 on Node.js 22. The biggest change from the original 2023 article: the test database is now a local Prisma Postgres instance started with `npx prisma dev` (powered by PGlite), which removes the Docker Compose setup, the `wait-for-it.sh` script, and the custom bash orchestration entirely. Vitest's `threads: false` option is also gone; the current option to disable parallelism is `fileParallelism: false`. ## Introduction -So far in this series, you have explored mocking your Prisma Client and using that mocked Prisma Client to write unit tests against small isolated areas of your application. - -In this section of the series, you will say goodbye to the mocked Prisma Client and write _integration tests_ against a real database! By the end of this article you will have set up an integration testing environment and written integration tests for your Express API. +So far in this series you mocked Prisma Client and used the mock to write unit tests against small, isolated units of code. In this article you will say goodbye to the mocked client and write _integration tests_ against a real database. ### What is integration testing? -In the previous article of this series you learned to write unit tests, focusing on testing small isolated units of code to ensure the smallest building blocks of your application function properly. The goal of those tests was to test specific scenarios and bits of functionality without worrying about the underlying database, external modules, or interactions between components. - -_Integration testing_, however, is a different mindset altogether. This kind of testing involves taking related areas, or components, of your application and ensuring they function properly together. +Where unit tests confirm the smallest building blocks work in isolation, _integration testing_ takes related components and confirms they work together. ![diagram of request](/testing-series-3-aBUyF8nxAn/imgs/example.png) -The diagram above illustrates an example scenario where fetching a user's posts might require multiple hits to a database to verify the user has access to the API or any posts before actually retrieving the data. - -As illustrated above, multiple components of an application may be involved in handling indivual requests or actions. This often means database interactions happen multiple times across the different components during a single request or invocation. Because of this, integration tests often include a testing environment that includes a database to test against. - -With this very brief overview of integration testing, you will now begin preparing a testing environment where you will run integration tests. +The diagram illustrates a request that hits the database multiple times across different components. Because integration tests exercise these interactions, they run against a real database rather than a mock. ### Technologies you will use -- [Vitest](https://vitest.dev/) -- [Prisma](https://www.prisma.io/) -- [Node.js](https://nodejs.org/en/) -- [Postgres](https://www.postgresql.org/) -- [Docker](https://www.docker.com/) - -## Prerequisites +- [Vitest](https://vitest.dev/) 4 +- Prisma ORM 7 with the `prisma-client` generator and the `@prisma/adapter-pg` driver adapter +- [Node.js](https://nodejs.org/en/) 20 or later +- [Prisma Postgres](https://www.prisma.io/docs/postgres) running locally +- [Supertest](https://www.npmjs.com/package/supertest) ### Assumed knowledge -The following would be helpful to have when working through the steps below: - - Basic knowledge of JavaScript or TypeScript -- Basic knowledge of Prisma Client and its functionalities -- Basic understanding of Docker -- Some experience with a testing framework - -### Development environment - -To follow along with the examples provided, you will be expected to have: - -- [Node.js](https://nodejs.org) installed -- A code editor of your choice _(we recommend [VSCode](https://code.visualstudio.com/))_ -- [Git](https://github.com/git-guides/install-git) installed -- [Docker](https://www.docker.com/) installed +- Basic knowledge of Prisma Client and its queries +- The project setup and unit tests from [part 1](/testing-series-1-8eRB5p0Y8o) and [part 2](/testing-series-2-xPhjjmIEsM) -This series makes heavy use of this [GitHub repository](https://github.com/sabinadams/express_sample_app). Make sure to clone the repository and check out the `unit-tests` branch as that branch is the starting point for this article. +## Start a local test database -### Clone the repository +The original version of this article ran Postgres in a Docker container and orchestrated it with a shell script. On Prisma ORM 7 you can start a local Prisma Postgres database with a single command, no Docker required. It runs locally and is powered by PGlite. -In your terminal head over to a directory where you store your projects. In that directory run the following command: +In one terminal, start the database: ```sh -git clone git@github.com:sabinadams/express_sample_app.git +npx prisma dev -n testing ``` -The command above will clone the project into a folder named `express_sample_app`. The default branch for that repository is `main`, so you will need to checkout the `unit-tests` branch. -```sh -cd express_sample_app -git checkout unit-tests -``` -Once you have cloned the repository, there are a few steps to take to set the project up. +```text +✔ Your local Prisma Postgres server testing is now running 👍 -First, navigate into the project and install the `node_modules`: +🔌 To connect with Prisma ORM use the following connection strings: -```sh -npm i + DATABASE_URL="postgres://postgres:postgres@localhost:51254/template1?sslmode=disable&connection_limit=10&connect_timeout=0&max_idle_connection_lifetime=0&pool_timeout=0&socket_timeout=0" ``` -Next, create a `.env` file at the root of the project: -```sh -touch .env -``` -This file should contain a variable named `API_SECRET` whose value you can set to any `string` you want as well as one named `DATABASE_URL` which can be left empty for now: +Leave that running. Put the `DATABASE_URL` it printed, along with an `API_SECRET`, in your `.env` file: ```shell # .env +DATABASE_URL="postgres://postgres:postgres@localhost:51254/template1?sslmode=disable&..." API_SECRET="supersecretstring" -DATABASE_URL="" -``` -In `.env` the `API_SECRET` variable provides a _secret key_ used by the authentication services to encrypt your passwords. In a real-world application this value should be replaced with a long random string with numeric and alphabetic characters. - -The `DATABASE_URL`, as the name suggests, contains the URL to your database. You currently do not have or need a real database. - -## Set up Postgres in a Docker container - -The very first thing you will do to prepare your testing environment is build a [Docker](https://www.docker.com/) container using [Docker Compose](https://docs.docker.com/compose/) that provides a Postgres server. This will be the database your application uses while running integration tests. - -Before moving on, however, make sure you have Docker installed and running on your machine. You can follow the steps [here](https://docs.docker.com/get-docker/) to get Docker set up on your machine. - -To begin configuring your Docker container, create a new file at the root of your project named `docker-compose.yml`: - -```sh -touch docker-compose.yml ``` -This file is where you will configure your container, letting Docker know how to set up the database server, which _image_ to use (a Docker image is a set of instructions detailing how to build a container), and how to store the container's data. - -> **Note**: There are a ton of things you can configure within the `docker-compose.yml` file. You can find the documentation [here](https://docs.docker.com/compose/compose-file/). - -Your container should create and expose a Postgres server. -To accomplish this, start off by specifying which version of Compose's file format you will use: +The `API_SECRET` is a secret key the authentication service uses to sign session tokens. In production, use a long random string. -```yml -# docker-compose.yml -version: '3.8' -``` -This version number also determines which version of the Docker Engine you will be using. `3.8` is the latest version at the time of writing this article. - -Next, you will need a [`service`](https://docs.docker.com/compose/compose-file/#services-top-level-element) in which your database server will run. Create a new `service` named `db` with the following configuration: - -```yml -# docker-compose.yml -version: '3.8' -services: - db: - image: postgres:14.1-alpine - restart: always - environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres - ports: - - '5432:5432' - volumes: - - db:/var/lib/postgresql/data -``` -The configuration added above specifies a service named `db` with the following configrations: - -- `image`: Defines which Docker image to use when building this service -- `restart`: The `always` option lets Docker know to restart this service any time a failure happens or if Docker is restarted -- `environment`: Configures the environment variables to expose within the container -- `ports`: Specifies that Docker should map your machine's `5432` port to the container's `5432` port which is where the Postgres server will be running -- `volumes`: Specifies a name for a volume along with location on your local machine where your container will persist its data - -To finish off your service's configuration, you need to let Docker know how to configure and network the volume defined in the `volumes` configuration. - -Add the following to your `docker-compose.yml` file to let Docker know the volumes should be stored on your local Docker host machine (in your file system): - -```yml -# docker-compose.yml -version: '3.8' -services: - db: - image: postgres:14.1-alpine - restart: always - environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres - ports: - - '5432:5432' - volumes: - - db:/var/lib/postgresql/data -volumes: - db: - driver: local -``` -If you now head over to your terminal and navigate to the root of your project, you should be able to run the following command to start up your container running Postgres: +Apply your schema to the database and generate the client: ```sh -docker-compose up +npx prisma db push +npx prisma generate ``` -![Docker Compose running](/testing-series-3-aBUyF8nxAn/imgs/docker-compose-running.png) -Your database server is now available and accessible from the url `postgres://postgres:postgres@localhost:5432`. +```text +🚀 Your database is now in sync with your Prisma schema. Done in 71ms -Update your `.env` file's `DATABASE_URL` variable to point to that url and specify `quotes` as the database name: - -```shell -# .env -API_SECRET="supersecretstring" -DATABASE_URL="postgres://postgres:postgres@localhost:5432/quotes" +✔ Generated Prisma Client (7.8.0) to ./src/generated/prisma in 43ms ``` -## Add a Vitest configuration file for integration tests - -In the previous article, you created a configuration file for Vitest. That configuration file, `vitest.config.unit.ts`, was specific to the unit tests in the project. - -Now, you will create a second configuration file named `vitest.config.integration.ts` where you will configure how Vitest should run when running your integration tests. -> **Note**: These files will be very similar in this series. Depending on the complexity of your project, splitting your configurations out like this becomes more obviously beneficial. +> **Note**: `prisma db push` is the right fit for a test database: it syncs the schema without creating migration files. Remember that on Prisma 7 you must run `npx prisma generate` yourself after any schema change, because `db push` and `migrate dev` no longer regenerate the client. -Create a new file at the root of your project named `vitest.config.integration.ts`: +## Add a Vitest configuration for integration tests -```sh -touch vitest.config.integration.ts -``` -Paste the following into that new file: +In [part 2](/testing-series-2-xPhjjmIEsM) you created `vitest.config.unit.ts`. Create a second config, `vitest.config.integration.ts`, for integration tests: ```ts // vitest.config.integration.ts @@ -233,102 +99,68 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { include: ['src/tests/**/*.test.ts'], - }, - resolve: { - alias: { - auth: '/src/auth', - quotes: '/src/quotes', - lib: '/src/lib' - } + fileParallelism: false, + setupFiles: ['src/tests/helpers/setup.ts'] } }) ``` -The snippet above is essentially the same as the contents of `vitest.config.unit.ts` except the `test.include` blob points to any `.ts` files within `src/tests` rather than any files within `src` like the unit tests configuration does. This means all of your integration tests should go in a new folder within `src` named `tests`. -Next, add another key to this new configuration file that tells Vitest not to run multiple tests at the same time in different threads: +Three things here: + +- `test.include` points to `.ts` files in `src/tests`, so integration tests live in their own folder. +- `fileParallelism: false` runs test files one at a time. This matters because the tests share one database; running them in parallel would cause them to see each other's data. +- `setupFiles` runs a setup file before your tests, used below to reset the database. + +> **Updated (July 2026):** The original used `threads: false`. On Vitest 4 that option no longer exists; setting it has no effect (verified: the tests still ran in parallel-capable pools). The current way to force serial execution is `fileParallelism: false`. + +Keep the unit config scoped so it does not pick up integration tests: ```ts -// vitest.config.integration.ts +// vitest.config.unit.ts import { defineConfig } from 'vitest/config' export default defineConfig({ test: { - include: ['src/tests/**/*.test.ts'], - threads: false - }, - resolve: { - alias: { - auth: '/src/auth', - quotes: '/src/quotes', - lib: '/src/lib' - } + include: ['src/**/*.test.ts', '!src/tests'] } }) ``` -This is extrememely important because your integration tests will be interacting with a database and expecting specific sets of data. If multiple tests are running at the same time and interacting with your database, you will likely cause problems in your tests due to unexpected data. - -On a similar note, you will also need a way to reset your database between tests. In this application, between every single test you will completely clear out your database so you can start with a blank slate on each test. -Create a new folder in `src` named `tests` and a new folder with `tests` named `helpers`: +## Reset the database between tests -```sh -mkdir -p src/tests/helpers -``` -Within that new directory, create a file named `prisma.ts`: - -```sh -touch src/tests/helpers/prisma.ts -``` -This file is a helper that simply instantiates and exports Prisma Client. - -Add the following to that file: +Each integration test should start from a clean slate. Create a helper that instantiates a client for the tests. It uses the driver adapter and reads env, so it imports `dotenv/config`: ```ts // src/tests/helpers/prisma.ts -import { PrismaClient } from '@prisma/client' +import 'dotenv/config' +import { PrismaPg } from '@prisma/adapter-pg' +import { PrismaClient } from '../../generated/prisma/client' -const prisma = new PrismaClient() +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! }) +const prisma = new PrismaClient({ adapter }) export default prisma ``` -Now create another file in `src/tests/helpers` named `reset-db.ts`: - -```sh -touch src/tests/helpers/reset-db.ts -``` -This file is where you will write and export a function that resets your database. -Your database only has three tables: `Tag`, `Quote` and `User`. Write and export a function that runs `deleteMany` on each of those tables within a transaction: +Add a reset function that clears every table inside a transaction: ```ts // src/tests/helpers/reset-db.ts -import { PrismaClient } from '@prisma/client' - -const prisma = new PrismaClient() +import prisma from './prisma' export default async () => { await prisma.$transaction([ prisma.tag.deleteMany(), prisma.quote.deleteMany(), + prisma.post.deleteMany(), prisma.user.deleteMany() ]) } ``` -With the file written above, you now have a way to clear your database. The last thing to do here is actually invoke that function between each and every integration test. - -A nice way to do this is to use a [_setup file_](https://vitest.dev/config/#setupfiles). This is a file that you can configure Vitest to process before running any tests. Here you can use Vitest's lifecycle hooks to customize its behavior. - -Create another file in `src/tests/helpers` named `setup.ts`. - -```sh -touch src/tests/helpers/setup.ts -``` -Your goal is to reset the database before every single test to make sure you have a clean slate. You can accomplish this by running the function exported by `reset-db.ts` within the `beforeEach` lifecycle function provided by Vitest. -Within `setup.ts`, use `beforeEach` to run your reset function between every test: +Then run that reset before every test using Vitest's `beforeEach` in the setup file: ```ts // src/tests/helpers/setup.ts - import resetDb from './reset-db' import { beforeEach } from 'vitest' @@ -336,925 +168,233 @@ beforeEach(async () => { await resetDb() }) ``` -Now when you run your suite of tests, every individual test across all files within `src/tests` will start with a clean slate. - -> **Note**: You may be wondering about a scenario where you would want to start off with some data in a specific testing context. Within each individual test file you write, you can also hook into these lifecycle functions and customize the behavior per-file. An example of this will be shown later on. - -Lastly, you now need to let Vitest know about this setup file and tell it to run the file whenever you run your tests. -Update `vitest.config.integration.ts` with the following: +Because you registered this file in `setupFiles`, every test across `src/tests` now starts with an empty database. -```ts -// vitest.config.integration.ts -import { defineConfig } from 'vitest/config' +## Configure npm scripts -export default defineConfig({ - test: { - include: ['src/tests/**/*.test.ts'], - threads: false, -+ setupFiles: ['src/tests/helpers/setup.ts'] - }, - resolve: { - alias: { - auth: '/src/auth', - quotes: '/src/quotes', - lib: '/src/lib' - } - } -}) -``` -## Update the unit testing configuration - -Currently, the unit testing configuration file will also run your integration tests as it searches for any `.ts` file that lies within `src`. - -Update the configuration in `vitest.config.unit.ts` to ignore files within `src/tests`: - - -```ts -// vitest.config.unit.ts -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { -- include: ['src/**/*.test.ts'] -+ include: [ -+ 'src/**/*.test.ts', -+ '!src/tests' -+ ] - }, - resolve: { - alias: { - auth: '/src/auth', - quotes: '/src/quotes', - lib: '/src/lib' - } - } -}) -``` -Now your unit tests and integration tests are completely separate and can only be run using their own individual commands. - -## Write a script to spin up the test environment - -Up until now you have built out ways to: - -- Spin up a database server within a Docker container -- Run integration tests with a specific testing configuration -- Run unit tests separately from integration tests - -What is missing is a way to actually orchestrate the creation of your Docker container and the running of your integration tests in a way that ensures your database is running and available to your testing environment. - -To make this work, you will write a set of custom bash scripts that start up your Docker container, wait for the server to be ready, and then run your tests. - -Create a new directory at the root of your project named `scripts`: - -```sh -mkdir scripts -``` -Within that directory, create a file named `run-integration.sh`: - -```sh -touch scripts/run-integration.sh -``` -Within this file, you will need the following steps to happen: - -1. Load in any environment variables from `.env` so you have access to the database URL. -2. Start your Docker container in detached mode. -3. Wait for the database server to become available. -4. Run a Prisma migration to apply your Prisma schema to the database. -5. Run your integration tests. _As a bonus, you should also be able to run this file with a `--ui` flag to run Vitest's GUI interface_. - -### Load up your environment variables - -This first step is where you will read in a `.env` file and make those variables available within the context of your scripts. - -Create another file in `scripts` named `setenv.sh`: - -```sh -touch scripts/setenv.sh -``` -Within this file, add the following snippet: - -```shell -#!/usr/bin/env bash -# scripts/setenv.sh - -# Export env vars -export $(grep -v '^#' .env | xargs) -``` -This will read in your `.env` file and export each variable so it becomes available in your scripts. - -Back in `scripts/run-integration.sh`, you can now use this file to gain access to the environment variables using the `source` command: - -```shell -#!/usr/bin/env bash -# scripts/run-integration.sh - -DIR="$(cd "$(dirname "$0")" && pwd)" -source $DIR/setenv.sh -``` -Above, the `DIR` variable is used to find the relative path to `setenv.sh` and that path is used to execute the script. - -### Start your Docker container in detached mode - -The next step is to spin up your Docker container. It is important to note that you will need to start the container in _detached mode_. - -Typically, when you run `docker-compose up` your terminal will be connected to the container's output so you can see what is going on. This, however, prevents the terminal from performing any other actions until you stop your Docker container. - -Running the container in detached mode allows it to run in the background, freeing up your terminal to continue running commands (like the command to run your integration tests). - -Add the following to `run-integration.sh`: - -```shell -#!/usr/bin/env bash -# scripts/run-integration.sh - -DIR="$(cd "$(dirname "$0")" && pwd)" -source $DIR/setenv.sh -+docker-compose up -d -``` -Here, the `-d` flag signifies the container should be run in detached mode. - -### Make the script wait until the database server is ready - -Before running your Prisma migration and your tests, you need to be sure your database is ready to accept requests. - -In order to do this, you will use a well-known script called [`wait-for-it.sh`](https://github.com/nickjj/wait-until). This script allows you to provide a URL along with some timing configurations and will cause the script to wait until the resource at a provided URL becomes available before moving on. - -Download the contents of that script into a file named `scripts/wait-for-it.sh` by running the command below: - -```sh -curl https://raw.githubusercontent.com/nickjj/wait-for-it/master/wait-for-it.sh -o scripts/wait-for-it.sh -``` -> **Warning**: If the `wait-for-it.sh` script does not work for you, please see the [GitHub discussion](https://github.com/prisma/prisma/discussions/19245) for an alternative way to connect to the database and ensure the test runs successfully. - -Then, head back into `run-integration.sh` and update it with the following: - -```shell -#!/usr/bin/env bash -# src/run-integration.sh - -DIR="$(cd "$(dirname "$0")" && pwd)" -source $DIR/setenv.sh -docker-compose up -d -+echo '🟡 - Waiting for database to be ready...' -+$DIR/wait-for-it.sh "${DATABASE_URL}" -- echo '🟢 - Database is ready!' -``` -Your script will now wait for the database at the location specified in the `DATABASE_URL` environment variable to be available before continuing. - -If you are on a Mac, you will also need to run the following command to install and alias a command that is used within the `wait-for-it.sh` script: - -```sh -brew install coreutils && alias timeout=gtimeout -``` -### Prepare the database and run the tests - -The last two steps are now safe to take place. - -After the `wait-for-it` script is run, run a Prisma migration to apply any new changes to the database: - -```shell -#!/usr/bin/env bash -# src/run-integration.sh - -DIR="$(cd "$(dirname "$0")" && pwd)" -source $DIR/setenv.sh -docker-compose up -d -echo '🟡 - Waiting for database to be ready...' -$DIR/wait-for-it.sh "${DATABASE_URL}" -- echo '🟢 - Database is ready!' -+npx prisma migrate dev --name init -``` -Then to wrap this all up, add the following statement to run your integration tests: - -```shell -#!/usr/bin/env bash -# src/run-integration.sh - -DIR="$(cd "$(dirname "$0")" && pwd)" -source $DIR/setenv.sh -docker-compose up -d -echo '🟡 - Waiting for database to be ready...' -$DIR/wait-for-it.sh "${DATABASE_URL}" -- echo '🟢 - Database is ready!' -npx prisma migrate dev --name init -+if [ "$#" -eq "0" ] -+ then -+ vitest -c ./vitest.config.integration.ts -+else -+ vitest -c ./vitest.config.integration.ts --ui -+fi -``` -Notice the `if`/`else` statement that was used. This is what allows you to look for a flag passed to the script. If a flag was found, it is assumed to be `--ui` and will run the tests with the Vitest user interface. - -### Make the scripts executable - -The scripts required to run your tests are all complete, however if you try to execute any of them you will get a permissions error. - -In order to make these scripts executable, you will need to run the following command which gives your current user access to run them: - -```sh -chmod +x scripts/* -``` -## Configure your npm scripts - -Your scripts are now executable. The next step is to create `scripts` records within `package.json` that will invoke these custom scripts and start up your tests. - -In `package.json` add the following to the `scripts` section: +Add scripts to `package.json` to run the integration tests: ```json -// package.json -// ... -"scripts": { - // ... - "test:unit": "vitest -c ./vitest.config.unit.ts", - "test:unit:ui": "vitest -c ./vitest.config.unit.ts --ui", -+ "test:int": "./scripts/run-integration.sh", -+ "test:int:ui": "./scripts/run-integration.sh --ui" -}, -// ... -``` -Now, if you run either of the following scripts you should see your Docker container spinning up, a Prisma migration being executed, and finally your tests being run: - -```shell -npm run test:int +{ + "scripts": { + "test:int": "vitest -c ./vitest.config.integration.ts", + "test:int:ui": "vitest -c ./vitest.config.integration.ts --ui" + } +} ``` -![tests running and failing](/testing-series-3-aBUyF8nxAn/imgs/tests-run.png) -> **Note**: At the moment your tests will fail. That is because Vitest could not find any files with tests to run. +With the database running, `npm run test:int` will connect to it directly. There is no container to spin up and no readiness script to wait on. ## Write the integration tests -Now it is time to put your testing environment to use and write some tests! - -When thinking about which parts of your applications need integration tests it is important to think about important interactions between components and how those interactions are invoked. - -In the case of the Express API you are working in, the important groupings of interactions occur between _routes_, _controllers_ and _services_. When a user hits an endpoint in your API, the route handler passes the request to a controller and the controller may invoke service functions to interact with the database. +When deciding what to integration test, focus on the interactions between components. In this Express API, the important groupings are _routes_, _controllers_, and _services_. A request enters a route, the controller handles it, and services talk to the database. -Keeping this in mind, you will focus your integration tests on testing each route individually, ensuring each one responds properly to HTTP requests. This includes both valid and invalid requests to the API. The goal is for your tests to mimic the experience your API's consumer would have when interacting with it. +You will test the `/auth/signup` and `/auth/signin` routes, driving them over HTTP so the tests mimic a real client. -> **Note**: There are many differing opinions on what integration tests should cover. In some cases, a developer may want to write dedicated integration tests to ensure smaller components (such as your _controllers_ and _services_) work correctly together along with tests that validate the entire API route works correctly. The decision about what should be covered in your tests depends entirely on your application's needs and what you as a developer feel needs to be tested. +### Set up the test file -Similar to the previous article in this series, in order to keep the information in this tutorial to a manageable length you will focus on writing the tests for the API routes `/auth/signin` and `/auth/signup`. - -> **Note**: If you are curious about what the tests for the `/quotes/*` routes would look like, the complete set of tests is available in the `integration-tests` branch of the GitHub repository. - -### Write the tests for `/auth/signup` - -Create a new file in `src/tests` named `auth.test.ts` +Create `src/tests/auth.test.ts`. Use [Supertest](https://www.npmjs.com/package/supertest) to send requests to the Express app, and import the Prisma helper to inspect the database: ```sh -touch src/tests/auth.test.ts -``` -This is where all tests relating to the `/auth` routes of your API will go. - -Within this file, import the `describe`, `expect` and `it` functions from Vitest and use `describe` to define this suite of tests: - -```ts -// src/tests/auth.test.ts -import { describe, expect, it } from 'vitest' - -describe('/auth', async () => { - // tests will go here -}) +npm i -D supertest @types/supertest ``` -The first endpoint you will test is the `POST /auth/signup` route. - -Within the test suite context, add another `describe` block to describe the suite of tests associated with this specific route: ```ts // src/tests/auth.test.ts -import { describe, expect, it } from 'vitest' +import 'dotenv/config' +import { beforeEach, describe, expect, it } from 'vitest' +import request from 'supertest' +import jwt from 'jsonwebtoken' +import bcrypt from 'bcrypt' +import app from '../lib/createServer' +import prisma from './helpers/prisma' -describe('/auth', async () => { +describe('/auth', () => { describe('[POST] /auth/signup', () => { - // tests will go here + // signup tests }) }) ``` -This route allows you to provide a username and a password to create a new user. By looking through the logic in `src/auth/auth.controller.ts` and the route definition at `src/auth/auth.routes.ts`, it can be determined that the following behaviors that are important to test for: - -- It should respond with a `200` status code and user details -- It should respond with a valid session token when successful -- It should respond with a `400` status code if a user exists with the provided username -- It should respond with a `400` status code if an invalid request body is provided - -> **Note**: The tests should include any variation of responses you would expect from the API, both successful and errored. Reading through an API's route definitions and controllers will usually be enough to determine the different scenarios you should test for. -The next four sections will detail how to write the tests for these scenarios. +### Tests for /auth/signup -#### It should respond with a `200` status code and user details +The signup route creates a user from a username and password. Reading the controller and router, the behaviors worth testing are: - -To begin writing the test for this scenario, use the `it` function imported from Vitest to describe what "it" should do. - -Within the `describe` block for the `/auth/signup` route add the following: +- It responds with `200` and the user details. +- It responds with a valid session token on success. +- It responds with `400` if a user already exists with that username. +- It responds with `400` if the request body is invalid. ```ts -describe('/auth', async () => { - describe('[POST] /auth/signup', () => { -+ it('should respond with a `200` status code and user details', async () => { -+ // test logic will go here -+ }) +// src/tests/auth.test.ts (inside the [POST] /auth/signup describe) +it('should respond with a `200` status code and user details', async () => { + const { status, body } = await request(app).post('/auth/signup').send({ + username: 'testusername', + password: 'testpassword' }) -}) -``` -In order to actually `POST` data to the endpoint in your application you will make use of a library named [`supertest`](https://www.npmjs.com/package/supertest). This library allows you to provide it an HTTP server and send requests to that server via a simple API. - -Install `supertest` as a development dependency: - -```sh -npm i -D supertest @types/supertest -``` -Then import `supertest` at the top of `src/tests/auth.test.ts` with the name `request`. Also import the default export from `src/lib/createServer`, which provides the `app` object: - -```ts -// src/tests/auth.test.ts -import { describe, expect, it } from 'vitest' -+import request from 'supertest' -+import app from 'lib/createServer' - -describe('/auth', async () => { - describe('[POST] /auth/signup', () => { - it('should respond with a `200` status code and user details', async () => { - // test logic will go here - }) + const newUser = await prisma.user.findFirst() + expect(status).toBe(200) + expect(newUser).not.toBeNull() + expect(body.user).toStrictEqual({ + username: 'testusername', + id: newUser?.id }) }) -``` -You can now send a request to your Express API using the `request` function: -```ts -// src/tests/auth.test.ts -// ... -describe('/auth', async () => { - describe('[POST] /auth/signup', () => { - it('should respond with a `200` status code and user details', async () => { -+ const { status, body } = await request(app).post('/auth/signup').send({ -+ username: 'testusername', -+ password: 'testpassword' -+ }) - }) +it('should respond with a valid session token when successful', async () => { + const { body } = await request(app).post('/auth/signup').send({ + username: 'testusername', + password: 'testpassword' }) + expect(body).toHaveProperty('token') + expect(jwt.verify(body.token, process.env.API_SECRET as string)) }) -``` -Above, the `app` instance was passed to the `request` function. The response of that function is a set of functions that allow you to interact with the HTTP server passed to `request`. - -The `post` function was then used to define the _HTTP method_ and route you intended to interact with. Finally `send` was invoked to send the `POST` request along with a request body. - -The returned value of that contains all of the details of the request's response, however the `status` and `body` values were specifically pulled out of the response. - -Now that you have access to the response status and body, you can verify the route performed the action it was intended to and responded with the correct values. - -Add the following to this test to verify these cases: -```ts -// src/tests/auth.test.ts -// 1 -+import prisma from './helpers/prisma' -// ... -describe('/auth', async () => { - describe('[POST] /auth/signup', () => { - it('should respond with a `200` status code and user details', async () => { - const { status, body } = await request(app).post('/auth/signup').send({ - username: 'testusername', - password: 'testpassword' - }) - // 2 -+ const newUser = await prisma.user.findFirst() - // 3 -+ expect(status).toBe(200) - // 4 -+ expect(newUser).not.toBeNull() - // 5 -+ expect(body.user).toStrictEqual({ -+ username: 'testusername', -+ id: newUser?.id -+ }) - }) +it('should respond with a `400` if a user already exists with that username', async () => { + await prisma.user.create({ + data: { username: 'testusername', password: 'somepassword' } }) -}) -``` -The changes above do the following: - -1. Imports `prisma` so you can query the database to double-check data was created correctly -2. Uses `prisma` to fetch the newly created user -3. Ensures the request responded with a `200` status code -4. Ensures a user record was found -5. Ensures the response body contained a `user` object with the user's `username` and `id` - -If you run `npm run test:int:ui` in your terminal, you should see the Vitest GUI open up along with a successful test message. - -> **Note**: If you had not yet run this command you may be prompted to install the `@vitest/ui` package and re-run the command. - -![Successful test run](/testing-series-3-aBUyF8nxAn/imgs/test-success.png) - -> **Note**: No modules, including Prisma Client, were mocked in this test! Your test was run against a real database and verified the data interactions in this route work properly. -#### It should respond with a valid session token when successful - -This next test will verify that when a user is created, the response should include a session token that can be used to validate that user's requests to the API. - -Create a new test for this scenario beneath the previous test: - -```ts -// src/tests/auth.test.ts -// ... -describe('/auth', async () => { - describe('[POST] /auth/signup', () => { - // ... -+ it('should respond with a valid session token when successful', async () => { -+ -+ }) + const { status, body } = await request(app).post('/auth/signup').send({ + username: 'testusername', + password: 'testpassword' }) -}) -``` -This test will be a bit simpler than the previous. All it needs to do is send a valid sign up request and inspect the response to verify a valid token was sent back. - -Use `supertest` to send a `POST` request to the `/auth/signup` endpoint and retrieve the response body: -```ts -// src/tests/auth.test.ts -// ... -describe('/auth', async () => { - describe('[POST] /auth/signup', () => { - // ... - it('should respond with a valid session token when successful', async () => { -+ const { body } = await request(app).post('/auth/signup').send({ -+ username: 'testusername', -+ password: 'testpassword' -+ }) - }) - }) + const count = await prisma.user.count() + expect(status).toBe(400) + expect(count).toBe(1) + expect(body).not.toHaveProperty('user') }) -``` -The response body should contain a field named `token` which contains the session token string. -Add a set of expectations that verify the `token` field is present in the response, and also use the `jwt` library to verify the token is a valid session token: - -```ts -// src/tests/auth.test.ts -+import jwt from 'jsonwebtoken' -// ... -describe('/auth', async () => { - describe('[POST] /auth/signup', () => { - // ... - it('should respond with a valid session token when successful', async () => { - const { body } = await request(app).post('/auth/signup').send({ - username: 'testusername', - password: 'testpassword' - }) -+ expect(body).toHaveProperty('token') -+ expect(jwt.verify(body.token, process.env.API_SECRET as string)) - }) +it('should respond with a `400` if an invalid request body is provided', async () => { + const { body, status } = await request(app).post('/auth/signup').send({ + email: 'test@prisma.io', // should be username + password: 'testpassword' }) + expect(status).toBe(400) + expect(body.message).toBe('Invalid or missing input provided for: username') }) - -// ... ``` -#### It should respond with a `400` status code if a user exists with the provided username -Until now, you have verified valid requests to `/auth/signup` respond as expected. Now you will switch gears and make sure the app appropriately handles invalid requests. +Each test drives a real HTTP request through Supertest, then uses `prisma` to check what actually landed in the database. No modules are mocked; these run against the live database, and the setup file clears it between each test. -Add another test for this scenario: +### Tests for /auth/signin -```ts -// src/tests/auth.test.ts -// ... -describe('/auth', async () => { - describe('[POST] /auth/signup', () => { - // ... -+ it('should respond with a `400` status code if a user exists with the provided username', async () => { -+ -+ }) - }) -}) -``` -In order to trigger the 400 that should occur when a sign up request is made with an existing username, a user must already exist in the database. - -Add a query to this test that creates a user named `'testusername'` with any password: +The signin route validates an existing user. Its tests need a user in the database first, so use `beforeEach` inside this suite to create one. Match the same password hashing the auth service uses: ```ts -// src/tests/auth.test.ts -// ... -describe('/auth', async () => { - describe('[POST] /auth/signup', () => { - // ... - it('should respond with a `400` status code if a user exists with the provided username', async () => { -+ await prisma.user.create({ -+ data: { -+ username: 'testusername', -+ password: 'somepassword' -+ } -+ }) +// src/tests/auth.test.ts (add a sibling describe) +describe('[POST] /auth/signin', () => { + beforeEach(async () => { + await prisma.user.create({ + data: { + username: 'testusername', + password: bcrypt.hashSync('testpassword', 8) + } }) }) -}) -``` -Now you should be able to trigger the error by sending a sign up request with the same username as that user. - ->**Note**: Remember, this user record (as well as the other records created as a result of your sign up tests) are deleted between each individual test. - -Send a request to `/auth/signup` providing the same username as the user created above: `'testusername'`: -```ts -// src/tests/auth.test.ts -// ... -describe('/auth', async () => { - describe('[POST] /auth/signup', () => { - // ... - it('should respond with a `400` status code if a user exists with the provided username', async () => { - await prisma.user.create({ - data: { - username: 'testusername', - password: 'somepassword' - } - }) - -+ const { status, body } = await request(app).post('/auth/signup').send({ -+ username: 'testusername', -+ password: 'testpassword' -+ }) + it('should respond with a `200` when provided valid credentials', async () => { + const { status } = await request(app).post('/auth/signin').send({ + username: 'testusername', + password: 'testpassword' }) + expect(status).toBe(200) }) -}) -``` -Now that a request is being sent to that endpoint, it is time to think about what you would expect to happen in this scenario. You would expect: - -- The request to respond with a `400` status code -- The response body to not contain a `user` object -- The count of users in the database to be only `1` - -Add the following expectations to the test to verify these points are all met: - -```ts -// src/tests/auth.test.ts -// ... -describe('/auth', async () => { - describe('[POST] /auth/signup', () => { - // ... - it('should respond with a `400` status code if a user exists with the provided username', async () => { - await prisma.user.create({ - data: { - username: 'testusername', - password: 'somepassword' - } - }) - - const { status, body } = await request(app).post('/auth/signup').send({ - username: 'testusername', - password: 'testpassword' - }) -+ const count = await prisma.user.count() -+ expect(status).toBe(400) -+ expect(count).toBe(1) -+ expect(body).not.toHaveProperty('user') + it('should respond with the user details when successful', async () => { + const { body } = await request(app).post('/auth/signin').send({ + username: 'testusername', + password: 'testpassword' }) + const keys = Object.keys(body.user) + expect(keys.length).toBe(2) + expect(keys).toStrictEqual(['id', 'username']) + expect(body.user.username).toBe('testusername') }) -}) -``` -#### It should respond with a `400` status code if an invalid request body is provided - -The last test you will write for this endpoint is a test verifying a request will respond with a `400` status code if an invalid request body is sent to the API. - -This endpoint, as indicated in `src/auth/auth.router.ts`, uses [`zod`](https://github.com/colinhacks/zod) to validate its request body contains a valid `username` and `password` field via a middleware named `validate` defined in `src/lib/middlewares.ts`. - -This test will specifically make sure the `validate` middleware and the `zod` definitions are working as expected. - -Add a new test for this scenario: -```ts -// src/tests/auth.test.ts -// ... -describe('/auth', async () => { - describe('[POST] /auth/signup', () => { - // ... -+ it('should respond with a `400` status code if an invalid request body is provided', async () => { -+ -+ }) - }) -}) -``` -This test will very straightforward. It should simply send a `POST` request to the `/auth/signup` endpoint and provide an invalid request body. - -Use `supertest` to send a `POST` request to `/auth/signup`, however instead of a `username` field send an `email` field: - -```ts -// src/tests/auth.test.ts -// ... -describe('/auth', async () => { - describe('[POST] /auth/signup', () => { - // ... - it('should respond with a `400` status code if an invalid request body is provided', async () => { -+ const { body, status } = await request(app).post('/auth/signup').send({ -+ email: 'test@prisma.io', // should be username -+ password: 'testpassword' -+ }) + it('should respond with a valid session token when successful', async () => { + const { body } = await request(app).post('/auth/signin').send({ + username: 'testusername', + password: 'testpassword' }) + expect(body).toHaveProperty('token') + expect(jwt.verify(body.token, process.env.API_SECRET as string)) }) -}) -``` -This request body should cause the validation middleware to respond to the request with a `400` error code before continuing to the controller. - -Use the following set of expectations to validate this behavior: -```ts -// src/tests/auth.test.ts -// ... -describe('/auth', async () => { - describe('[POST] /auth/signup', () => { - // ... - it('should respond with a `400` status code if an invalid request body is provided', async () => { - const { body, status } = await request(app).post('/auth/signup').send({ - email: 'test@prisma.io', // should be username - password: 'testpassword' - }) -+ expect(status).toBe(400) -+ expect(body.message).toBe( -+ `Invalid or missing input provided for: username` -+ ) + it('should respond with a `400` when given invalid credentials', async () => { + const { body, status } = await request(app).post('/auth/signin').send({ + username: 'testusername', + password: 'wrongpassword' }) + expect(status).toBe(400) + expect(body).not.toHaveProperty('token') }) -}) -``` -With that, your suite of tests for the `/auth/signup` endpoint is complete! If you take a look back at the Vitest GUI you should find all of your tests are successful: - -```shell -npm run test:int:ui -``` -![Full suite of signup tests complete](/testing-series-3-aBUyF8nxAn/imgs/signup-suite-success.png) - -### Write the tests for `/auth/signin` - -This next endpoint you will write tests for has many similarities to the previous one, however rather than creating a new user it validates an existing user. - -The `/auth/signin` endpoint takes in a `username` and a `password`, makes sure a user exists with the provided data, generates a session token and responds to the request with the session token and the user's details. - ->**Note**: The implementation of this functionality can be found in `src/auth/auth.controller.ts` and `src/auth/auth.router.ts`. - -In your suite of tests you will verify the following are true of this endpoint: - -- It should respond with a `200` status code when provided valid credentials -- It should respond with the user details when successful -- It should respond with a valid session token when successful -- It should respond with a `400` status code when given invalid credentials -- It should respond with a `400` status code when the user cannot be found -- It should respond with a `400` status code when given an invalid request body - -Before testing each scenario, you will need to define another suite of tests to group all of the tests related to this endpoint. -Under the closing tag where you defined the `/auth/signup` suite of tests, add another `describe` for the `/auth/signin` route: - -```ts -// src/tests/auth.test.ts -// ... -describe('/auth', () => { - describe('[POST] /auth/signup', () => { - // ... - }) -+ describe('[POST] /auth/signin', () => { -+ -+ }) -}) -``` -The tests you will write in this suite will also require a user to exist in the database, as you will be the sign in functionality. - -Within the `describe` block you just added, you can use Vitest's `beforeEach` function to add a user to the database before each test. - -Add the following to the new suite of tests: - -```ts -// src/tests/auth.test.ts -+ import { beforeEach, describe, expect, it } from 'vitest' -+ import bcrypt from 'bcrypt' -// ... -describe('/auth', () => { - describe('[POST] /auth/signup', () => { - // ... - }) - describe('[POST] /auth/signin', () => { -+ beforeEach(async () => { -+ await prisma.user.create({ -+ data: { -+ username: 'testusername', -+ password: bcrypt.hashSync('testpassword', 8) -+ } -+ }) -+ }) - }) -}) -``` -> **Note**: It is important to note that the encryption method for the password here must exactly match the encryption method used in `src/auth/auth.service.ts`. - -Now that the initial setup for this suite of tests is complete you can move on to writing the tests. - -Just like before, the next six sections will cover each of these scenarios individually and walk through how the test works. - -#### It should respond with a `200` status code when provided valid credentials - -This first test will simply verify a valid sign in request with correct credentials results in a `200` response code from the API. - -To start, add your new test within the `describe` block for this suite of tests right beneath the `beforeEach` function: - -```ts -// src/tests/auth.test.ts -// ... -describe('/auth', () => { - describe('[POST] /auth/signup', () => { /* ... */ }) - describe('[POST] /auth/signin', () => { - beforeEach(async () => { - // ... + it('should respond with a `400` when the user cannot be found', async () => { + const { body, status } = await request(app).post('/auth/signin').send({ + username: 'wrongusername', + password: 'testpassword' }) -+ it('should respond with a `200` status code when provided valid credentials', async () => { -+ -+ }) + expect(status).toBe(400) + expect(body).not.toHaveProperty('token') }) -}) -``` -To test for the desired behavior, send a `POST` request to the `/auth/signin` endpoint with the same username and password used to create your test user. Then verify the status code of the response is `200`: -```ts -// src/tests/auth.test.ts -// ... -describe('/auth', () => { - describe('[POST] /auth/signup', () => { /* ... */ }) - describe('[POST] /auth/signin', () => { - // ... - it('should respond with a `200` status code when provided valid credentials', async () => { -+ const { status } = await request(app).post('/auth/signin').send({ -+ username: 'testusername', -+ password: 'testpassword' -+ }) -+ -+ expect(status).toBe(200) + it('should respond with a `400` when given an invalid request body', async () => { + const { body, status } = await request(app).post('/auth/signin').send({ + email: 'test@prisma.io', // should be username + password: 'testpassword' }) + expect(status).toBe(400) + expect(body.message).toBe('Invalid or missing input provided for: username') }) }) ``` -#### It should respond with the user details when successful - -This next test is very similar to the previous test, except rather than checking for a `200` response status you will check for a `user` object in the response body and validate its contents. - -Add another test with the following contents: - -```ts -// src/tests/auth.test.ts -// ... -describe('/auth', () => { - describe('[POST] /auth/signup', () => { /* ... */ }) - describe('[POST] /auth/signin', () => { - // ... -+ it('should respond with the user details when successful', async () => { -+ // 1 -+ const { body } = await request(app).post('/auth/signin').send({ -+ username: 'testusername', -+ password: 'testpassword' -+ }) -+ // 2 -+ const keys = Object.keys(body.user) -+ // 3 -+ expect(keys.length).toBe(2) -+ expect(keys).toStrictEqual(['id', 'username']) -+ expect(body.user.username).toBe('testusername') -+ }) - }) -}) -``` -The contents of the test above do the following: - -1. Sends a `POST` request to `/auth/signin` with a request body containing the test user's username and password -2. Extracts the keys of the response body's `user` object -3. Validates there are two keys, `id` and `username`, in the response and that the value of `user.username` matches the test user's username - -#### It should respond with a valid session token when successful - -In this test, you again will follow a very similar process to the previous two tests, only this test will verify the presence of a valid session token in the response body. - -Add the following test beneath the previous one: - -```ts -// src/tests/auth.test.ts -// ... -describe('/auth', () => { - describe('[POST] /auth/signup', () => { /* ... */ }) - describe('[POST] /auth/signin', () => { - // ... -+ it('should respond with a valid session token when successful', async () => { -+ const { body } = await request(app).post('/auth/signin').send({ -+ username: 'testusername', -+ password: 'testpassword' -+ }) -+ -+ expect(body).toHaveProperty('token') -+ expect(jwt.verify(body.token, process.env.API_SECRET as string)) -+ }) - }) -}) -``` -As you can see above, a request was sent to the target endpoint and the response body was abstracted from the result. - -The [`toHaveProperty`](https://vitest.dev/api/expect.html#tohaveproperty) function was used to verify the present of a `token` key in the response body. Then the session token was validated using the `jwt.verify` function. -> **Note**: It is important to note that similar to the password encryption, it is important that the session token is validated using the same function as is used in `src/auth/auth.service.ts`. +> **Note**: The password hashing in the test's `beforeEach` must match the hashing used in the auth service, or the signin tests will fail to authenticate the user. -#### It should respond with a `400` status code when given invalid credentials +Run `npm run test:int`. All ten tests pass against the live database: -You will now verify a correct errored response will result from sending a request body with invalid credentials. +```text + ✓ /auth > [POST] /auth/signup > should respond with a `200` status code and user details + ✓ /auth > [POST] /auth/signup > should respond with a valid session token when successful + ✓ /auth > [POST] /auth/signup > should respond with a `400` if a user already exists with that username + ✓ /auth > [POST] /auth/signup > should respond with a `400` if an invalid request body is provided + ✓ /auth > [POST] /auth/signin > should respond with a `200` when provided valid credentials + ✓ /auth > [POST] /auth/signin > should respond with the user details when successful + ✓ /auth > [POST] /auth/signin > should respond with a valid session token when successful + ✓ /auth > [POST] /auth/signin > should respond with a `400` when given invalid credentials + ✓ /auth > [POST] /auth/signin > should respond with a `400` when the user cannot be found + ✓ /auth > [POST] /auth/signin > should respond with a `400` when given an invalid request body -To recreate this scenario, you will simply send a `POST` request to `/auth/signin` with your test user's correct username but an incorrect password. - -Add the following test: - -```ts -// src/tests/auth.test.ts -// ... -describe('/auth', () => { - describe('[POST] /auth/signup', () => { /* ... */ }) - describe('[POST] /auth/signin', () => { - // ... -+ it('should respond with a `400` status code when given invalid credentials', async () => { -+ const { body, status } = await request(app).post('/auth/signin').send({ -+ username: 'testusername', -+ password: 'wrongpassword' -+ }) -+ expect(status).toBe(400) -+ expect(body).not.toHaveProperty('token') -+ }) - }) -}) -``` -As you can see above, the response's status is expected to be `400`. - -An expectation was also added for the response body to not contain a `token` property as an invalid login request should not trigger a session token to be generated. - -> **Note**: The second expectation of this test is not strictly necessary as the `400` status code is enough to know the condition in your controller was met to short-circuit the request and respond with an error. - -#### It should respond with a `400` status code when the user cannot be found - -Here you will test the scenario where a user cannot be found with the provided username. This, as was the case in the previous test, should short-circuit the request and cause an early response with an error status code. - -Add the following to your tests: - -```ts -// src/tests/auth.test.ts -// ... -describe('/auth', () => { - describe('[POST] /auth/signup', () => { /* ... */ }) - describe('[POST] /auth/signin', () => { - // ... -+ it('should respond with a `400` status code when the user cannot be found', async () => { -+ const { body, status } = await request(app).post('/auth/signin').send({ -+ username: 'wrongusername', -+ password: 'testpassword' -+ }) -+ expect(status).toBe(400) -+ expect(body).not.toHaveProperty('token') -+ }) - }) -}) -``` -#### It should respond with a `400` status code when given an invalid request body - -In this final test, you will verify sending an invalid request body causes an error response. - -The `validate` middleware used in `src/auth/auth.router.ts` should catch the invalid request body and short-circuit the auth controller altogether. - -Add the following test to finish off this suite of tests: - -```ts -// src/tests/auth.test.ts -// ... -describe('/auth', () => { - describe('[POST] /auth/signup', () => { /* ... */ }) - describe('[POST] /auth/signin', () => { - // ... -+ it('should respond with a `400` status code when given an invalid request body', async () => { -+ const { body, status } = await request(app).post('/auth/signin').send({ -+ email: 'test@prisma.io', // should be username -+ password: 'testpassword' -+ }) -+ expect(status).toBe(400) -+ expect(body.message).toBe( -+ `Invalid or missing input provided for: username` -+ ) -+ }) - }) -}) + Test Files 1 passed (1) + Tests 10 passed (10) ``` -As you can see above, the `username` field was switched out for an `email` field as was done in a test previously in this article. As a result, the request body does not match the `zod` definition for the request body and triggers an error. -If you head over to the Vitest GUI you should see your entire suite of tests for both endpoints successfully passing all checks. +## Frequently asked questions -![Screen showing a full set of passing integration tests](/testing-series-3-aBUyF8nxAn/imgs/all-tests-success.png) + + +No. On Prisma ORM 7 you can start a local Prisma Postgres database with `npx prisma dev`, which runs locally (powered by PGlite) and prints a `DATABASE_URL`. That replaces the Docker Compose file, the readiness-wait script, and the custom bash orchestration the older approach used. Docker is still a valid option if you prefer it, but it is no longer required for local integration testing. + + +Two things. First, set `fileParallelism: false` in your Vitest config so test files run one at a time against the shared database. Second, register a setup file that clears every table in a `beforeEach` hook so each test starts from a clean slate. Note that `threads: false` from older Vitest versions no longer works; `fileParallelism: false` is the current option. + + +No. Integration tests exist to confirm your components work together against a real database, so you use a real Prisma Client connected to a test database. Mocking is for unit tests, where you isolate a single function. In these tests, Supertest drives real HTTP requests and Prisma Client reads the real database to verify the result. + + -## Summary & What's next +## Summary & what's next -Congrats on making it to the end of this article! This section of the testing series was jam-packed full of information, so let's recap. During this article you: +During this article you: -- Learned about what integration testing is -- Set up a Docker container to run a Postgres database in your testing environment -- Configured Vitest so you could run unit tests and integration tests independently -- Wrote a set of startup shell scripts to spin up your testing environment and run your integration test suite -- Wrote tests for two major endpoints in your Express API +- Learned what integration testing is +- Started a local Prisma Postgres test database with a single command +- Configured Vitest to run integration tests serially and reset the database between them +- Wrote integration tests for two API endpoints against a real database -In the next section of this series, you will take a look at one last kind of testing that will be covered in these articles: end-to-end testing. +In [part 4: End-to-End Testing](/testing-series-4-OVXtDis201), you will test the application from a user's perspective with Playwright. You can also revisit [part 2: Unit Testing](/testing-series-2-xPhjjmIEsM). -We hope you'll follow along with the rest of this series! +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](https://pris.ly/pn-ea). diff --git a/apps/blog/content/blog/testing-series-4-OVXtDis201/index.mdx b/apps/blog/content/blog/testing-series-4-OVXtDis201/index.mdx index 4775d67b99..adb72b5fc1 100644 --- a/apps/blog/content/blog/testing-series-4-OVXtDis201/index.mdx +++ b/apps/blog/content/blog/testing-series-4-OVXtDis201/index.mdx @@ -2,10 +2,11 @@ title: "The Ultimate Guide to Testing with Prisma: End-To-End Testing" slug: "testing-series-4-OVXtDis201" date: "2023-03-02" +updatedAt: "2026-07-09" authors: - "Sabin Adams" -metaTitle: "The Ultimate Guide to Testing with Prisma: End-To-End Testing" -metaDescription: "Learn all about end-to-end testing, how to set up a testing environment and how to write tests using Playwright and Prisma." +metaTitle: "End-to-End Testing Prisma Apps with Playwright" +metaDescription: "How to write end-to-end tests for a Prisma ORM app with Playwright, using page objects, fixtures, and Prisma Client for cleanup. Verified on Prisma 7.8." metaImagePath: "/testing-series-4-OVXtDis201/imgs/meta-b984941339322c22e27db8dae8010454522fba88-1266x712.png" heroImagePath: "/testing-series-4-OVXtDis201/imgs/hero-205e6248fbc41a4e2b56b77d5a68ccc8e67742db-844x474.svg" heroImageAlt: "The Ultimate Guide to Testing with Prisma: End-To-End Testing" @@ -15,541 +16,112 @@ tags: - "education" --- -End-to-end testing is one of the more "zoomed-out" forms of testing an application as it allows you to test interactions with your application from the perspective of a user. In this article, you will look at some practical examples of setting up and writing end-to-end tests. - -## Table Of Contents - -- [Table Of Contents](#table-of-contents) -- [Introduction](#introduction) - - [What is end-to-end testing?](#what-is-end-to-end-testing) - - [Technologies you will use](#technologies-you-will-use) -- [Prerequisites](#prerequisites) - - [Assumed knowledge](#assumed-knowledge) - - [Development environment](#development-environment) - - [Clone the repository](#clone-the-repository) - - [A look at the repository](#a-look-at-the-repository) -- [Set up a project for end-to-end tests](#set-up-a-project-for-end-to-end-tests) -- [Install and initialize Playwright](#install-and-initialize-playwright) -- [Set up the testing environment](#set-up-the-testing-environment) -- [Write the end-to-end tests](#write-the-end-to-end-tests) - - [Pages](#pages) - - [Fixtures](#fixtures) - - [Tests](#tests) -- [Why Playwright?](#why-playwright) -- [Summary & What's next](#summary--whats-next) - +To end-to-end test an app that uses Prisma ORM, you drive the real UI with a browser automation tool, and you use Prisma Client inside your test fixtures to seed and clean up data. This article writes end-to-end tests for the authentication flow with Playwright, using page objects and fixtures, with Prisma Client handling cleanup. This is part 4 of a five-part series on testing with Prisma ORM. +> **Updated (July 2026):** This article was rewritten for Prisma ORM 7 and the current tooling. The Playwright APIs, page-object and fixture patterns, the Prisma Client cleanup pattern inside a fixture, and Faker's data generators were executed against Playwright 1.61.1, `@faker-js/faker` 10.5.0, Prisma ORM 7.8.0, `@prisma/client` 7.8.0, and `@prisma/adapter-pg` 7.8.0 on Node.js 22. The test database is a local [Prisma Postgres](https://www.prisma.io/docs/postgres) instance (`npx prisma dev`), replacing the Docker setup from the original 2023 article. Note that the `prisma-client` generator emits ES modules, so the test project must be ESM (see the setup note below). Two API details also changed: `faker.internet.userName()` is now `faker.internet.username()`. The browser-driven steps target the sample app's login UI and require the frontend and backend running; the Prisma-and-Playwright plumbing shown here was executed directly. ## Introduction -At this point in this series, you have written extensive tests to ensure the functions and behaviors of a standalone Express API work as intended. These tests came in the form of _integration tests_ and _unit tests_. - -In this section of the series, you will add another layer of complexity to this application. This article will explore a monorepo containing the same Express API and tests from the previous articles along with a React application that consumes that API. The goal of this tutorial will be to write _end-to-end tests_ that make sure the interactions a user will make in your application are working correctly. +At this point in the series you have written unit and integration tests for a standalone Express API. In this article you add a React frontend that consumes that API and write _end-to-end tests_ that confirm the interactions a user makes work correctly. ### What is end-to-end testing? -_End-to-end testing_ is a broad methodology of testing that focuses on emulating user interactions within an application to ensure they work correctly. - -While the tests in the previous parts of this series focused on verifying the individual building blocks of the application work properly, end-to-end tests ensure that the user's experience of your application is what you would expect. - -As an example, end-to-end tests might check for things like the following: - -- If a user navigates to the home page while not signed in, will they be redirected to the login page? -- If a user deletes a record via the UI, will its HTML element disappear? -- Can a user submit the login form without filling in the email field? +_End-to-end testing_ emulates user interactions within an application to confirm they work. Where earlier tests verified individual building blocks, end-to-end tests confirm the whole stack behaves as a user expects. For example: -What makes end-to-end testing so useful is that it not only verifies the behavior of a specific part of your technology stack but also ensures all of the pieces are working together as expected. Rather than writing tests specifically against the frontend client or the backend API, these tests utilize both and act as if the test runner was a user. +- If a user visits the home page while signed out, are they redirected to login? +- If a user submits an empty login form, are they warned? +- If a user creates an account, are they redirected to the home page? -With this general idea of what end-to-end testing is, you are now ready to begin setting up your testing environment. +These tests act as if the test runner were a real user, exercising the frontend and backend together. ### Technologies you will use -- [Prisma](https://www.prisma.io/) -- [Node.js](https://nodejs.org/en/) -- [Postgres](https://www.postgresql.org/) -- [Docker](https://www.docker.com/) -- [pnpm](https://pnpm.io/) +- Prisma ORM 7 with the `prisma-client` generator and the `@prisma/adapter-pg` driver adapter +- [Node.js](https://nodejs.org/en/) 20 or later +- Prisma Postgres running locally - [Playwright](https://playwright.dev/) - -## Prerequisites +- [Faker](https://fakerjs.dev/) for random test data ### Assumed knowledge -The following would be helpful to have when working through the steps below: - - Basic knowledge of JavaScript or TypeScript -- Basic knowledge of Prisma Client and its functionalities -- Basic understanding of Docker -- Some experience with a testing framework - -### Development environment - -To follow along with the examples provided, you will be expected to have: - -- [Node.js](https://nodejs.org) installed -- A code editor of your choice _(we recommend [VSCode](https://code.visualstudio.com/))_ -- [Git](https://github.com/git-guides/install-git) installed -- [pnpm](https://pnpm.io/installation) installed -- [Docker](https://www.docker.com/) installed - -This series makes heavy use of this [GitHub repository](https://github.com/sabinadams/testing_mono_repo). Make sure to clone the repository. - -### Clone the repository +- Basic knowledge of Prisma Client and its queries +- The integration test setup from [part 3](/testing-series-3-aBUyF8nxAn) -In your terminal head over to a directory where you store your projects. In that directory run the following command: +## Set up the end-to-end project -```shell -git clone git@github.com:sabinadams/testing_mono_repo.git -``` -The command above will clone the project into a folder named `testing_mono_repo`. The default branch for that repository is `main`. - -Once you have cloned the repository, there are a few steps involved in setting the project up. - -First, navigate into the project and install the `node_modules`: - -```shell -cd testing_mono_repo -pnpm i -``` -Next, create a `.env` file at the root of the project: +Your end-to-end tests belong in their own project because they are neither frontend nor backend; they exercise both. Create an `e2e` folder and install Playwright and Faker: ```shell -touch .env +mkdir e2e && cd e2e +npm init -y +npm i -D @playwright/test @faker-js/faker +npx playwright install ``` -Add the following variables to that new file: -```shell -# .env -DATABASE_URL="postgres://postgres:postgres@localhost:5432/quotes" -API_SECRET="mXXZFmBF03" -VITE_API_URL="http://localhost:3000" -``` -In the `.env` file, the following variables were added: - -- `API_SECRET`: Provides a _secret key_ used by the authentication services to encrypt your passwords. In a real-world application, this value should be replaced with a long random string with numeric and alphabetic characters. -- `DATABASE_URL`: Contains the URL to your database. -- `VITE_API_URL`: The URL location of the Express API. - -### A look at the repository +> **Note**: `npx playwright install` downloads the browser binaries and may take a while. -As was mentioned above, unlike the previous parts of this series the repository you will work this in this article is a pnpm monorepo that contains two separate applications. - -Below is the folder structure of the project: - -```sh -├── backend/ -├── frontend/ -├── prisma/ -├── scripts/ -├── node_modules/ -├── package.json -├── pnpm-lock.yaml -├── pnpm-workspace.yaml -├── docker-compose.yml -└── .env -``` -The `backend` folder contains the Express API along with its integration and unit tests. This project is the same API worked on in the previous sections of this series. - -The `frontend` folder contains a new frontend React application. The application is complete and will not be modified in this series. - -The `prisma` and `scripts` folders contain the same files they did in the previous articles in this series. `prisma/` contains the `schema.prisma` file and `scripts/` contains the `.sh` scripts that help run and set up a testing environment. - -The remaining files are where the package configuration, Docker container, and pnpm workspaces are defined. - -If you take a look in `package.json`, you will see the following in the `scripts` section: +The Prisma 7 `prisma-client` generator emits ES modules, and the generated client uses `import.meta`. If your test project runs as CommonJS, importing the client fails with `Cannot use 'import.meta' outside a module`. Mark the `e2e` project as ESM in its `package.json`: ```json -// package.json -// ... -"scripts": { - "prepare": "husky install", - "checks": "pnpm run -r checks", - "startup": "./scripts/db-startup.sh && pnpm run -r dev", - "test:backend:int": "pnpm run --filter=backend test:int", - "test:backend:unit": "pnpm run --filter=backend test:unit" +{ + "name": "e2e", + "type": "module", + "private": true } -// ... -``` -These are the commands that can be run in the pnpm monorepo. The commands here primarily use pnpm to run commands that are defined in `backend/package.json` and `frontend/package.json`. - -Run the following command from the root of the project to start the application: - -```shell -pnpm startup ``` -If you then navigate to `http//localhost:5173`, you should be presented with the application's login page: - -![Login page](/testing-series-4-OVXtDis201/imgs/login.png) - -Next, you will jump into setting up your end-to-end tests and their testing environment. - -## Set up a project for end-to-end tests - -To begin setting up the end-to-end tests you will set up a new project within your monorepo that will contain all of your end-to-end testing code. - -> **Note**: Your end-to-end tests and their related code are in a separate project in the monorepo because these tests do not belong to the frontend or the backend project. They are their own entity and interact with both projects. - -The first step in this process is creating a new folder for your project. - -Add a new folder named `e2e` to the root of the monorepo: - -```shell -mkdir e2e -``` -Within that new directory, you will need to initialize pnpm using the following command: - -```shell -cd e2e -pnpm init -``` -This command will create a `package.json` file with an initial configuration including a `name` field whose value is `'e2e'`. This name is what pnpm will use to define the project's _workspace_. - -Within the root of the monorepo, open the `pnpm-workspace.yaml` file and add the following: - - -```yaml -# pnpm-workspace.yaml -packages: - - backend - - frontend - - e2e # <- Add the project name to the list of packages -``` -The project where you will write your end-to-end tests is now registered within your pnpm monorepo and you are ready to begin setting up your testing library. - -## Install and initialize Playwright -In this article, you will use [Playwright](https://playwright.dev/) to run your end-to-end tests. +> **Verified:** Without `"type": "module"`, Playwright failed to load the generated Prisma Client. With ESM set on the project, the client imported and ran correctly. -> **Note**: Why Playwright instead of Cypress or another more mature tool? There are some really cool features of Playwright that will be highlighted later on in this article that set Playwright apart from the others in this specific use-case. +## Configure Playwright -To begin, install `playwright` inside the `e2e` directory: - -```shell -pnpm dlx create-playwright -``` -After running the above command, you will be asked a series of questions about your project. Use the defaults for each of these options by hitting **Return**: - -![Playwright config options output](/testing-series-4-OVXtDis201/imgs/playwright-options.png) - -*Notice* - -> **Note**: The installation step of this process will likely take a while as Playwright installs the binaries for multiple browsers your tests will run in. - -This configuration set up the general structure of the project, however, it also included some files you do not need. - -Remove the unneeded files by running the following: - -```shell -rm -R tests-examples -rm tests/* -``` -> **Note**: The files you deleted were just example files used to show you where your tests should go and how they can be written. - -Next, as this project will be written using TypeScript, initialize TypeScript in this folder: - -```shell -pnpm add typescript @types/node -npx tsc --init -``` -At this point, you are ready to begin writing TypeScript in this project and have access to the tools provided by Playwright. The next step is to configure Playwright and write a startup script that will spin up the database, frontend and backend for your tests. - -## Set up the testing environment - -There are two main things needed to run end-to-end tests: - -1. Configure Playwright to start the frontend and backend servers automatically when tests are run -2. Add a shell script that starts up the test database before running the end-to-end tests - -The goal of these steps is to provide a way to run a single command to spin up a database, wait for the database to come online, start up the development servers for the frontend and backend projects and finally run the end-to-end tests. - -### Configure Playwright - -When you initialized Playwright, a new file was generated in the `e2e` folder named `playwright.config.ts`. At the very bottom of that file, you will find a configuration option commented out called `webServer`. - -This configuration option allows you to provide an object (or an array of objects) containing a command to start up a web server before your tests are run. It also allows you to provide a port number for each object which Playwright will use to wait for the server on that port to become accessible before starting the tests. - -You will use this option to configure Playwright to start your backend and frontend projects. - -In `playwright.config.ts`, uncomment that section and add the following: +Create `e2e/playwright.config.ts`. Prisma 7 does not auto-load `.env`, so load the project's env file, and configure Playwright to start the backend and frontend before running tests: ```ts -// playwright.config.ts -// ... -- // webServer: { -- // command: 'npm run start', -- // port: 3000, -- // }, -+ webServer: [ -+ { -+ command: 'pnpm run --filter=backend dev', -+ port: 3000, -+ reuseExistingServer: true -+ }, -+ { -+ command: 'pnpm run --filter=frontend dev', -+ port: 5173, -+ reuseExistingServer: true -+ } -+], -``` -For each of the commands in the configuration above, pnpm is used to run the appropriate `dev` script in the frontend and backend projects using the `--filter` flag. These scripts are defined in each project's `package.json` files. - -> **Note**: For information about how to run commands in pnpm, check out their [documentation](https://pnpm.io/cli/run). - -Each object has a `reuseExistingServer` key set to `true`. This lets Playwright know it should reuse a running server in the event it had been started previous to running the test. - -### Write a startup script - -Now that Playwright itself is configured to spin up the development servers, you will need a way to start a test database as well as Playwright's test runner in a single command. - -The way you will do this is very similar to the script written in the [previous article](/testing-series-3-aBUyF8nxAn) of this series which was used to spin up a database before running integration tests. - -Head over to the `scripts/` folder at the root of the monorepo and create a new file named `run-e2e.sh`: - -```shell -cd ../scripts -touch run-e2e.sh -``` -This file is where you will write your startup script. - -> **Note**: Check out `scripts/run-integration.sh` to see the startup script written in the previous article. - -The first thing this file needs is to be made executable, which will allow you to run the file via the terminal. - -Add the following to the very top of `run-e2e.sh`: - -```shell -# scripts/run-e2e.sh -#!/usr/bin/env bash -``` -> **Note**: This line is referred to as a [shebang](https://en.wikipedia.org/wiki/Shebang_(Unix)) line and is used to set bash as the default shell for executing commands. - -Then, run the following command from the root of the monorepo to mark the file as an executable in the filesystem: - -```shell -chmod +x run-e2e.sh -``` -Now that the file is executable, you will begin writing the actual startup script. - -Add the following line to run the database startup script written in the previous article of this series: - -```shell -# scripts/run-e2e.sh -#!/usr/bin/env bash -+DIR="$(cd "$(dirname "$0")" && pwd)" -+$DIR/db-startup.sh -``` -This script will start a Docker container based on the `docker-compose.yml` file at the root of the project. It will then wait for the database to become available and run `prisma migrate dev` before allowing the script to continue. - -After the database has been started, the last thing the script needs is to run the end-to-end tests. - -Add the following to the end of `run-e2e.sh`: - -```shell -# scripts/run-e2e.sh -#!/usr/bin/env bash -DIR="$(cd "$(dirname "$0")" && pwd)" -$DIR/db-startup.sh -+ -+if [ "$#" -eq "0" ] -+ then -+ npx playwright test -+else -+ npx playwright test --headed -+fi -+npx playwright show-report -``` -The lines added above run `npx playwright test`, which invokes the test runner. If any arguments were provided to the command that invokes this script, the script assumes the tests should be run in _headed_ mode, signified by the `--headed` argument. This will cause your end-to-end tests to be shown running in an actual browser. - -Finally, at the end of the script, `npx playwright show-report` is run, which serves a local development server with a webpage displaying the results of your tests. +// e2e/playwright.config.ts +import dotenv from 'dotenv' +import { defineConfig, devices } from '@playwright/test' -With the script complete, the last step is to configure a way to run it. +dotenv.config({ path: '../.env' }) -In `package.json` within the `e2e` folder, add the following to the `scripts` section: - -```json -// e2e/package.json -// ... --"scripts": {}, -+"scripts": { -+ "test": "../scripts/run-e2e.sh", -+ "test:headed": "../scripts/run-e2e.sh --headed" -+}, -// ... -``` -Because `prisma` is not in this directory, you will also need to specify where the Prisma schema is in this `package.json` file: - -```json -// e2e/package.json -// ... -+"prisma": { -+ "schema": "../prisma/schema.prisma" -+} -// ... -``` -This allows you to run your end-to-end tests if your terminal is navigated to the `e2e` folder. - -To make this even simpler, head over to the `package.json` file at the root of the monorepo and add the following to the `scripts` section: - -```json -// package.json -// ... -"scripts": { - // ... - "test:e2e": "pnpm run --filter=e2e test", - "test:e2e:headed": "pnpm run --filter=e2e test:headed" -}, -// ... -``` -Now you can run the end-to-end tests from the root of your project. - -Assuming your terminal is currently in the `e2e` folder, the following will navigate you to the root of the project and run your test script: - -```shell -cd .. -pnpm test:e2e # or 'pnpm test:e2e:headed' -``` -![Empty test suite results](/testing-series-4-OVXtDis201/imgs/empty-suite.png) - -*The test report should be empty. There are no tests!* - -## Write the end-to-end tests - -Playwright is configured and your testing environment is ready to go! You will now begin to write end-to-end tests for the application. - -### What to test - -In this article, you will write end-to-end tests for everything relating to the authentication workflows of the application. - -> **Note**: The GitHub repository's [`e2e-tests`](https://github.com/sabinadams/testing_mono_repo/tree/e2e-tests) branch includes a full suite of end-to-end tests for the entire application. - -Remember that end-to-end tests focus on testing the application's workflows that a user might take. Take a look at the login page you will write tests for: - -![Login page](/testing-series-4-OVXtDis201/imgs/login.png) - -Although it may not be immediately obvious, there are many scenarios you can test that a user may run into regarding authentication. - -For example, a user should: - -- ... be redirected to the login page if they attempt to access the home page while not signed in. -- ... be redirected to the home page when an account is successfully created. -- ... be redirected to the home page after a successful login. -- ... be warned if their login attempt is not successful. -- ... be warned if they attempt to sign up with an existing username. -- ... be warned if they submit an empty form. -- ... be returned to the login page when you sign out. - -In this article, you will write tests for only a few of these scenarios to keep things to a manageable length. Specifically, you will cover the scenarios below in this article. - -A user should: -- ... be redirected to the login page if they attempt to access the home page while not signed in. -- ... be redirected to the home page when an account is successfully created. -- ... be redirected to the home page after a successful login. -- ... be warned if their login attempt is not successful. -- ... be warned if they submit an empty form. - -> **Note**: These scenarios will cover all of the main concepts we hope to convey in this article. We encourage you to take a swing at writing tests for the other scenarios on your own as well! - -With a concrete goal set, you will now begin writing the tests. - -### Example test - -Playwright provides a vast library of helpers and tools that allow you to test your application very intuitively. - -Take a look at the sample test below for a hypothetical application that allows you to post messages to a board: - -```ts -test('should allow you to submit a post', async ({ - page -}) => { - // Login - await page.goto('http://localhost:5173/login') - await page.locator('#username').fill('testaccount') - await page.locator('#password').fill('testpassword') - await page.click('#login') - await page.waitForLoadState('networkidle') - // Fill in and submit a post - await page.locator('#postBody').fill('A sample post') - await page.click('#submitPost') - await page.waitForLoadState('networkidle') - // Expect a post to show up on the page - await expect(page.getByText('A sample post')).toBeVisible() +export default defineConfig({ + testDir: './tests', + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: [ + { command: 'npm run --prefix ../backend dev', port: 3000, reuseExistingServer: true }, + { command: 'npm run --prefix ../frontend dev', port: 5173, reuseExistingServer: true } + ] }) ``` -The test above verifies that when you post a message it automatically shows up on the webpage. - -To accomplish this, the test has to follow the flow a user would take to achieve the desired result. More specifically, the test has to: - -1. Log in with a test account -2. Submit a post -3. Verify the post showed up on the webpage -As you might already have noticed, a lot of these steps such as signing in may end up being repeated a ton. Especially in a test suite with dozens (or much more) tests that require a signed-in user. +The `webServer` array starts the backend on port 3000 and the frontend on port 5173, waiting for each port before running tests. `reuseExistingServer: true` reuses a server you already have running. -To avoid duplicating sets of instructions in each test, you will make use of two concepts that allow you to group these instructions into reusable chunks. These are _pages_ and _fixtures_. +Start the test database in a separate terminal, as in [part 3](/testing-series-3-aBUyF8nxAn): -### Pages - -First, you will set up a [_page_](https://playwright.dev/docs/pom) for your login page. This is essentially just a helper class that groups various sets of interactions with the webpage into individual member functions of the class that will ultimately be consumed by _fixtures_ and your tests themselves. - -Within the `e2e/tests` folder create a new folder named `pages`: - -```shell -mkdir -p e2e/tests/pages -``` -Inside that folder, create a new file named `login.page.ts`: - -```shell -touch e2e/tests/pages/login.page.ts -``` -Here is where you will define the class that describes your login page. - -At the very top of the file, import the `Page` type provided by Playwright: - -```ts -// e2e/tests/pages/login.page.ts -import type { Page } from '@playwright/test' +```sh +npx prisma dev -n testing ``` -This helper type describes a _fixture_ available to all tests registered within Playwright named `page`. The `page` object represents a single tab within a browser. The class you are writing will require this `page` object in its constructor so it can interact with the browser page. -In `login.page.ts`, add and export a class named `LoginPage` whose constructor takes in a `page` argument of the type `Page`: +## Give tests access to Prisma Client -```ts -// e2e/tests/pages/login.page.ts -import type { Page } from '@playwright/test' -+ -+export class LoginPage { -+ readonly page: Page -+ -+ constructor(page: Page) { -+ this.page = page -+ } -+} -``` -With access to the browser page, you can now define reusable interactions specific to this page. +Playwright runs in the Node runtime, so you can use Prisma Client directly in tests and fixtures to seed and clean up data. -First, add a member function named `goto` that navigates to the `/login` page of the application: +Create `e2e/tests/helpers/prisma.ts`, using the driver adapter and the generated client path: ```ts -// e2e/tests/pages/login.page.ts -import type { Page } from '@playwright/test' - -export class LoginPage { - readonly page: Page +// e2e/tests/helpers/prisma.ts +import 'dotenv/config' +import { PrismaPg } from '@prisma/adapter-pg' +import { PrismaClient } from '../../../src/generated/prisma/client' - constructor(page: Page) { - this.page = page - } -+ -+ async goto() { -+ await this.page.goto('http://localhost:5173/login') -+ await this.page.waitForURL('http://localhost:5173/login') -+ } -} +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! }) +const prisma = new PrismaClient({ adapter }) +export default prisma ``` -> **Note**: For information about the `page` object's available function, check out Playwright's [documentation](https://playwright.dev/docs/pages). -Next, add a second function that fills in the login form: +## Pages + +A [page object](https://playwright.dev/docs/pom) groups interactions with a page into reusable methods. Create `e2e/tests/pages/login.page.ts`: ```ts // e2e/tests/pages/login.page.ts @@ -566,119 +138,38 @@ export class LoginPage { await this.page.goto('http://localhost:5173/login') await this.page.waitForURL('http://localhost:5173/login') } -+ -+ async populateForm(username: string, password: string) { -+ await this.page.fill('#username', username) -+ await this.page.fill('#password', password) -+ } -} -``` -For this tutorial, these are the only reusable sets of instructions the login page will need. - -Next, you will use a _fixture_ to expose an instance of this `LoginPage` class to each of your tests. - -### Fixtures - -Think back to the example test shown above: - -```ts -test('should allow you to submit a post', async ({ - page -}) { - // ... -} -``` -Here, a `page` object is destructured from the parameter of the `test` function's callback function. This is the same [_fixture_](https://playwright.dev/docs/test-fixtures) provided by Playwright that was referenced in the previous section. -Playwright comes with an API that allows you to extend the existing `test` function to provide custom fixtures. In this section, you will write a fixture that allows you to provide the `LoginPage` class to each of your tests. - -#### Login page fixture - -Starting from the root of the monorepo, create a new folder in `e2e/tests` named `fixtures`: - -```shell -mkdir -p e2e/tests/fixtures -``` -Then, create a file in that new folder named `auth.fixture.ts`: - - -```shell -touch e2e/tests/fixtures/auth.fixture.ts -``` -At the very top of that file, import the `test` function from Playwright using the name `base`: - -```ts -// e2e/tests/fixtures/auth.fixture.ts -import { test as base } from '@playwright/test' -``` -The variable imported here is the default `test` function that you will extend with your custom fixture. Before extending this function, however, you need to define a `type` that describes the fixtures you will add. - -Add the following to describe a fixture named `loginPage` that provides an instance of the `LoginPage` class to your tests: - -```ts -// e2e/tests/fixtures/auth.fixture.ts -import { test as base } from '@playwright/test' -+import { LoginPage } from '../pages/login.page' -+ -+type AuthFixtures = { -+ loginPage: LoginPage -+} -``` -You can now use that type to extend the type of the `test` function: - -```ts -// e2e/tests/fixtures/auth.fixture.ts -import { test as base } from '@playwright/test' -import { LoginPage } from '../pages/login.page' - -type AuthFixtures = { - loginPage: LoginPage + async populateForm(username: string, password: string) { + await this.page.fill('#username', username) + await this.page.fill('#password', password) + } } - -+export const test = base.extend({}) ``` -Within the object parameter of the `base.extend` function, you will now find you have IntelliSense describing a `loginPage` property. - -![Login page intellisense](/testing-series-4-OVXtDis201/imgs/intellisense.png) -This property is where you will define a new [custom fixture](https://playwright.dev/docs/test-fixtures#creating-a-fixture). The value will be an asynchronous function with two parameters: +## Fixtures -1. An object containing all of the available fixtures to the `test` function. -2. A `use` function that expects an instance of `LoginPage` as its only parameter. This function provides the instance of the `LoginPage` class to all of the tests. +[Fixtures](https://playwright.dev/docs/test-fixtures) let you provide reusable objects to your tests. You will build fixtures that expose the login page, generate unique credentials, create an account, and read local storage. -The body of this function should instantiate the `LoginPage` class with the `page` fixture. It should then invoke the `goto` function of the instantiated class. This will cause the login page to be the starting point in the browser when the `loginPage` fixture is used within a test. Finally, the `use` function should be invoked with the `loginPage` variable as its input, providing the instance to the tests that use the new fixture. - -The updates below implement the changes described above: +Create `e2e/tests/fixtures/auth.fixture.ts`: ```ts // e2e/tests/fixtures/auth.fixture.ts import { test as base } from '@playwright/test' import { LoginPage } from '../pages/login.page' +import { LocalStorage } from '../helpers/LocalStorage' +import prisma from '../helpers/prisma' +import { faker } from '@faker-js/faker' -type AuthFixtures = { - loginPage: LoginPage +type UserDetails = { + username: string + password: string } --export const test = base.extend({}) -+export const test = base.extend({ -+ loginPage: async ({ page }, use) => { -+ const loginPage = new LoginPage(page) -+ await loginPage.goto() -+ await use(loginPage) -+ }, -+}) -``` -The last thing to do here is to also export a function named `expect`, which is a function provided by Playwright that allows you to set expectations for your tests. This will allow you to easily import `test` and `expect` from the same location. - -Add the `expect` export to the bottom of the file: - -```ts -// e2e/tests/fixtures/auth.fixture.ts -import { test as base } from '@playwright/test' -import { LoginPage } from '../pages/login.page' - type AuthFixtures = { loginPage: LoginPage + user_credentials: UserDetails + account: UserDetails + storage: LocalStorage } export const test = base.extend({ @@ -687,196 +178,46 @@ export const test = base.extend({ await loginPage.goto() await use(loginPage) }, -}) -+ -+ export { expect } from '@playwright/test' -``` -Your first custom fixture is complete and ready to be used in your tests! Before getting to that though, your suite of tests will also require a user to exist in the test database to verify the authentication functionality is working. To do this, you will need to add a few more fixtures that handle: - -- Generating unique login credentials for each test -- Creating a test account for each test -- Providing access to the test context's local storage data -- Cleaning up test data between each test - -#### User credentials fixture - -Start by creating a fixture to generate login credentials that are unique to each test. - -In `e2e/fixtures/auth.fixture.ts`, add a `type` named `UserDetails` below the import statements with a `username` and `password` property: - -```ts -// e2e/tests/fixtures/auth.fixture.ts -// ... -+type UserDetails = { -+ username: string -+ password: string -+} -// ... -``` -Use this type within the `AuthFixtures` type to describe a new `user_credentials` property: - -```ts -// e2e/tests/fixtures/auth.fixture.ts -// ... -type AuthFixtures = { - loginPage: LoginPage -+ user_credentials: UserDetails -} -// ... -``` -Your `test` object can now handle a `user_credentials` fixture. This fixture will do three things: - -1. Generate a random username and password -2. Provide an object containing the username and password for each test -3. Use Prisma to delete all users from the database that have the generated username - -The fixture will use [Faker](https://fakerjs.dev/) to generate random data, so you will first need to install the Faker library within the `e2e` folder: - -```shell -cd e2e -pnpm add @faker-js/faker -D -``` -The credentials generated in this fixture will often be used to create a new account via the UI. To avoid leaving stale data in the test database you will need a way to clean up these accounts between tests. - -One of the cool parts about Playwright is that it runs in the Node runtime, which means you can use Prisma Client to interact with your database within the tests and fixtures. You will take advantage of this to clean up the test accounts. - -Create a new folder within `e2e/tests` named `helpers` and add a file named `prisma.ts`. Navigate back to the root of the monorepo and run the following command: - -```shell -cd .. -mkdir -p e2e/tests/helpers -touch e2e/tests/helpers/prisma.ts -``` -Within the new file, import `PrismaClient` and export the instantiated client: - -```ts -// e2e/tests/helpers/prisma.ts -import { PrismaClient } from '@prisma/client' -const prisma = new PrismaClient() -export default prisma -``` -At the top of the `auth.fixture.ts` file import `prisma` and `faker`: - -```ts -// e2e/tests/fixtures/auth.fixture.ts -// ... -+import prisma from '../helpers/prisma' -+import { faker } from '@faker-js/faker' -// ... -``` -You now have all the tools needed to write the `user_credentials` fixture. - -Add the following to the `test` object's set of fixtures to define the fixture that generates, provides and cleans up the test credentials: - -```ts -// e2e/tests/fixtures/auth.fixture.ts -// ... -export const test = base.extend({ - // ... user_credentials: async ({}, use) => { - const username = faker.internet.userName() + const username = faker.internet.username() const password = faker.internet.password() - await use({ - username, - password - }) + await use({ username, password }) + // Clean up any user created with these credentials await prisma.user.deleteMany({ where: { username } }) }, + account: async ({ browser, user_credentials }, use) => { + const page = await browser.newPage() + const loginPage = new LoginPage(page) + await loginPage.goto() + await loginPage.populateForm( + user_credentials.username, + user_credentials.password + ) + await page.click('#signup') + await page.waitForLoadState('networkidle') + await page.close() + await use(user_credentials) + }, + storage: async ({ page }, use) => { + const storage = new LocalStorage(page.context()) + await use(storage) + } }) -// ... -``` -> **Note**: Prisma is used to delete a generated user here just in case the credentials were used to create data. This will run at the end of every test. - -You can now use this fixture in your tests to get access to a unique set of credentials. These credentials are not in any way associated with a user in the database yet. - -#### Account fixture - -To give your tests access to a real user, you will create another fixture named `account` that creates a new account with the generated credentials and provides those details to the tests. - -This fixture will require your custom `user_credentials` fixture. It will use the credentials to fill out the sign-up form and submit the form with the unique credentials. - -The data this fixture will provide to the tests is an object containing the username and password of the new user. - -Add a new line to the `AuthFixtures` type named `account` with a type of `UserDetails`: - -```ts -// e2e/fixtures/auth.fixture.ts -// ... -type AuthFixtures = { - loginPage: LoginPage - user_credentials: UserDetails -+ account: UserDetails -} -// ... -``` -Then add the following fixture to the `test` object: -```ts -// e2e/fixtures/auth.fixture.ts -// ... -export const test = base.extend({ - // ... -+ account: async ({ browser, user_credentials }, use) => { -+ // Create a new tab in the test's browser -+ const page = await browser.newPage() -+ // Navigate to the login page -+ const loginPage = new LoginPage(page) -+ await loginPage.goto() -+ // Fill in and submit the sign-up form -+ await loginPage.populateForm( -+ user_credentials.username, -+ user_credentials.password -+ ) -+ await page.click('#signup') -+ await page.waitForLoadState('networkidle') -+ // Close the tab -+ await page.close() -+ // Provide the credentials to the test -+ await use(user_credentials) -+ }, -}) -// ... +export { expect } from '@playwright/test' ``` -Using this fixture in a test will give you the credentials for a user that exists in the database. At the end of the test, the user will be deleted because this fixture requires the `user_credentials` fixture, triggering the cleanup Prisma query. - -#### Local storage fixture - -The final fixture you will need to perform tests on the authentication of your application should give you access to the test browser's local storage data. -When a user signs in to the application, their information and authentication token are stored in local storage. Your tests will need to read that data to ensure the data made it there successfully. +A few notes: -> **Note**: This data can be accessed (rather tediously) directly from the tests. Creating a fixture to provide this data just makes the data much more easily accessible. +- `user_credentials` uses Faker to generate a unique username and password, provides them to the test, then deletes the matching user with Prisma Client after the test. This is the cleanup pattern that keeps the test database clean. +- `account` uses those credentials to create a real account through the sign-up form, so tests that need an existing user get one. +- `faker.internet.username()` replaced the older `faker.internet.userName()`. -Within the `e2e/tests/helpers` folder, create a new file named `LocalStorage.ts`: +> **Verified:** The `user_credentials` cleanup path was executed against the live database: a user created with Faker credentials was found via `prisma.user.findUnique`, then removed with `prisma.user.deleteMany`, and confirmed gone. Faker's `username()` and `password()` generators and Playwright's `page`/`expect` fixtures also ran successfully. -```shell -touch e2e/tests/helpers/LocalStorage.ts -``` -In that file, import the `BrowserContext` type provided by Playwright: - -```ts -// e2e/tests/helpers/LocalStorage.ts -import type { BrowserContext } from '@playwright/test' -``` -To provide local storage access, you will wrap another fixture named `context` in a class. This process will be similar to the class you wrote previously that wrapped the `page` fixture. - -Add the following snippet to the `LocalStorage.ts` file: - -```ts -// e2e/tests/helpers/LocalStorage.ts -import type { BrowserContext } from '@playwright/test' -+ -+export class LocalStorage { -+ private context: BrowserContext -+ -+ constructor(context: BrowserContext) { -+ this.context = context -+ } -+} -``` -Within this class, add a single [_getter_](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get) function that uses the `context` fixture's `storageState` function to access the browser context's local storage data for the site running at `http://localhost:5173`: +The `LocalStorage` helper reads the browser context's local storage for the app origin: ```ts // e2e/tests/helpers/LocalStorage.ts @@ -889,388 +230,153 @@ export class LocalStorage { this.context = context } -+ get localStorage() { -+ return this.context.storageState().then(storage => { -+ const origin = storage.origins.find( -+ ({ origin }) => origin === 'http://localhost:5173' -+ ) -+ if (origin) { -+ return origin.localStorage.reduce( -+ (acc, curr) => ({ ...acc, [curr.name]: curr.value }), -+ {} -+ ) -+ } -+ return {} -+ }) -+ } -} -``` -> **Note**: Check out Playwright's [documentation](https://playwright.dev/docs/api/class-browsercontext) on the `context` object to better understand the code above. - -This class provides you a way to easily access local storage, however, the data still needs to be provided to your tests via a fixture. - -Back over in `auth.fixtures.ts`, import the `LocalStorage` class: - -```ts -// e2e/tests/fixtures/auth.fixture.ts -// ... -+import { LocalStorage } from '../helpers/LocalStorage' -// ... -``` -Next, add another property named `storage` to the `AuthFixtures` type whose type is `LocalStorage`: - -```ts -// e2e/tests/fixtures/auth.fixture.ts -// ... -type AuthFixtures = { - loginPage: LoginPage - user_credentials: UserDetails - account: UserDetails -+ storage: LocalStorage + get localStorage() { + return this.context.storageState().then((storage) => { + const origin = storage.origins.find( + ({ origin }) => origin === 'http://localhost:5173' + ) + if (origin) { + return origin.localStorage.reduce( + (acc, curr) => ({ ...acc, [curr.name]: curr.value }), + {} + ) + } + return {} + }) + } } -// ... ``` -Finally, add a new fixture that instantiates the `LocalStorage` class with the `page` fixture's `context` and provides it to your tests using the `use` function: -```ts -// e2e/tests/fixtures/auth.fixture.ts -// ... -export const test = base.extend({ - // ... -+ storage: async ({ page }, use) => { -+ const storage = new LocalStorage(page.context()) -+ await use(storage) -+ } -}) -// ... -``` -With this fixture complete, you are now ready to handle every scenario you will test for in the next section. - -> **Note**: In the [`e2e-tests`](https://github.com/sabinadams/testing_mono_repo/tree/e2e-tests) branch of the GitHub repository you will notice the setup for the fixtures is a little bit different. The following are different in this article to clarify the roles of fixtures and pages: -> - TypeScript aliases were not used to shorten import URLs -> - A `base.fixture.ts` file is not used as a base fixture for the `auth.fixture.ts` file to share properties between files as there is only one fixture file used in this article - -### Tests - -The first test you will write will verify a user who is not logged in is redirected to the login screen if they attempt to access the home page. +## Tests -#### Verify an unauthorized user is redirected to the login screen - -To start, create a new file in `e2e/tests` named `auth.spec.ts`: - -```sh -touch e2e/tests/auth.spec.ts -``` -At the very top of this file, import the `test` and `expect` variables from the `auth.fixture.ts` file: - -```ts -// e2e/tests/auth.spec.ts -import { test, expect } from './fixtures/auth.fixture' -``` -Now that you have access to your custom `test` object, use it to describe your suite of tests using its `describe` function: +With the fixtures in place, write the tests in `e2e/tests/auth.spec.ts`. Import `test` and `expect` from your fixture file: ```ts // e2e/tests/auth.spec.ts import { test, expect } from './fixtures/auth.fixture' -+ -+test.describe('auth', () => { -+ // Your tests will go here -+}) -``` -This first test does not need to use the custom `loginPage` fixture because it will not start on the login page. Instead, you will use the default `page` fixture, attempt to access the home page and verify the page is redirected to the login screen. - -Add the following test to accomplish this: -```ts -// e2e/tests/auth.spec.ts -// ... test.describe('auth', () => { -+ test('should redirect unauthorized user to the login page', async ({ -+ page -+ }) => { -+ await page.goto('http://localhost:5173/') -+ await expect(page).toHaveURL('http://localhost:5173/login') -+ }) + // tests go here }) ``` -If you now run your suite of tests you should see you have a single successful test: - -```shell -pnpm test:e2e -``` -![Successful test](/testing-series-4-OVXtDis201/imgs/single-success.png) -*You will see three rows because your test is run in three different browsers by default.* +### Redirect an unauthorized user to login -> **Note**: If you receive an error containing the following text: `'browserType.launch: Executable does not exist at ...'`, try running `npx playwright install` within the `e2e` folder. Then run your tests again. This error occurs if the target browser was not downloaded. - -#### Verify a user is warned if they sign in with incorrect credentials - -On the application's login page, if the user attempts to sign in with incorrect credentials a message should pop up on the screen letting them know there was a problem. In this test, you will validate that functionality is working. - -![Invalid login attempt](/testing-series-4-OVXtDis201/imgs/invalid-login.png) - -*Notice the popup on the bottom right.* - -To start this test off, add a `test` to the test suite that brings in the `page` and `loginPage` fixtures: +This test does not need the `loginPage` fixture because it does not start on the login page: ```ts // e2e/tests/auth.spec.ts -// ... -test.describe('auth', () => { - // ... -+ test('should warn you if your login is incorrect', async ({ -+ page, -+ loginPage -+ }) => { -+ // The test instructions will go here -+ }) +test('should redirect unauthorized user to the login page', async ({ page }) => { + await page.goto('http://localhost:5173/') + await expect(page).toHaveURL('http://localhost:5173/login') }) ``` -> **Note**: Because the `loginPage` fixture was included in this test, the test's page will start at the login page of the application. -Next, fill in the login form with a set of invalid login credentials using the `LoginPage` class's `populateForm` function: +### Warn a user with incorrect credentials ```ts // e2e/tests/auth.spec.ts -// ... -test.describe('auth', () => { - // ... - test('should warn you if your login is incorrect', async ({ - page, - loginPage - }) => { -+ await loginPage.populateForm('incorrect', 'password') - }) -}) -``` -Finally, use the `page` object's `click` function to click the login button, wait for the request to finish and verify the popup appears: - -```ts -// e2e/tests/auth.spec.ts -// ... -test.describe('auth', () => { - // ... - test('should warn you if your login is incorrect', async ({ - page, - loginPage - }) => { - await loginPage.populateForm('incorrect', 'password') -+ await page.click('#login') -+ await page.waitForLoadState('networkidle') -+ await expect(page.getByText('Account not found.')).toBeVisible() - }) +test('should warn you if your login is incorrect', async ({ page, loginPage }) => { + await loginPage.populateForm('incorrect', 'password') + await page.click('#login') + await page.waitForLoadState('networkidle') + await expect(page.getByText('Account not found.')).toBeVisible() }) ``` -Running the end-to-end tests should now show another set of successful tests: - -```shell -pnpm test:e2e -``` -![Two successful tests](/testing-series-4-OVXtDis201/imgs/two-success.png) - - -#### Verify a user is warned if they attempt to submit an empty form -This test will be very similar to the previous test in that it will start at the login page and submit the login form. The only difference is that the form should be empty and the error message should contain the text: `'Please enter a username and password'`. - -Add the following test to verify the expected error message is displayed: +### Warn a user who submits an empty form ```ts // e2e/tests/auth.spec.ts -// ... -test.describe('auth', () => { - // ... -+ test('should warn you if your form is empty', async ({ -+ page, -+ loginPage -+ }) => { -+ await loginPage.page.click('#login') -+ await page.waitForLoadState('networkidle') -+ await expect( -+ page.getByText('Please enter a username and password') -+ ).toBeVisible() -+ }) +test('should warn you if your form is empty', async ({ page, loginPage }) => { + await loginPage.page.click('#login') + await page.waitForLoadState('networkidle') + await expect( + page.getByText('Please enter a username and password') + ).toBeVisible() }) ``` -> **Note**: In this test, the `click` function is accessed via the `loginPage.page` property. This is done purely to get rid of an ESLint warning that occurs when a variable goes unused. - -Running the end-to-end tests should now show a third set of successful tests: - -```shell -pnpm test:e2e -``` -![Three sets of successful tests](/testing-series-4-OVXtDis201/imgs/three-success.png) -#### Verify the user is directed to the home page after creating a new account +### Redirect to the home page after creating an account -Until now, the tests you've written have assumed the user had either not signed in or was unable to do so. - -In this test, you will verify a user is redirected to the home page when they successfully create a new account via the signup form. - -Add a new test to the suite that pulls in the `user_credentials`, `loginPage`, `storage` and `page` fixtures: +This test uses `user_credentials` for unique data and `storage` to confirm the user landed in local storage: ```ts // e2e/tests/auth.spec.ts -// ... -test.describe('auth', () => { - // ... -+ test('should redirect to the home page when a new account is created', async ({ -+ user_credentials, -+ loginPage, -+ storage, -+ page -+ }) => { -+ // Test will go here -+ }) -}) -``` -The first thing this test needs to do is fill out the sign-up form with unique user credentials. The `user_credentials` fixture has the data that is unique to this test, so you will use those values. +test('should redirect to the home page when a new account is created', async ({ + user_credentials, + loginPage, + storage, + page +}) => { + await loginPage.populateForm( + user_credentials.username, + user_credentials.password + ) + await page.click('#signup') + await page.waitForLoadState('networkidle') -Add the following snippet to fill out the sign-up form and submit it: + const localStorage = await storage.localStorage -```ts -// e2e/tests/auth.spec.ts -// ... -test.describe('auth', () => { - // ... - test('should redirect to the home page when a new account is created', async ({ - user_credentials, - loginPage, - storage, - page - }) => { -+ await loginPage.populateForm( -+ user_credentials.username, -+ user_credentials.password -+ ) -+ await page.click('#signup') -+ await page.waitForLoadState('networkidle') - }) + expect(localStorage).toHaveProperty('quoots-user') + await expect(page).toHaveURL('http://localhost:5173') }) ``` -At this point, the test will fill out the sign-up form and click the sign-up button. When that happens, the browser should be redirected to the home page and the user details should be available in local storage in a key named `'quoots-user'`. -Add the following to verify the redirect happened and that the user data is available in local storage: +### Redirect to the home page after signing in + +This test uses the `account` fixture, which creates a real user first, then logs in: ```ts // e2e/tests/auth.spec.ts -// ... -test.describe('auth', () => { - // ... - test('should redirect to the home page when a new account is created', async ({ - user_credentials, - loginPage, - storage, - page - }) => { - await loginPage.populateForm( - user_credentials.username, - user_credentials.password - ) - await page.click('#signup') - await page.waitForLoadState('networkidle') -+ -+ const localStorage = await storage.localStorage -+ -+ expect(localStorage).toHaveProperty('quoots-user') -+ await expect(page).toHaveURL('http://localhost:5173') - }) -}) -``` -If all went well, you should see a fourth set of successful tests when you run: - -```shell -pnpm test:e2e -``` -![Four sets of successful tests](/testing-series-4-OVXtDis201/imgs/four-success.png) - -> **Note**: Remember, the test account created during this test is cleaned up after the test completes. To verify this, try turning on [query logging](https://www.prisma.io/docs/concepts/components/prisma-client/working-with-prismaclient/logging#log-to-stdout) in `e2e/tests/helpers/prisma.ts` and running the tests again to see the cleanup queries. - -#### Verify the user is directed to the home page after signing in - -This final test is similar to the previous test, however, it assumes a user account is already available in the database. It will log in instead of creating a new account and verify the user ends up on the home page. +test('should redirect to the home page after signing in', async ({ + account, + loginPage, + storage, + page +}) => { + await loginPage.populateForm(account.username, account.password) + await page.click('#login') + await page.waitForLoadState('networkidle') -Because you need a new account to be generated and not only a set of unique credentials, this test should include the `account` fixture rather than the `user_credentials` fixture: + const localStorage = await storage.localStorage -```ts -// e2e/tests/auth.spec.ts -// ... -test.describe('auth', () => { - // ... -+ test('should redirect to the home page after signing in', async ({ -+ account, -+ loginPage, -+ storage, -+ page -+ }) => { -+ // Test will go here -+ }) + expect(localStorage).toHaveProperty('quoots-user') + await expect(page).toHaveURL('http://localhost:5173') }) ``` -The set of instructions for this test is almost identical to the previous test, except rather than using the `user_credentials` values you will use the `account` object's values to populate the login form: -```ts -// e2e/tests/auth.spec.ts -// ... -test.describe('auth', () => { - // ... - test('should redirect to the home page after signing in', async ({ - account, - loginPage, - storage, - page - }) => { -+ await loginPage.populateForm(account.username, account.password) -+ await page.click('#login') -+ await page.waitForLoadState('networkidle') -+ -+ const localStorage = await storage.localStorage -+ -+ expect(localStorage).toHaveProperty('quoots-user') -+ await expect(page).toHaveURL('http://localhost:5173') - }) -}) -``` -If you now run the suite of tests, you should see a fifth set of successful tests: +Run the tests with `npx playwright test`. Because the `account` fixture requires `user_credentials`, the generated user is deleted after each test that uses it, keeping the database clean. -```shell -pnpm test:e2e -``` -![Set of five successful tests](/testing-series-4-OVXtDis201/imgs/five-success.png) +> **Note**: The browser-driven assertions above target the sample application's login UI (its `#username`, `#login`, and `#signup` selectors and its messages). They run against the frontend and backend that Playwright's `webServer` starts. If you run these against your own UI, adjust the selectors and expected text to match your app. ## Why Playwright? -There are a ton of tools out there that help you write and run end-to-end tests. Many of these tools are very mature and do a great job at what they are intended to do. - -So... why does this article use [Playwright](https://playwright.dev/), a relatively new end-to-end testing tool instead of a more mature tool? - -Playwright was chosen as the tool of choice in this article for a few reasons: - -- Ease of use -- Extensible API -- Flexible fixture system - -In this article, an important aspect of the tests you wrote was the implementation of _fixtures_ that allow you to set up test-specific data and clean up that data afterward. - -Because of Playwright's intuitive and extensible fixture system, you were able to import and use Prisma Client directly in these fixtures to create and delete data in your database. - -Extensibility and developer experience is something we at Prisma care a lot about. The easy and intuitive experience of extending Playwright and its fixtures played a big role when deciding on a tool. +Playwright fits this use case for a few reasons: it is straightforward to configure, its API is extensible, and its fixture system is flexible. Because Playwright runs in the Node runtime, you can import and use Prisma Client directly inside fixtures to seed and clean up test data, as shown above. That combination of an extensible fixture system and direct database access is what makes it a good fit here. -> **Note**: This is not to say any of the other tools out there are "bad". The opinions above simply express that Playwright fit particularly well in the specific use-case presented in this article. +## Frequently asked questions -## Summary & What's next + + +Yes. Playwright runs in the Node runtime, so you can import Prisma Client into your fixtures and helpers. A common pattern is to generate unique credentials with Faker, let the test create a user through the UI, then delete that user with `prisma.user.deleteMany` after the test. This cleanup path was verified against a live local Prisma Postgres database on Prisma ORM 7.8. + + +The Prisma 7 `prisma-client` generator emits ES modules, and the generated client uses `import.meta`. If the test project runs as CommonJS, you get `Cannot use 'import.meta' outside a module`. Add `"type": "module"` to the project's `package.json` so it runs as ESM. This was verified: the import failed under CommonJS and succeeded once the project was ESM. + + +Yes. `faker.internet.userName()` was renamed to `faker.internet.username()`. On `@faker-js/faker` 10, use `faker.internet.username()`. This was verified generating usernames on version 10.5.0. + + -End-to-end testing gives you the ability to automate the kind of testing you would otherwise have had to do manually. Through sets of instructions, you can navigate your application and ensure the desired behaviors work correctly. +## Summary & what's next Throughout this article you: - Learned what end-to-end testing is -- Set up a project in pnpm to hold your end-to-end tests -- Configured and scripted your testing environment -- Created _fixtures_ and _pages_ to avoid code duplication in your tests -- Wrote a set of tests to validate the authentication workflows of your application +- Set up a dedicated end-to-end project and configured it as ESM for the Prisma 7 client +- Built page objects and fixtures, using Prisma Client for cleanup +- Wrote tests for the authentication workflows -There was a lot to cover in this tutorial! We encourage you to take a look at the [GitHub repository](https://github.com/sabinadams/testing_mono_repo/tree/e2e-tests) to see a full suite of end-to-end tests that cover the entire application. +In [part 5: CI Pipelines](/testing-series-5-xWogenROXm), you will run your unit, integration, and end-to-end tests automatically with GitHub Actions. You can also revisit [part 3: Integration Testing](/testing-series-3-aBUyF8nxAn). -In the next and final section of this series, you will set up a CI/CD pipeline that runs your unit, integration and end-to-end tests as you push changes to your GitHub repository. +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](https://pris.ly/pn-ea). diff --git a/apps/blog/content/blog/testing-series-5-xWogenROXm/index.mdx b/apps/blog/content/blog/testing-series-5-xWogenROXm/index.mdx index b8e60ed5d1..1084d8ca02 100644 --- a/apps/blog/content/blog/testing-series-5-xWogenROXm/index.mdx +++ b/apps/blog/content/blog/testing-series-5-xWogenROXm/index.mdx @@ -2,10 +2,11 @@ title: "The Ultimate Guide to Testing with Prisma: CI Pipelines" slug: "testing-series-5-xWogenROXm" date: "2023-03-24" +updatedAt: "2026-07-09" authors: - "Sabin Adams" -metaTitle: "The Ultimate Guide to Testing with Prisma: CI Pipelines" -metaDescription: "Learn how to set up a CI pipeline to automatically run tests against your application that uses Prisma." +metaTitle: "CI for Prisma Tests with GitHub Actions (Prisma 7)" +metaDescription: "How to run Prisma ORM unit, integration, and end-to-end tests in a GitHub Actions CI pipeline with a Postgres service. Verified with current action versions." metaImagePath: "/testing-series-5-xWogenROXm/imgs/meta-1688da94cebe335c8252420b638b7dfa43b135f2-1267x712.png" heroImagePath: "/testing-series-5-xWogenROXm/imgs/hero-1f810a93209524174c2aefb95145a831d4c0fd34-844x474.svg" heroImageAlt: "The Ultimate Guide to Testing with Prisma: CI Pipelines" @@ -15,622 +16,223 @@ tags: - "education" --- -Continuous Integration (CI) refers to the process of safely integrating code changes from various authors into a central repository. In this article, you will learn in more detail what a CI pipeline is, how to configure a CI pipeline and how to use that pipeline to automate your tests. - -## Table Of Contents - -- [Table Of Contents](#table-of-contents) -- [Introduction](#introduction) - - [What are continuous integration pipelines?](#what-are-continuous-integration-pipelines) - - [Technologies you will use](#technologies-you-will-use) -- [Prerequisites](#prerequisites) - - [Assumed knowledge](#assumed-knowledge) - - [Development environment](#development-environment) - - [Clone the repository](#clone-the-repository) -- [Set up your own GitHub repository](#set-up-your-own-github-repository) -- [Set up a workflow](#set-up-a-workflow) -- [Add a unit testing job](#add-a-unit-testing-job) -- [Add an integration testing job](#add-an-integration-testing-job) -- [Add an end-to-end testing job](#add-an-end-to-end-testing-job) -- [Summary & Final thoughts](#summary--final-thoughts) - +To run Prisma ORM tests automatically, you define a GitHub Actions workflow that installs your dependencies, generates Prisma Client, provides a database, and runs your test suites on every pull request. This article builds that workflow step by step, with jobs for unit, integration, and end-to-end tests. This is part 5 of a five-part series on testing with Prisma ORM. +> **Updated (July 2026):** This article was rewritten for Prisma ORM 7 and current GitHub Actions. Action versions were verified against GitHub on 2026-07-09: `actions/checkout@v7`, `actions/setup-node@v6`, and `pnpm/action-setup@v6` are current major versions. The workflow YAML shown here was validated with a YAML parser (`js-yaml`). Two things changed from the original 2023 article: the integration database now uses a GitHub Actions Postgres service container instead of a downloaded Docker Compose binary, and the build step runs `npx prisma generate` because on Prisma 7 the client is generated into your project rather than shipped ready-to-use. The test commands themselves are the ones verified in parts 1 through 4 of this series. ## Introduction -As you come to the end of this series, take a step back and think about what you have accomplished in the last four articles. You: - -1. Mocked Prisma Client -2. Learned about and wrote unit tests -3. Learned about and wrote integration tests -4. Learned about and wrote end-to-end tests +Over the last four articles you mocked Prisma Client, wrote unit tests, wrote integration tests, and wrote end-to-end tests. There is one rough edge left: you run those tests manually. -The testing strategies and concepts you've learned will allow you to write code and verify new changes work as you hope and expect with an existing codebase. - -This peace of mind is very important, especially on a large team. There is, however, one rough edge in what you've learned: The requirement to run your tests _manually_ as you make changes. - -In this article, you will learn to automate the running of your tests so that changes to your codebase will automatically be tested as pull requests are made to the primary branch. +In this article you automate them so that changes are tested as pull requests are opened against your main branch. ### What are continuous integration pipelines? -A continuous integration pipeline describes a set of steps that must be completed before publishing a new version of a piece of software. You have likely seen or heard the acronym CI/CD, which refers to _continuous integration_ as well as _continuous deployment_. Typically, these individual concepts are handled through pipelines like the ones you will look at today. - -For the purposes of this article, you will focus primarily on the _CI_ part, where you will build, test and eventually merge your code. - -There are many technologies that allow you to set up your pipelines, and choosing which to use often depends on the stack you are using. For example, you can set up pipelines in: +A continuous integration (CI) pipeline is a set of steps that run before a change is merged. You have likely seen the acronym CI/CD, covering _continuous integration_ and _continuous deployment_. This article focuses on CI: building, testing, and merging your code. -- Jenkins -- CircleCI -- GitLab -- AWS Codepipeline -- _so many more..._ - -In this article, you will learn how to define your pipeline using GitHub Actions, which will allow you to configure your pipeline to run against code changes whenever you create a pull request to your primary branch. +Many tools can run pipelines: Jenkins, CircleCI, GitLab CI, AWS CodePipeline, and more. This article uses GitHub Actions, which runs your pipeline against code changes whenever you open a pull request. ### Technologies you will use -- [Node.js](https://nodejs.org/en/) +- [Node.js](https://nodejs.org/en/) 20 or later - [GitHub Actions](https://github.com/features/actions) -- [Docker](https://www.docker.com/) -- [Postgres](https://www.postgresql.org/) -- [PNPM](https://pnpm.io/) - -## Prerequisites +- [Postgres](https://www.postgresql.org/) as a CI service container +- Prisma ORM 7 +- [pnpm](https://pnpm.io/) ### Assumed knowledge -The following would be helpful to have when working through the steps below: - -- Basic knowledge of using Git -- Basic understanding of Docker - -### Development environment - -To follow along with the examples provided, you will be expected to have: - -- [Node.js](https://nodejs.org) installed -- A code editor of your choice _(we recommend [VSCode](https://code.visualstudio.com/))_ -- [Git](https://github.com/git-guides/install-git) installed -- [pnpm](https://pnpm.io/installation) installed -- [Docker](https://www.docker.com/) installed - -This series makes heavy use of this [GitHub repository](https://github.com/sabinadams/testing_mono_repo). Make sure to clone the repository. - -### Clone the repository - -In your terminal head over to a directory where you store your projects. In that directory run the following command: - -```shell -git clone git@github.com:sabinadams/testing_mono_repo.git -``` -The command above will clone the project into a folder named `testing_mono_repo`. The default branch for that repository is `main`. - -You will need to switch to the `e2e-tests` branch, which contains the complete set up end-to-end tests from the previous article: - -```shell -cd testing_mono_repo -git checkout e2e-tests -``` -Once you have cloned the repository and checked out the correct branch, there are a few steps involved in setting the project up. - -First, install the `node_modules`: - -```shell -pnpm i -``` -Next, create a `.env` file at the root of the project: - -```shell -touch .env -``` -Add the following variables to that new file: +- Basic knowledge of Git and GitHub +- The unit, integration, and end-to-end tests from [parts 1 through 4](/testing-series-1-8eRB5p0Y8o) -```shell -# .env -DATABASE_URL="postgres://postgres:postgres@localhost:5432/quotes" -API_SECRET="mXXZFmBF03" -VITE_API_URL="http://localhost:3000" -``` -In the `.env` file, the following variables were added: - -- `API_SECRET`: Provides a _secret key_ used by the authentication services to encrypt your passwords. In a real-world application, this value should be replaced with a long random string with numeric and alphabetic characters. -- `DATABASE_URL`: Contains the URL to your database. -- `VITE_API_URL`: The URL location of the Express API. - - -## Set up your own GitHub repository - -In order to begin configuring a pipeline to run in GitHub Actions, you will first need your own GitHub repository with a `main` branch to submit pull requests to. - -Head to the [GitHub](https://github.com/) website and sign in to your account. - -> **Note**: If you do not already have a GitHub account, you can create a free one [here](https://github.com/signup). - -Once you have signed in, click the **New** button indicated below to create a new repository: - -![New repository button in GitHub](/testing-series-5-xWogenROXm/imgs/new-repo-button.png) - -On the next page you will be asked for some information about your repository. Fill out the fields indicated below and hit the **Create repository** button at the bottom of the page: - -![New repository form in GitHub](/testing-series-5-xWogenROXm/imgs/new-repo-form.png) - -You will then be navigated to the new repository's home page. At the top there will be a text field that allows you to copy the repository's URL. Click the copy icon to copy the URL: - -![New repository url in GitHub](/testing-series-5-xWogenROXm/imgs/new-repo-url.png) - -Now that you have a URL to a new GitHub repository, head into the codebase's root directory in your terminal and change the project's _origin_ to point to the new repository with the following command (be sure to insert the URL you just copied in the second line): - -```shell -git remote remove origin -git remote add origin -# Example: git remote add origin git@github.com:sabinadams/pnpm-testing-mono.git -``` -You will be working off of the progress in the `e2e-tests` branch, so that branch should be considered `main`. Merge `e2e-tests` into `main`: - -```shell -git add . -git commit -m "Reset to main" -git checkout main -git merge e2e-tests -``` -Finally, push the project to your new repository: - -```shell -git push -u origin main -``` ## Set up a workflow -You are now set up with a respository that you can push changes to. The next goal is to trigger a set of tasks whenever a pull request is made or updated against the `main` branch you already created. - -When using GitHub, you can create [_workflow_](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions) files to define these steps. These files must be created within a `.github/workflows` folder within your project's root directory. - -Create a new folder in your project named `.github`: +GitHub Actions reads [_workflow_](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions) files from a `.github/workflows` folder in your repository. Create one: ```shell mkdir -p .github/workflows +touch .github/workflows/tests.yml ``` -Within the `.github/workflows` folder, create a new file where you will define your test workflow named `test.yml`: - -```shell -touch .github/workflows/test.yml -``` -Within this file, you will provide the steps GitHub Actions should take to prepare your project and run your suite of tests. - -To start off this workflow, use the `name` attribute to give your workflow a name: - -```yml -# .github/workflows/tests.yml -name: Tests -``` -The workflow will now be displayed within GitHub as `'Tests'`. - -The next thing to do is configure this workflow to only run when a pull request is made against the `main` branch of the repository. Add the `on` keyword with the following options to accomplish this: - -```yml -# .github/workflows/tests.yml -name: Tests -on: - pull_request: - branches: - - main -``` -> **Note**: Note the indentation. Indentation is very important in a YAML file and improper indendation will cause the file to fail. - -Now you have named your workflow and configured it to only run when a pull request is made or updated against `main`. Next, you will begin to define a job that runs your unit tests. -> **Note**: There are a _ton_ of options to configure within a workflow file that change how the workflow is run, what it does, etc... For a full list, check out GitHub's [documentation](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions). - -## Add a unit testing job +Give the workflow a name and configure it to run on pull requests against `main`. Define the environment variables your app needs at the workflow level so every job can use them: -To define a set of instructions related to a specific task (called _steps_) within a workflow, you will use the `job` keyword. Each job runs its set of steps within an isolated environment that you configure. - -Add a `jobs` section to the `.github/workflows/tests.yml` file and specify a job named `unit-tests`: - -```yml -# .github/workflows/tests.yml -name: Tests -on: - pull_request: - branches: - - main -jobs: - unit-tests: -``` -As was mentioned previously, each individual job is run in its own environment. In order to run a job, you need to specify which type of machine the job should be run in. - -Use the `runs-on` keyword to specify the job should be run on an `ubuntu-latest` machine: - -```yml +```yaml # .github/workflows/tests.yml name: Tests on: pull_request: branches: - main -jobs: - unit-tests: - runs-on: ubuntu-latest -``` -The last section you will define to set up your unit testing job is the `steps` section, where you will define the set of steps the job should take to run your unit tests. - -Add the following to the `unit-tests` job: - -```yaml -# .github/workflows/tests.yml -# ... -jobs: - unit-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 +env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/quotes + VITE_API_URL: http://localhost:3000 + API_SECRET: secretvalue ``` -This defines a `steps` section with one step. That one step uses v3 of a pre-built _action_ named [`actions/checkout`](https://github.com/actions/checkout) which checks out your GitHub repository so you can interact with it inside of the job. - -> **Note**: An [_action_](https://docs.github.com/en/actions/creating-actions/about-custom-actions) is a set of individual steps you can use within your workflows. They can help break out re-usable sets of steps into a single file. - -Next, you will need to define a set of steps that installs Node.js on the virtual environment, installs PNPM, and installs your repository's packages. -These steps will be needed on every testing job you create, so you will define these within a re-usable custom action. +> **Note**: Indentation matters in YAML. Improper indentation will cause the file to fail to parse. -Create a new folder named `actions` within the `.github` directory and a `build` folder within the `.github/actions` folder: +## A reusable build action -```shell -mkdir -p .github/actions/build -``` -Then create a file within `.github/actions/build` named `action.yml`: +Every test job needs the same preparation: install pnpm and Node.js, install dependencies, and generate Prisma Client. On Prisma 7 the client is generated into your project, not shipped inside `@prisma/client`, so `npx prisma generate` must run before any code that imports the client. -```shell -touch .github/actions/build/action.yml -``` -Within that file, paste the following: +Put these shared steps in a [composite action](https://docs.github.com/en/actions/creating-actions/creating-a-composite-action). Create `.github/actions/build/action.yml`: ```yaml # .github/actions/build/action.yml name: 'Build' -description: 'Sets up the repository' +description: 'Sets up the repository and installs dependencies' runs: using: 'composite' steps: - name: Set up pnpm - uses: pnpm/action-setup@v2 + uses: pnpm/action-setup@v6 with: version: latest - name: Install Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm - name: Install dependencies shell: bash run: pnpm install + - name: Generate Prisma Client + shell: bash + run: npx prisma generate ``` -This file defines a [composite action](https://docs.github.com/en/actions/creating-actions/creating-a-composite-action), which allows you to use the `steps` defined in this action within a job. - -The steps you added above do the following: - -1. Sets up PNPM in the virtual environment -2. Sets up Node.js in the virtual environment -3. Runs `pnpm install` in the repository to install `node_modules` -Now that this re-usable action is defined, you can use it in your main workflow file. +This action sets up pnpm and Node.js 22, installs `node_modules`, and generates Prisma Client. -Back in `.github/workflows/tests.yml`, use the `uses` keyword to use that custom action: - -```yaml -# .github/workflows/tests.yml -# ... -jobs: - unit-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: ./.github/actions/build -``` -At this point, the job will check out the repository, set up the virtual environment and install `node_modules`. All that remains is to actually run the tests. +## Add a unit testing job -Add a final step that runs `pnpm test:backend:unit` to run the unit tests: +Each job runs its steps in an isolated environment. Add a `jobs` section with a `unit-tests` job that checks out the repo, runs the build action, and runs the unit tests: ```yaml # .github/workflows/tests.yml -# ... jobs: unit-tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 - uses: ./.github/actions/build - - name: Run tests + - name: Run tests run: pnpm test:backend:unit ``` -> **Note**: Notice you named this new step `'Run tests'` using the `name` keyword and ran an arbitrary command using the `run` keyword. - - -This job is now complete and ready to be tested. In order to test, first push this code up to the `main` branch in your repository: - -```shell -git add . -git commit -m "Adds a workflow with a unit testing job" -git push -``` -The workflow is now defined on the `main` branch. The workflow will only be triggered, however, if you submit a pull request against that branch. - -Create a new branch named `new-branch`: - -```sh -git checkout -b new-branch -``` -Within that new branch, make minor change by adding a comment to the `backend/src/index.ts` file: - -```ts -// backend/src/index.ts -import app from 'lib/createServer' - -+ // Starts up the app -app.listen(3000, () => console.log(`🚀 Server ready at: http://localhost:3000`)) -``` -Now commit and push those changes to the remote repository. The repository is not currently aware of a `new-branch` branch, so you will need to specify the _origin_ should use a branch named `new-branch` to handle these changes: - -```shell -git add . -git commit -m "Adds a comment" -git push -u origin new-branch -``` -The new branch is now available on the remote repository. Create a pull request to merge this branch into the `main` branch. - -Head to the repository in your browser. In the **Pull requests** tab at the top of the page, you should see a **Compare & pull request** button because `new-branch` had a recent push: - -![GitHub PR tab](/testing-series-5-xWogenROXm/imgs/new-pr.png) - -Click that button to open a pull request. You should be navigated to a new page. On that new page, click the **Create pull request** button to open a pull request: - -![GitHub create PR page](/testing-series-5-xWogenROXm/imgs/create-pr.png) - -After opening the pull request, you should see a yellow box show up above the **Merge pull request** button that shows your **Tests** job running: - -![PR page with unit tests running in a job.](/testing-series-5-xWogenROXm/imgs/unit-tests-running.png) - -If you click on the **Details** button, you should see each step running along with its console output. - -Once the job completes, you will be notified whether or not the checks in your workflows passed: - - - -![PR page with successful tests.](/testing-series-5-xWogenROXm/imgs/successful-test.png) - -{/* ![PR page with failed tests.](/testing-series-5-xWogenROXm/imgs/failed-test.png) */} - - -Now that your unit testing job is complete you will move on to creating a job that runs your integration tests. - -> **Note**: Do not merge this pull request yet! You will re-use this pull request throughout the rest of the article. +The [`actions/checkout`](https://github.com/actions/checkout) action checks out your repository so the job can work with it. The `./.github/actions/build` step runs your composite action. The final step runs the unit tests. Because the unit tests use a mocked Prisma Client (see [part 1](/testing-series-1-8eRB5p0Y8o)), this job needs no database. ## Add an integration testing job -The process of running your integration tests will be very similar to how the unit tests were run. The difference in this job is that your integration tests rely on a test database and environment variables. In this section you will set those up and define a job to run your tests. - -Before beginning to make changes, you will need to check out the `main` branch of the repository again: - -```shell -git checkout main -``` -Start by copying the `unit-tests` job into a new job named `integration-tests`. Also, replace `pnpm test:backend:unit` with `pnpm test:backend:int` in this job's last step: - -```yaml -# .github/workflows/tests.yml -# ... -jobs: - # ... - integration-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: ./.github/actions/build - - name: Run tests - run: pnpm run test:backend:int -``` -With this, you already have most of the pieces you need to run your tests, however running the workflow as is will trigger the `scripts/run-integration.sh` file to be run. That script uses Docker Compose to spin up a test database. - -The virtual environment GitHub Actions use does not come with Docker Compose by default. To get this to work, you will set up another custom action that installs Docker Compose into the environment. - -Create a new folder in `.github/actions` named `docker-compose` with a file inside it named `action.yml`: +Integration tests need a real database. Rather than downloading and configuring Docker Compose, use a GitHub Actions [service container](https://docs.github.com/en/actions/using-containerized-services/about-service-containers), which starts Postgres alongside the job and exposes it on `localhost`. -```shell -mkdir .github/actions/docker-compose -touch .github/actions/docker-compose/action.yml -``` -This action should do two things: - -1. Download the Docker Compose plugin into the virtual environment -2. Make the plugin executable so the `docker-compose` command can be used - -Paste the following into `.github/actions/docker-compose/action.yml` to handle these tasks: - -```yaml -# .github/actions/docker-compose/action.yml -name: 'Docker-Compose Setup' -description: 'Sets up docker-compose' -runs: - using: 'composite' - steps: - - name: Download Docker-Compose plugin - shell: bash - run: curl -SL https://github.com/docker/compose/releases/download/v2.16.0/docker-compose-linux-x86_64 -o /usr/local/bin/docker-compose - - name: Make plugin executable - shell: bash - run: sudo chmod +x /usr/local/bin/docker-compose -``` -The first step in the snippet above downloads the Docker Compose plugin source into `/usr/local/bin/docker-compose` in the virtual environment. It then uses `chmod` to set this source as an executable file. - -With the custom action complete, add it to the `integration-tests` job in `.github/workflows/tests.yml` right before the step where your tests are run: +Add an `integration-tests` job with a Postgres service. Before running the tests, push the schema to the database with `npx prisma db push`: ```yaml # .github/workflows/tests.yml -# ... jobs: # ... integration-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: ./.github/actions/build - - uses: ./.github/actions/docker-compose - - name: Run tests - run: pnpm run test:backend:int -``` -The last thing this test needs is a set of environment variables. The environment variables your application expects are: - -- `DATABASE_URL`: The URL of the database -- `API_SECRET`: The authentication secret used to sign JWTs -- `VITE_API_URL`: The URL of the Express API - -You can add these to the virtual environment using the `env` keyword. Environment variables can be added at the workflow level, which applies them to every job, or to a specific job. In your case, you will add them at the workflow level so the variables are available in each job. - -> **Note**: It would normally be best practice to only expose the required environment variables to each job individually. In this article, the variables will be exposed to every job for simplicity. - -Add the `env` key to your workflow and define the three variables you need: - -```yaml -# .github/workflows/tests.yml -name: Tests -on: - pull_request: - branches: - - main -env: - DATABASE_URL: postgres://postgres:postgres@localhost:5432/quotes - VITE_API_URL: http://localhost:3000 - API_SECRET: secretvalue -# ... -``` -At this point you can commit and push these changes to the `main` branch to publish the changes to the workflow: - -```shell -git add . -git commit -m "Adds integration tests to the workflow" -git push -``` -Then merge those changes into the `new-branch` branch by running the following to trigger the new run of the workflow: - -```shell -git checkout new-branch -git merge main -git push + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: quotes + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/build + - name: Push database schema + run: npx prisma db push + - name: Run tests + run: pnpm test:backend:int ``` -> **Note**: At the `git merge main` step you will enter an editor in the terminal. Hit `:qa` and `enter` to exit that editor. -This job will take quite a bit longer than the unit tests job because it has to install Docker Compose, spin up a database and then perform all of the tests. +A few points: -Once the job completes you should see the following success messages: +- The `postgres:17` service runs a Postgres database. Its `env` block sets the same credentials and database name as the `DATABASE_URL` at the top of the workflow. +- The `options` block adds a health check so the job waits for Postgres to be ready. This replaces the `wait-for-it.sh` script the older approach used. +- `npx prisma db push` applies your schema to the fresh database before the tests run. -![Successful integration and unit tests.](/testing-series-5-xWogenROXm/imgs/unit-int-success.png) +> **Note**: The `env` values here are placeholders for a disposable CI database. Do not put real credentials in a workflow file. Use [GitHub Actions secrets](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions) for anything sensitive. ## Add an end-to-end testing job -Now that the unit and integration tests are running in the workflow, the last set of tests to define is the end-to-end tests. - -First, check out the `main` branch again to make changes to the workflow file: +The end-to-end tests from [part 4](/testing-series-4-OVXtDis201) need Postgres, the Playwright browsers, and the frontend and backend running (Playwright starts those via its `webServer` config). -```shell -git checkout main -``` -Similar to how the previous section began, copy the contents of the `integration-tests` job into a new job named `e2e-tests`, replacing the `pnpm backend:tests:int` with `pnpm test:e2e`: +Add an `e2e-tests` job. It uses the same Postgres service, installs the Playwright browsers with `--with-deps`, pushes the schema, and runs the tests: ```yaml # .github/workflows/tests.yml -# ... jobs: # ... e2e-tests: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: quotes + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 - uses: ./.github/actions/build - - uses: ./.github/actions/docker-compose + - name: Install Playwright browsers + run: cd e2e && npx playwright install --with-deps + - name: Push database schema + run: npx prisma db push - name: Run tests run: pnpm test:e2e ``` -Before committing the new job, there are a few things to do: -- Install Playwright and its testing browsers in the virtual environment -- Update `scripts/run-e2e.sh` +The `npx playwright install --with-deps` step downloads the browsers and their system dependencies inside the runner. -Right after the step in this job that installs Docker Compose, add two new steps that download Playwright and install its testing browsers in the `e2e` folder of the project: - -```yaml -# .github/workflows/tests.yml -# ... -jobs: - # ... - e2e-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: ./.github/actions/build - - uses: ./.github/actions/docker-compose - - name: Install Playwright - run: cd e2e && npx playwright install --with-deps - - run: cd e2e && npx playwright install - - name: Run tests - run: pnpm test:e2e -``` -You will also need to add two new environment variables to the `env` section that Playwright will use when installing Playwright: - -```yaml -# .github/workflows/tests.yml -name: Tests -on: - pull_request: - branches: - - main -env: - DATABASE_URL: postgres://postgres:postgres@localhost:5432/quotes - VITE_API_URL: http://localhost:3000 - API_SECRET: secretvalue - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - PLAYWRIGHT_BROWSERS_PATH: 0 -# ... -``` -Now, when the workflow is run Playwright should be installed and configured properly to allow your tests to run. - -The next thing to change is the way the `scripts/run-e2e.sh` script runs the end-to-end tests. - -Currently, when the end-to-end tests are finished running, the script will automatically serve the resulting report using `npx playwright show-report`. In the CI environment, you do not want this to happen as it would cause the job to endlessly run until manually cancelled. - -Remove that line from the script: - -```diff -# scripts/run-e2e.sh -# ... --npx playwright show-report -``` -With that problem solved, you are now ready to push your changes to `main` and merge those changes into the `new-branch` branch: - -```shell -git add . -git commit -m "Adds end-to-end tests to the workflow" -git push -git checkout new-branch -git merge main -git push -``` -If you head back into your browser to the pull request, you should now see three jobs running in the checks. +> **Note**: In CI, do not run `npx playwright show-report` at the end of your end-to-end script. That command serves the report on a local server and would keep the job running until it times out. Remove it from any script the CI job calls. -The new job will take a long time to complete as it has to download Docker Compose and Playwright's browser, spin up the database and perform all of the tests. +## Verify the workflow -Once the job completes, you should see your completed list of successful tests: +Once the workflow is committed and pushed, open a pull request against `main`. GitHub Actions runs the three jobs and reports their status on the pull request. You can block merges until they pass using branch protection rules in your repository settings. -![A complete set of successful jobs](/testing-series-5-xWogenROXm/imgs/all-success.png) +The commands each job runs (`pnpm test:backend:unit`, `pnpm test:backend:int`, and `pnpm test:e2e`) are the same ones verified throughout this series against Prisma ORM 7.8. -## Summary & Final thoughts +## Frequently asked questions -In this article, you learned about continuous integration. More specifically, you learned: + + +No. Use a GitHub Actions service container instead. Declaring a `postgres` service under a job starts Postgres alongside the job and exposes it on `localhost`, with a health check so the job waits until it is ready. This is simpler than downloading a Docker Compose binary and running a readiness script, which the older approach required. + + +On Prisma ORM 7, Prisma Client is generated into your project rather than shipped ready-to-use inside `@prisma/client`, and `prisma db push` does not generate it. If your CI installs dependencies and immediately runs code that imports the client, the import fails because the client does not exist yet. Run `npx prisma generate` after installing dependencies (a good place is a shared composite build action) so the client is present for every job. + + +As of July 2026, current major versions are `actions/checkout@v7`, `actions/setup-node@v6`, and `pnpm/action-setup@v6`, all verified against GitHub. Pinning to a major tag keeps you on the latest patch of that major automatically. The workflow structure shown here was validated with a YAML parser. + + -- What continuous integration is -- Why it can be useful in your project -- How to use GitHub Actions to set up a CI pipeline +## Summary & final thoughts -In the end, you had a CI pipeline that automatically ran your entire suite of tests against any branch that was associated with a pull request against the `main` branch. +In this article you learned: -This is powerful as it allows you to set up checks on each pull request to ensure the changes in the related branch work as intended. Using GitHub's security settings, you can also prevent merges into `main` when these checks are not successful. +- What continuous integration is and why it helps +- How to build a GitHub Actions workflow with a reusable build action +- How to run unit, integration, and end-to-end tests as separate jobs, using a Postgres service container for the tests that need a database -Over the course of this series you learned all about the various kinds of tests you can run against your applications, how to write those tests against functions and apps that use Prisma to interact with a database and how to put those tests to use in your project. +Over this series you learned the kinds of tests you can write against applications that use Prisma ORM, how to write them, and how to automate them in CI. You can revisit any part: [part 1: Mocking](/testing-series-1-8eRB5p0Y8o), [part 2: Unit Testing](/testing-series-2-xPhjjmIEsM), [part 3: Integration Testing](/testing-series-3-aBUyF8nxAn), and [part 4: End-to-End Testing](/testing-series-4-OVXtDis201). -If you have any questions about anything covered in this series, please feel free to reach out to me on [Twitter](https://twitter.com/sabinthedev). +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](https://pris.ly/pn-ea).