January 31, 2023

The Ultimate Guide to Testing with Prisma: Unit Testing

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.

The Ultimate Guide to Testing with Prisma: Unit Testing

Table Of Contents

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 for his honesty 😉

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.

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.

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.

Take the function below as an example:

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:

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.

Test graphic

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. 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:

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:

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

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.

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

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 installed
  • A code editor of your choice (we recommend VSCode)
  • Git installed

This series will make heavy use of this GitHub repository. 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:

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:

Next, create a .env file at the root of the project:

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:

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:

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.

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's files are organized by feature into folders within the src directory.

Within src there are three main folders:

  • /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:

endpointdescription
POST /auth/signupCreates a new user with a username and password.
POST /auth/signinLogs a user in with a username and a password.
GET /quotesReturns all quotes related to the logged in user.
POST /quotesStores a new quote related to the logged in user.
DELETE /quotes/:idDeletes 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.

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.

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.

Begin by installing vitest and vitest-mock-extended with the following command:

Note: For information regarding the two packages installed above, be sure to read the first article 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:

This file will define and export the configuration for your unit tests using the defineConfig function provided by Vitest:

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.

Add the following to the scripts section of package.json:

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:

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.

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:

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.

The functions only invokes an external module

Another scenario that is important to point out is the one in src/quotes/quotes.service.ts:

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 branch of the Github repository has a complete set of tests for every function.

Test the tags service

The tags service exports two functions, upsertTags and deleteOrphanedTags.

To start, create a new file in the same directory as tags.service.ts named tags.service.test.ts:

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 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

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

This lets VSCode to nest any files under the main file named ${capture}.ts. To better illustrate, see the following example:

Nested files

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:

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 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 function provided by Vitest:

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:

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 and vi.restoreAllMocks 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.

Note: If you are unsure about how these mocks work, be sure to refer to the previous article in this series where mocking was covered.

Whenever these modules are imported within TagsService, the mocked versions will now be imported instead.

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.

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:

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:

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.

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.

The test should:

  • 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.

Add the following test below the previous test within the describe block for the upsertTags function:

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:

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:

  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:

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.

In this test, verify that this behavior is working by adding the following:

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!

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 suite of tests

Test the deleteOrphanedTags function

This function is a very different scenario than the previous function.

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

During the course of 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

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!

In the next part of this series, you will dive into integration testing and write integration tests for this same application.

Don’t miss the next post!

Sign up for the Prisma Newsletter