Hamburger Icon
  • Labs icon Lab
  • Core Tech
Labs

Guided: Testing a Next.js Finance Application with Vitest

In this Guided Code Lab, you will create tests and mocks using an increasingly popular testing framework, Vitest. You will be provided an existing finance application that is built using Next.js and Prisma. You will be tasked with writing unit tests for a client component, server component, and server actions that perform CRUD operations using Prisma. By the end of this lab, you will have a deeper understanding of Vitest and how it can and cannot be used to unit test a full stack application.

Labs

Path Info

Level
Clock icon Beginner
Duration
Clock icon 45m
Published
Clock icon Aug 20, 2024

Contact sales

By filling out this form and clicking submit, you acknowledge our privacy policy.

Table of Contents

  1. Challenge

    Introduction

    Welcome to the Guided: Testing a Next.js Finance Application with Vitest lab.

    Throughout this lab, you will become familiar with how to use Vitest to test a Next.js application. This lab will cover how to configure Vitest, setup a testing script, and unit test a full stack Next.js finance application. You will learn about Vitest’s strengths and limitations all while completing tests for a Next.js client component, server component, and server action. You will also learn how to use Vitest to mock modules and their associated methods.

    To start, you have been given a Next.js finance application. You can launch the application by entering npm run dev in the Terminal. The application has a Home page that displays all transactions in a table and highlights expense transactions in red and income transactions in green. There are also Income and Expenses pages where you can create, edit, and delete the corresponding transactions. Lastly, there is a Plan page where you can create, edit, and delete budget plans.

    The application’s main pages (e.g. Home, Expenses, Income, and Plan) are all Next.js server components. A server component is one where the backend compiles the HTML and hands that to the frontend instead of just supplying data. These server components render client components. Client components are what you are familiar with if you have used React in the past. They are generated entirely on the frontend within the browser.

    The application is using a SQLite database that is manipulated using Prisma. Prisma is a modern database toolkit designed for TypeScript and JavaScript applications. It simplifies database access by providing a type-safe query builder, an Object-Relational Mapping (ORM) layer, and a schema definition language. This lab will cover how to mock a Prisma Client using Vitest to unit test Next.js server actions.

    You will start by configuring the Next.js application to use Vitest. Then, you will be responsible for testing the TransactionTable client component. This will use Vitest alongside React Testing Library to create unit tests for the TransactionTable component. You will verify that transaction data exists in the table and the transactions link to their corresponding edit pages. This section will feel familiar if you have experience testing frontend code using a React Testing Library and Jest.

    Next, you will test the ExpensesPage server component. You will learn about the limitations of Vitest when it comes to testing async server components and how to mock other client component modules that are being rendered from the ExpensesPage component.

    Lastly, you will mock the Prisma’s Prisma Client module and test transactions CRUD server actions. This will cover what a deep mock is from the vitest-mock-extended library and why it is important when testing results from server actions.

    You do not need any previous experience with Next.js, Prisma, Vitest, or Vitest Mock Extended to complete this lab. Any specifics you will need around these technologies will be provided to help you complete the step at hand. It will benefit you to have basic experience with Javascript, JSX, and React. Knowledge of some testing framework like Jest and React Testing Library would also be helpful for this lab, but it is not required. Throughout each step, you will learn about what is available to you through Vitest’s Test API and React Testing Library.

    There is a solution directory with corresponding step directories that you can refer to if you are stuck or want to check your implementations at any time. Keep in mind that the solution provided in this directory is not the only solution, so it is acceptable for you to have a different solution so long as the test functions as intended. The solution provided will be for the entire step.


    Activity - Start the Application

    Launch the application by entering npm run dev in the Terminal. The application can be seen in the Web Browser tab at localhost:3000. Take a moment to become familiar with the different pages. You will be testing the Expenses page and the transaction table that appears on it as well as the transactions CRUD operations. The starting application should look like the picture below:

    A screenshot of a financial transaction table showing income and expenses over time. The table has columns for Date, Amount, Category, and Notes. Income rows are highlighted in green, and expense rows are highlighted in red. Transactions include paychecks, interest, mortgage payments, groceries, gas, utilities, and various other categories.

    Remember: Throughout the lab, any code changes within the app directory and the database will be reflected live on the website after refreshing, as long as the application is running. If you do, however, want to stop and restart the application, you can do so by pressing Ctrl+C in the Terminal and rerunning npm run dev. This will hold true for when you are running tests as well in the future. Tests will update when changes are made without needing to be restarted.

  2. Challenge

    Setting up Vitest

    Vitest Overview

    Testing application code has become a very important step in creating developers that are confident in the code they write and refactor. It can help developers create software more efficiently. Vitest is a testing framework that provides a set of tooling to build and run tests, as well as create mocks of modules. Vitest is designed to assist developers when they are writing tests for their applications. Vitest can be used alongside the React Testing Library to test frontend components. Vitest supports mocking modules of all kinds. Mocks are helpful when a developer does not want certain code to be executed. One reason a developer may not want code to be executed is because it interacts with a database that may not exist in the testing environment - as is the case of this application. Another reason Vitest mocks can be helpful is the ability to separate concerns and not test the same internal module twice. Mocks will be discussed further in a future step.


    Setting up Vitest

    Vitest has already been installed for you using Node Package Manage, npm. If you are using npm in another project, Vitest is installed by running the following command: npm install -D vitest. This application will also use the @vitejs/plugin-react, vitest-mock-extended, jsdom, and @testing-library/react libraries, so these libraries have also been installed for you using the npm install -D @vitejs/plugin-react jsdom @testing-library/react vitest-mock-extended command.

    However, configuring an application to be tested using Vitest requires you to do more than just installing the necessary libraries. In the activities below, you will have the opportunity to configure Vitest.


    Activity 1 - Configuration

    Edit the existing vitest.config.js file to configure Vitest as follows:

    1. Add a test property in your defineConfig parameter object.
    2. Set the test property to be an empty object.
    3. Inside the empty object, create an environment property.
    4. Set the environment property to be ”jsdom”.
      • This lab uses jsdom as the environment, but if you ever wanted to use a different environment this is where you would set it.
    5. Add a plugins property at the same level as the test property.
    6. Set the plugins property to be an empty list.
    7. Inside the empty list, call the already imported react module.
      • This lab uses vitest’s react plugin which is why this step is necessary, but you could configure Vitest to not use any plugins by removing this property from the configuration.
    Example Vitest Configuration
    import { defineConfig } from "vitest/config";
    import react from "@vitejs/plugin-react";
    
    export default defineConfig({
      plugins: [react()],
      test: {
        environment: "jsdom",
      },
    });
    

    The vitest.config.js file is used to override the configuration of vite.config.js (if there is any) and configure Vitest. The finance application at hand does not rely on Vite specifically, which is why the vitest.config.js file is used to configure vitest instead of vite.config.js.


    Activity 2 - Test Script

    Next, add a script named test that simply runs the command vitest inside your package.json file. This script will be responsible for running your tests from the Terminal with the following command: npm run test.

    Example package.json
    {
      "name": "finance-app",
      "version": "0.1.0",
      "private": true,
      "scripts": {
        "dev": "next dev",
        "build": "next build",
        "start": "next start",
        "lint": "next lint",
        "seed": "node seedDatabase/seed.js",
        "test": "vitest" ← You should have added this line
      },
     …
    }
    

    Now, run npm run test in the Terminal to run your application’s vitest tests. Vitest will watch for changes in your project by default and reruns files that have been changed or contain failing tests and/or failing test suites. Outside of this lab environment, you would not need to restart this command whenever you make changes to test and production files. However, in this lab, you will be asked to restart the tests at the end of each step to make sure all test files are compiled and ran (instead of just those that have been changed or are still failing). This is so you can compare test output against all files to what is expected.

    When you run npm run test, all test files are ran. Each test file is considered its own test suite. Test suites are designed to be a group of tests that relate to each other or test a certain context of the application. Test suites are created for every test file and describe block. Currently, the application has six test suites (made up of three test files and three describe blocks). There are no tests within any of these test suites. This means that you will notice that when you run the tests now all six test suites fail with an error that contains, "Error: No test suite found in ...".

    Run npm run test in Terminal, and your output should match the screenshot below:

    A terminal screenshot showing test results for a project. It indicates that six test suites have failed. The errors are related to missing test cases in files such as actions.test.js, transactionTable.test.jsx, and expenses/page.test.jsx.

  3. Challenge

    Test the `TransactionTable` Client Component

    Introduction

    In this step, you will be implementing the tests found in the __tests__/(ui)/components/transactionTable.test.jsx directory.

    Vitest links this test file with the component found in the app/(ui)/components/transactionTable.jsx directory. This is because Vitest can recognize that the two files are named the same, with the exception of the files' paths and that one file has .test before the file extension.

    Throughout this step, you will become familiar with different Vitest API options (e.g. expect, test, describe, beforeEach, and afterEach). You will also use render, screen, and cleanup from the React Testing Library to test the TransactionTable client component.

    The test file you have been given to test the TransactionTable client component already imports the TransactionTable component and other methods or modules you will use. This includes all imports you will need from vitest and @testing-library/react to complete this step.

    It may be helpful to also look at the app/lib/transactions/data/mockData.json file to familiarize yourself with how transaction records will look as the frontend consumes them. This file will be used to provide test data for your tests and is being read in the getTransactions method already given to you in the __tests__/(ui)/components/transactionTable.test.jsx file.


    Activity 1 - Create the TransactionTable Component’s Test Suite

    Test suites can be created implicitly (with a test at the top level of a test file) or explicitly using Vitest’s describe. By using describe you can define a new suite, in the current context, as a set of related tests or benchmarks and other nested suites. A suite lets you organize your tests and benchmarks so reports are more clear.

    describe takes two parameters, a name for the test suite and a function that will contain test setup, test teardown, and tests for the test suite. You want to create a test suite that describes the tests for the TransactionTable client component.

    1. Under the getTransactions method, start a describe block.
    2. Name the test suite ”Transaction Table client component”.
    3. For now have an empty test suite by passing () => {} as the second parameter.
    Example TransactionTable describe block
    describe("Transaction Table client component", () => {});
    

    Activity 2 - Before Each Test in the TransactionTable Test Suite Render the TransactionTable

    Vitest’s beforeEach is used to set up a testing suite. beforeEach registers a callback to be called before each of the tests in the current context runs. beforeEach takes a function as a parameter to run as the callback. Set up the test suite you created in the first activity to use the React Testing Library’s render to render the TransactionTable before each test.

    1. In the describe block from the first activity, call beforeEach.
    2. Pass the beforeEach method as an asynchronous function lambda.
    3. Inside the lambda, assign a variable called transactions to what is returned from getTransactions():
      • await the result of getTransactions.
      • Define the transactions variable without assigning it to anything at the top of the describe block.
    4. Call render with the TransactionTable component with transactions for the transactions prop.
    Example TransactionTable describe block with beforeEach block
    describe("Transaction Table client component", () => {
      let transactions;
    
      beforeEach(async () => {
        transactions = await getTransactions();
        render(<TransactionTable transactions={transactions} />);
      });
    });
    

    Activity 3 - After Each Test Cleanup the TransactionTable Component

    Vitest’s afterEach method works similarly to beforeEach except it registers a callback function to be called after each test is executed. You want to tear down the component to be rerendered again after each test.

    1. Under the beforeEach block, call afterEach.
    2. Pass a function that calls React Testing Library’s cleanup method.
    Example TransactionTable describe block with afterEach block
    describe("Transaction Table client component", () => {
      let transactions;
    
      beforeEach(async () => {
        transactions = await getTransactions();
        render(<TransactionTable transactions={transactions} />);
      });
    
      afterEach(() => {
        cleanup();
      });
    });
    

    Activity 4 - Assert that the TransactionTable Component Renders Transaction Data

    Vitest’s test method defines a set of related expectations. It receives the test name and a function that holds the expectations to test. Vitest’s expect is used to create assertions. In this context, assertions are functions that can be called to assert a statement. Vitest provides Chai assertions by default and also Jest compatible assertions built on top of Chai.

    You will now write your first Vitest test and use expect to assert that certain text appears on the React Testing Library’s screen.

    1. Under the afterEach block, call Vitest’s test.
    2. Name the test ”displays each transaction's data in a table”.
    3. Pass a lambda function for the second parameter.
    4. Inside the lambda function iterate over every transaction using Javascript’s forEach method on the transactions array.
    5. Use Vitest’s expect to assert that screen.getByText(transaction.notes) is defined.
    Example TransactionTable describe block with first test block
    describe("Transaction Table client component", () => {
      let transactions;
    
      beforeEach(async () => {
        transactions = await getTransactions();
        render(<TransactionTable transactions={transactions} />);
      });
    
      afterEach(() => {
        cleanup();
      });
    
      test("displays each transaction's data in a table", () => {
        transactions.forEach((transaction) => {
          expect(screen.getByText(transaction.amount)).toBeDefined();
        });
      });
    });
    

    At this point, you have written a test to assert that each transaction's amount is rendered on the TransactionTable client component. If you wanted to continue to extensively test the TransactionTable component, you could also verify that the transaction’s category, date, and notes are rendered correctly. You may also create another test to verify that each transaction’s Edit button links to the correct page.

    If you still have npm run test running from the previous step, you will notice that it updates live and reruns some of the test suites/files again as you were making changes. However, it usually does not show the output for all of tests it can find for the application. It will zero in on the file being edited and any other failing test files, suites, or tests. It will not show you what other tests outside of the file most recently saved still pass. If you leave it running, you may see an output like below:

    A terminal screenshot displaying the results of a test run. Out of three test files, two have failed, and one has passed. The test suite includes actions.test.js, expenses/page.test.jsx, and transactionTable.test.jsx. A warning about an invalid prop action on a  tag in transactionTable.jsx is shown, suggesting the prop should be removed or passed a string/number. The successful test verifies that the 'Transaction Table client component' correctly displays transaction data in a table.

    For the rest of this lab, please quit your test execution in Terminal by typing q and then rerun the npm run test command. This will allow you to precisely compare your output to what is provided as an example throughout the rest of the lab and continue to verify that tests you implemented in previous steps still work.

    Now, please quit any test execution you have running and rerun npm run test. When this command completes, the output should look like:

    A terminal screenshot showing the results of a test run with five failed suites. The test files actions.test.js and expenses/page.test.jsx report no tests found in various suites, including 'Transaction CRUD Server Actions' and 'async Expenses server component.' Despite the failures, one test passed in transactionTable.test.jsx, which successfully tested the 'Transaction Table client component'.

    You should now see that one test file and one test is passing. There are still two test files that are failing. You will implement the failing test files in future steps. You can also see that only five test suites are failing, meaning the one you wrote in this step, titled Transaction Table client component is passing. This is because of the test you wrote in this step. This step is in the Transaction Table client component test suite and the __tests__/(ui)/components/transactionTable.test.jsx test file.


    Note: You may have seen a Warning: Invalid value for propactionon <form> tag. message in the output. This is because testing a server component using Vitest is not fully supported as will be discussed in the next step. If you were to install the canary version of React this error would go away, but given it is still experimental, this lab does not run off of the canary version of React.

  4. Challenge

    Test the `ExpensesPage` Server Component

    Introduction

    In this step, you will be implementing the tests found in the __tests__/(ui)/expenses/page.test.jsx file.

    Vitest associates this test file with the component found in the app/(ui)/expenses/page.jsx file.

    Throughout this step, you will use the Vitest methods you learned about in the previous step (e.g. expect, test, describe, beforeEach, and afterEach). You will use render, screen, and cleanup from the React Testing Library like in the previous step to test the ExpensesPage server component. In this step, you will learn about Vitest’s vi utility to help you create mocks on modules.

    The test file you have been given to test the ExpensesPage server component already imports the ExpensesPage component and all other methods or modules you will use to complete this step.

    The ExpensesPage testing suite has already been created for you within the describe block with the name ”async Expenses server component”. Add all tests and test set up inside this test suite. You have also been given a few helpful variables to use within this test suite.


    Activity 1 - Before Each Test in the ExpensesPage Test Suite Render the ExpensesPage

    Vitest is capable of testing client components just like any other framework. Vitest (as well as any other testing framework) is limited by how it can be used to test a server component since async server components are new to the React ecosystem. For this reason, you will often see end-to-end (E2E) tests using playkit to test server components. In this step, you will test the async ExpensesPage server component similar to how you would test a client component. You will await the rendered component in order to perform assertions on the component.

    Note: If you were to render the server component without awaiting the result, you would get the following error when running your tests:

    [Error: Objects are not valid as a React child (found: [object Promise]). If you meant to render a collection of children, use an array instead.]
    

    The beforeEach call is used to set up a testing suite. beforeEach registers a callback before each of the tests in the current context runs. beforeEach takes a function as a parameter to run as the callback. Set up the test suite you created in the first activity to use the React Testing Library’s render to render the TransactionTable before each test.

    1. In the ExpensesPage describe block, call beforeEach.
    2. Pass the beforeEach method as an asynchronous function lambda.
    3. Inside the lambda, create a constant variable called Expenses and assign it to what is returned from ExpensesPage():
      • await the result of ExpensesPage since it is an asynchronous server component.
    4. Call render and pass it Expenses.
    Example ExpensesPage describe block with beforeEach block
    describe("async Expenses server component", async () => {
      const transactions = await getTransactions();
      const transactionsIdsString = transactions
        .map((transaction) => transaction.id)
        .join(" ");
    
      beforeEach(async () => {
        const Expenses = await ExpensesPage();
        render(Expenses);
      });
    });
    

    Note: The difference between testing a server component and a client component with Vitest is that you must await the server component before you render it in the test. A client component is not asynchronous so this step is not necessary.


    Activity 2 - Write a Test to Assert that the ExpensesPage Component Renders a Header

    1. Under the afterEach block, call Vitest’s test.
    2. Name the test ”renders a header”.
    3. Pass a lambda function for the second parameter.
    4. Inside the lambda function, call screen.getByText("Expenses");.
      • Note: You did not have to wrap getByText in an expect block to create an assertion. This is because technically this will throw an error when it does not find anything with that text on the screen. This makes it so you do not have to wrap it in an expect block to see if it is defined if you choose not to.
    Example ExpensesPage describe block with first test block
    describe("async Expenses server component", async () => {
      const transactions = await getTransactions();
      const transactionsIdsString = transactions
        .map((transaction) => transaction.id)
        .join(" ");
    
      beforeEach(async () => {
        const Expenses = await ExpensesPage();
        render(Expenses);
      });
    
      afterEach(() => {
        cleanup();
      });
    
      test("renders a header", () => {
        screen.getByText("Expenses");
      });
    });
    

    --- ## Activity 3 - Mock the TransactionTable Component

    Vitest provides utility functions to help you out through its vi helper. vi.mock allows you to mock modules and change the functionality of the module in the test.

    The ExpensesPage server component renders the TransactionTable server component. You want to verify that the TransactionTable is rendered with another test without knowing the specifics of what the TransactionTable renders (also known as separation of concerns), this is where mocking steps in.

    You will mock the TransactionTable component in the app/(ui)/components/createTransactionForm.jsx file using Vitest’s vi utility. The component will now simply return a string of transaction ids separated by spaces.

    1. Above the describe block, call vi.mock.
    2. Pass the relative path to the TransactionTable component, ”../../../app/(ui)/components/transactionTable” as vi.mock’s first parameter.
    3. Pass a lambda function for the second parameter.
    4. The lambda function should return an object with the property default.
      • Note: This object that is returned will essentially become the signature for the mocked module. Since TableTransaction was a default export and not a named export, it is under the default property. In a future activity, you will have the chance to mock a module that is a named export.
    5. The value of the default property is another lambda function that takes props as a parameter and returns props.transactions.map((transaction) => transaction.id).join(" ");.
    Example ExpensesPage test file
    import { expect, test, describe, beforeEach, afterEach, vi } from "vitest";
    import { render, screen, cleanup } from "@testing-library/react";
    import ExpensesPage from "../../../app/(ui)/expenses/page";
    import TRANSACTION_TYPE from "../../../app/utils/transactionTypeEnum";
    import { promises as fs } from "fs";
    
    async function getTransactions() {
      const file = await fs.readFile(
        process.cwd() + "/app/lib/transactions/data/mockData.json",
        "utf-8"
      );
      const transactions = JSON.parse(file);
      return transactions.slice(0, 3);
    }
    
    vi.mock("../../../app/(ui)/components/transactionTable", () => ({
      default: (props) => {
        return props.transactions.map((transaction) => transaction.id).join(" ");
      },
    }));
    
    describe("async Expenses server component", async () => {
      const transactions = await getTransactions();
      const transactionsIdsString = transactions
        .map((transaction) => transaction.id)
        .join(" ");
    
      beforeEach(async () => {
        const Expenses = await ExpensesPage();
        render(Expenses);
      });
    
      afterEach(() => {
        cleanup();
      });
    
      test("renders a header", () => {
        screen.getByText("Expenses");
      });
    });
    
    

    Activity 4 - Keep Track of Props Passed to the TransactionTable Component by Spying on a Function

    vi.fn allows you to spy on functions and objects. vi.fn can be initialized with a function or without one. Every time a function is invoked, it stores its call arguments, returns, and instances. Also, you can manipulate its behavior with methods. If no function is given, mock will return undefined when invoked.

    You will create a mock and spy on a function with vi.fn in this activity.

    1. Above the vi.mock method call, initialize a constant variable titled mockTransactionTable and assign it to vi.fn().
    2. Inside the default value lambda function, call mockTransactionTable(props) before returning the string of ids.

    This will allow you to see what props were passed to TransactionTable when it was rendered by ExpensesPage in a future test. This is possible because of the spy on behavior vi.fn provides.

    Example ExpensesPage test file
    import { expect, test, describe, beforeEach, afterEach, vi } from "vitest";
    import { render, screen, cleanup } from "@testing-library/react";
    import ExpensesPage from "../../../app/(ui)/expenses/page";
    import TRANSACTION_TYPE from "../../../app/utils/transactionTypeEnum";
    import { promises as fs } from "fs";
    
    async function getTransactions() {
      const file = await fs.readFile(
        process.cwd() + "/app/lib/transactions/data/mockData.json",
        "utf-8"
      );
      const transactions = JSON.parse(file);
      return transactions.slice(0, 3);
    }
    
    const mockTransactionTable = vi.fn();
    vi.mock("../../../app/(ui)/components/transactionTable", () => ({
      default: (props) => {
        mockTransactionTable(props);
        return props.transactions.map((transaction) => transaction.id).join(" ");
      },
    }));
    
    describe("async Expenses server component", async () => {
      const transactions = await getTransactions();
      const transactionsIdsString = transactions
        .map((transaction) => transaction.id)
        .join(" ");
    
      beforeEach(async () => {
        const Expenses = await ExpensesPage();
        render(Expenses);
      });
    
      afterEach(() => {
        cleanup();
      });
    
      test("renders a header", () => {
        screen.getByText("Expenses");
      });
    });
    

    --- ## Activity 5 - Verify TransactionTable Is Rendered by ExpensesPage Using a Test

    1. Under the afterEach block, call Vitest’s test.
    2. Name the test ”renders a transactions table”.
    3. Pass a lambda function for the second parameter.
    4. Inside the lambda function, call screen.getByText(transactionsIdsString);.
    5. Inside the lambda function, assert that mockTransactionTable was called with an object that looks like { transactions: transactions }.
      • Note: You can call toHaveBeenCalledWith on any Vitest expect assertion.
      • Hint: Pass toHaveBeenCalledWith expect.objectContaining({ transactions: transactions }).
    Example ExpensesPage test file
    import { expect, test, describe, beforeEach, afterEach, vi } from "vitest";
    import { render, screen, cleanup } from "@testing-library/react";
    import ExpensesPage from "../../../app/(ui)/expenses/page";
    import TRANSACTION_TYPE from "../../../app/utils/transactionTypeEnum";
    import { promises as fs } from "fs";
    
    async function getTransactions() {
      const file = await fs.readFile(
        process.cwd() + "/app/lib/transactions/data/mockData.json",
        "utf-8"
      );
      const transactions = JSON.parse(file);
      return transactions.slice(0, 3);
    }
    
    const mockTransactionTable = vi.fn();
    vi.mock("../../../app/(ui)/components/transactionTable", () => ({
      default: (props) => {
        mockTransactionTable(props);
        return props.transactions.map((transaction) => transaction.id).join(" ");
      },
    }));
    
    describe("async Expenses server component", async () => {
      const transactions = await getTransactions();
      const transactionsIdsString = transactions
        .map((transaction) => transaction.id)
        .join(" ");
    
      beforeEach(async () => {
        const Expenses = await ExpensesPage();
        render(Expenses);
      });
    
      afterEach(() => {
        cleanup();
      });
    
      test("renders a header", () => {
        screen.getByText("Expenses");
      });
    
      test("renders a transactions table", () => {
        expect(mockTransactionTable).toHaveBeenCalledWith(
          expect.objectContaining({
            transactions: transactions,
          })
        );
        screen.getByText(transactionsIdsString);
      });
    });
    

    Activity 6 - Mock the getTransactions and getPlanCategories Server Actions

    At this point, if you save your progress and analyze the results of your tests by running npm run test, you will notice that the "renders a transactions table" test is failing. It will fail because the spy is not being called with what you would expect. The component is accessing database data instead of just using the mock data from the "/app/lib/transactions/data/mockData.json" file. To correct this, you will need to mock the getTransactions and getPlanCategories server actions.

    You will mock the getTransactions and getPlanCategories server actions in the app/lib/actions.js file using Vitest’s vi utility.

    1. Above the describe block, call vi.mock.

    2. Pass the relative path to the TransactionTable component, ”../../../app/lib/actions” as vi.mock’s first parameter.

    3. Pass a lambda function for the second parameter.

    4. The lambda function should return an object with the properties getTransactions and getPlanCategories.

      • Note: This object that is returned becomes the signature for the mocked module. Both of the methods you are mocking are named exports so the property should be the name of the method.
    5. The value of the getTransactions property is a lambda function that looks like the code block below:

      	async (transactionType) => {
      		return await getTransactions();
      	}
      
    6. The value of the getPlanCategories is a lambda function that looks like the code block below:

    	async (planType) => {
    		const transactions = await getTransactions();
    		return transactions.map((transaction) => {
    			return { id: transaction.planId, name: transaction.plan.name };
    		});
    	}
    ``` <details>
    	<summary>Example <code>ExpensesPage</code> test file</summary>
    	
    ```js
    import { expect, test, describe, beforeEach, afterEach, vi } from "vitest";
    import { render, screen, cleanup } from "@testing-library/react";
    import ExpensesPage from "../../../app/(ui)/expenses/page";
    import TRANSACTION_TYPE from "../../../app/utils/transactionTypeEnum";
    import { promises as fs } from "fs";
    
    async function getTransactions() {
      const file = await fs.readFile(
        process.cwd() + "/app/lib/transactions/data/mockData.json",
        "utf-8"
      );
      const transactions = JSON.parse(file);
      return transactions.slice(0, 3);
    }
    
    const mockTransactionTable = vi.fn();
    vi.mock("../../../app/(ui)/components/transactionTable", () => ({
      default: (props) => {
        mockTransactionTable(props);
        return props.transactions.map((transaction) => transaction.id).join(" ");
      },
    }));
    
    vi.mock("../../../app/lib/actions", () => ({
      getTransactions: async (transactionType) => {
        return await getTransactions();
      },
      getPlanCategories: async (planType) => {
        const transactions = await getTransactions();
        return transactions.map((transaction) => {
          return { id: transaction.planId, name: transaction.plan.name };
        });
      },
    }));
    
    describe("async Expenses server component", async () => {
      const transactions = await getTransactions();
      const transactionsIdsString = transactions
        .map((transaction) => transaction.id)
        .join(" ");
    
      beforeEach(async () => {
        const Expenses = await ExpensesPage();
        render(Expenses);
      });
    
      afterEach(() => {
        cleanup();
      });
    
      test("renders a header", () => {
        screen.getByText("Expenses");
      });
    
      test("renders a transactions table", () => {
        expect(mockTransactionTable).toHaveBeenCalledWith(
          expect.objectContaining({
            transactions: transactions,
          })
        );
        screen.getByText(transactionsIdsString);
      });
    });
    

    Now when quit any running testing executions by typing q in Terminal and you rerun the npm run test command to run all of your tests, all tests in the __tests__/(ui)/components/transactionTable.test.jsx and __tests__/(ui)/expenses/page.test.jsx files should pass. Your output should look like below:

    A screenshot of a terminal window showing the results of a test suite execution. It indicates that three test suites have failed, each with the error message "No test found in suite." Specifically, the errors occur in tests/lib/actions.test.js, under the "Transaction CRUD Server Actions" and "createTransaction" test cases. The summary at the bottom shows that 1 test file failed, 2 passed, and the overall test count is 3, with 3 tests passing and 1 failing.

  5. Challenge

    Test the `createTransaction` Server Action

    Introduction

    In this step, you will be implementing the ”createTransaction” test suite found in the __tests__/lib/actions.test.js file.

    Vitest associates this test file with the code found in the app/lib/actions.js file.

    Throughout this step, you will use the utilize all the Vitest methods you have learned about in this lab so far (e.g. expect, test, describe, beforeEach, afterEach, and vi).

    The test file you have been given to test server actions already imports the appropriate server actions from the app/lib/actions.js file and all other modules you will need to complete this step.


    Activity 1 - Mock the revalidatePath Method from the External Module “next/cache”

    You cannot call revalidatePath when testing a Next.js application or you get the following error:

    Error: Invariant: static generation store missing in revalidatePath “path”
    

    The createTransaction server action calls revalidatePath on a few different static paths to force a rerender of the associated server components. In order to prevent this error when you write a future test around the createTransaction server action, you will mock ”next/caches” revalidatePath method.

    1. Above the "Transaction CRUD Server Actions" describe block, call vi.mock.
    2. Pass the ”next/cache” module path as vi.mock’s first parameter.
    3. Pass a lambda function for the second parameter.
    4. The lambda function should return an object with the property revalidatePath.
    5. The value of the revalidatePath property is a lambda function that looks like the code block below:
    (path) => {
      return "Path Validated Once More";
    },
    
    Example actions.test.js
    import { expect, test, describe, beforeEach, afterEach, vi } from "vitest";
    import { promises as fs } from "fs";
    import {
      createTransaction,
      getTransaction,
      getTransactions,
      updateTransaction,
      deleteTransaction,
    } from "../../app/lib/actions";
    import TRANSACTION_TYPE from "../../app/utils/transactionTypeEnum";
    import prisma from "../../app/__mocks__/db";
    
    async function getTestTransactions() {
      const file = await fs.readFile(
        process.cwd() + "/app/lib/transactions/data/mockData.json",
        "utf-8"
      );
      const transactions = JSON.parse(file);
      return transactions;
    }
    vi.mock("next/cache", () => ({
      revalidatePath: (path) => {
        return "Path Validated Once More";
      },
    }));
    
    describe("Transaction CRUD Server Actions", () => {
      const oneMonthCadence = {
        id: "1",
        name: "1-month",
      };
      const expenseType = {
        id: "1",
        name: TRANSACTION_TYPE.EXPENSE,
      };
      const mortgagePlan = {
        id: "1",
        budgetAmount: 5000,
        name: "Mortgage",
        description: "A Place To Live",
        cadenceId: "1",
        typeId: "1",
      };
    
      let testTransactions;
    
      beforeEach(async () => {
        testTransactions = await getTestTransactions();
      });
    
      describe("createTransaction", () => {
        const formDataObject = {
          date: "10-12-2002",
          amount: "5000",
          notes: "",
          category: "Mortgage",
        };
        const expectedNewTransaction = {
          date: "2002-10-12T06:00:00.000Z",
          amount: "5000",
          notes: "",
          planId: "1",
          id: "1",
        };
        const formData = new FormData();
    
        beforeEach(() => {
          for (var key in formDataObject) {
            formData.append(key, formDataObject[key]);
          }
        });
      });
    });
    

    Activity 2 - Write a Test to Verify the Return Value from the createTransaction Server Action

    1. In the ”createTransaction” describe block and under this describe’s beforeEach block, call Vitest’s test.
    2. Name the test ”returns the new transaction in JSON format”.
    3. Pass a lambda function for the second parameter.
    4. Inside the lambda function, await the results of calling createTransaction with TRANSACTION_TYPE.EXPENSE and formData as parameters respectively.
    5. Store the result of createTransaction in a constant variable titled transaction.
    6. Assert that transaction equals the expectedNewTransaction object initialized earlier in ”createTransaction” test suite.
      • Note: This should be a deep comparison which can be done with the chai toStrictEqual assertion.
    Example createTransaction describe block
    describe("createTransaction", () => {
        const formDataObject = {
          date: "10-12-2002",
          amount: "5000",
          notes: "",
          category: "Mortgage",
        };
        const expectedNewTransaction = {
          date: "2002-10-12T06:00:00.000Z",
          amount: "5000",
          notes: "",
          planId: "1",
          id: "1",
        };
        const formData = new FormData();
    
        beforeEach(() => {
          for (var key in formDataObject) {
            formData.append(key, formDataObject[key]);
          }
        });
    
        test("returns the new transaction in JSON format", async () => {
          const transaction = await createTransaction(
            TRANSACTION_TYPE.EXPENSE,
            formData
          );
    
          expect(transaction).toStrictEqual({
            ...expectedNewTransaction,
          });
        });
      });
    

    Activity 3 - Mock the Prisma Client

    In situations like unit testing, which focus on a single function, the best practice is to assume that your database operations will behave correctly and use a mocked version of your client or driver instead. This will allow you to focus on testing the specific behavior of the function you are targeting without dealing with specifics of other functions and modules. This practice aligns with what you may have heard as "separation of concerns."

    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 examples of these cases. These tests may rely on multiple database operations occurring across multiple functions and areas of your application.

    If you run your tests at this point, you will notice that the result from the createTransaction server action is not what you expected. If you look closely, you will notice it is the id and planId that is never what you expect because it is a random guid determined by Prisma.

    To fix this issue, you will mock the Prisma Client so you are not interacting with the database when running tests. This will allow you to establish what ids you expect to receive from the createTransaction server action.

    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.

    1. Above the "Transaction CRUD Server Actions" describe block, call vi.mock.
    2. Pass ../../app/db as the module to mock for the first parameter.
      • Note: This time you will not supply a factory for what to return when this mock is called.
      • Note: app/db.ts is where prisma is defined globally to be an instance of a PrismaClient. You are mocking this module because you want to override the global instance of prisma with a mocked version of PrismaClient.
    Example mock to add to actions.test.js
    vi.mock("../../app/db");
    

    Activity 4 - Deep Mock the Prisma Client

    Currently, Vitest will attempt to mock the module found in the app/db.ts file, however it will not be able to automatically mock the "deep" or "nested" properties of the prisma object. For example, prisma.transaction.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 run as it normally does against the real database.

    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.

    Inside the app/__mocks__/db.ts file, do the following:

    1. Call Vitest’s beforeEach method to reset the prisma mock before each test is ran.
      • Note: This is done by calling the ”vitest-mock-extended” mockReset method and passing prisma as a parameter.
    2. Assign the constant variable prisma to mockDeep<PrismaClient>(); instead of a new instance of PrismaClient.
      • Note: Essentially, mockDeep will set every Prisma Client function's value to vi.fn().

    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.

    Example app/__mocks__/db.ts file contents
    import { PrismaClient } from "@prisma/client";
    import { beforeEach } from "vitest";
    import { mockDeep, mockReset } from "vitest-mock-extended";
    
    beforeEach(() => {
      mockReset(prisma);
    });
    
    const prisma = mockDeep<PrismaClient>();
    export default prisma;
    

    Before running your tests again, import the mocked Prisma Client variable prisma from the app/__mocks__/db.ts file in the __tests__/lib/actions.test.js file.

    Example mocked prisma import
    import prisma from "../../app/__mocks__/db";
    

    Activity 5 - Mock Prisma Client Methods

    If you run your tests now, you will now see the following type error for the ”returns the new transaction in JSON format” test:

    	TypeError: Cannot read properties of undefined (reading 'id')
    

    This error actually occurs because the prisma mock has been put in place correctly. Your prisma.type.findUnique invocation in the getTypeByName server action in the app/lib/actions.js file is no longer hitting the database. Currently, that function essentially does nothing and returns undefined. Then the getPlanByTypeAndPlanName server action tries to access id on undefined which is a TypeError.

    You need to tell Vitest what prisma.type.findUnique, prisma.plan.findUnique, and prisma.transaction.create should do by mocking its behavior. Now that you have a proper mocked version of the Prisma Client, you need to do a simple change to your test’s before action.

    1. Add the following code to your beforeEach lambda function inside the ”createTransaction” describe block:
    prisma.transaction.create.mockResolvedValue({
      ...expectedNewTransaction,
    });
    prisma.type.findUnique.mockResolvedValue({
      ...expenseType,
    });
    prisma.plan.findUnique.mockResolvedValue({
      ...mortgagePlan,
    });
    
    Example actions.test.js file contents
    import { expect, test, describe, beforeEach, afterEach, vi } from "vitest";
    import { promises as fs } from "fs";
    import {
      createTransaction,
      getTransaction,
      getTransactions,
      updateTransaction,
      deleteTransaction,
    } from "../../app/lib/actions";
    import TRANSACTION_TYPE from "../../app/utils/transactionTypeEnum";
    import prisma from "../../app/__mocks__/db";
    
    async function getTestTransactions() {
      const file = await fs.readFile(
        process.cwd() + "/app/lib/transactions/data/mockData.json",
        "utf-8"
      );
      const transactions = JSON.parse(file);
      return transactions;
    }
    
    vi.mock("next/cache", () => ({
      revalidatePath: (path) => {
        return "Path Validated Once More";
      },
    }));
    
    vi.mock("../../app/db");
    
    describe("Transaction CRUD Server Actions", () => {
      const oneMonthCadence = {
        id: "1",
        name: "1-month",
      };
      const expenseType = {
        id: "1",
        name: TRANSACTION_TYPE.EXPENSE,
      };
      const mortgagePlan = {
        id: "1",
        budgetAmount: 5000,
        name: "Mortgage",
        description: "A Place To Live",
        cadenceId: "1",
        typeId: "1",
      };
    
      let testTransactions;
    
      beforeEach(async () => {
        testTransactions = await getTestTransactions();
      });
    
      describe("createTransaction", () => {
        const formDataObject = {
          date: "10-12-2002",
          amount: "5000",
          notes: "",
          category: "Mortgage",
        };
        const expectedNewTransaction = {
          date: "2002-10-12T06:00:00.000Z",
          amount: "5000",
          notes: "",
          planId: "1",
          id: "1",
        };
        const formData = new FormData();
    
        beforeEach(() => {
          for (var key in formDataObject) {
            formData.append(key, formDataObject[key]);
          }
          prisma.transaction.create.mockResolvedValue({
            ...expectedNewTransaction,
          });
          prisma.type.findUnique.mockResolvedValue({
            ...expenseType,
          });
          prisma.plan.findUnique.mockResolvedValue({
            ...mortgagePlan,
          });
        });
    
        test("returns the new transaction in JSON format", async () => {
          const transaction = await createTransaction(
            TRANSACTION_TYPE.EXPENSE,
            formData
          );
    
          expect(transaction).toStrictEqual({
            ...expectedNewTransaction,
          });
        });
      });
    });
    

    After this, all three test files should pass and all four test methods implemented in this lab should pass. If you quit any running test executions in Terminal by typing q and rerunning tests with the command npm run test, your output should look like the picture below. You can ignore any warnings and only focus on what comes after them.

    A screenshot of a terminal window showing the results of a successful test suite execution. It indicates that all tests have passed. Specifically, 3 test files were executed, resulting in 4 tests being passed. The files listed include tests/lib/actions.test.js, tests/ui/components/transactionTable.test.jsx, and tests/ui/expenses/page.test.jsx

  6. Challenge

    Conclusion

    Conclusion

    In this lab, you became familar with how Vitest is used to test an existing Next.js application. You tested a Next.js client component, server component, and server action using Vitest. You can see how you were able to test backend and frontend code using Vitest. In addition, you learned about the shortcomings of Vitest when it comes to testing server components. You mocked internal and external modules to complete tests and maintained separation of concerns. The finance application in the lab is well on its way to being well tested at this point, which is a huge accomplishment!

Jaecee is an associate author at Pluralsight helping to develop Hands-On content. Jaecee's background in Software Development and Data Management and Analysis. Jaecee holds a graduate degree from the University of Utah in Computer Science. She works on new content here at Pluralsight and is constantly learning.

What's a lab?

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Provided environment for hands-on practice

We will provide the credentials and environment necessary for you to practice right within your browser.

Guided walkthrough

Follow along with the author’s guided walkthrough and build something new in your provided environment!

Did you know?

On average, you retain 75% more of your learning if you get time for practice.