- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
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.
Lab Info
Table of Contents
-
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 devin 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 Clientusing 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
TransactionTableclient component. This will use Vitest alongside React Testing Library to create unit tests for theTransactionTablecomponent. 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
ExpensesPageserver component. You will learn about the limitations of Vitest when it comes to testingasyncserver components and how to mock other client component modules that are being rendered from theExpensesPagecomponent.Lastly, you will mock the Prisma’s
Prisma Clientmodule and test transactions CRUD server actions. This will cover what a deep mock is from thevitest-mock-extendedlibrary 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
solutiondirectory with correspondingstepdirectories 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 devin the Terminal. The application can be seen in the Web Browser tab atlocalhost: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:
Remember: Throughout the lab, any code changes within the
appdirectory 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 pressingCtrl+Cin the Terminal and rerunningnpm 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. -
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 usingnpmin 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/reactlibraries, so these libraries have also been installed for you using thenpm install -D @vitejs/plugin-react jsdom @testing-library/react vitest-mock-extendedcommand.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.jsfile to configure Vitest as follows:- Add a
testproperty in yourdefineConfigparameter object. - Set the
testproperty to be an empty object. - Inside the empty object, create an
environmentproperty. - Set the
environmentproperty to be”jsdom”.- This lab uses
jsdomas theenvironment, but if you ever wanted to use a differentenvironmentthis is where you would set it.
- This lab uses
- Add a
pluginsproperty at the same level as thetestproperty. - Set the
pluginsproperty to be an empty list. - Inside the empty list, call the already imported
reactmodule.- 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.
- This lab uses
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.jsfile is used to override the configuration ofvite.config.js(if there is any) and configure Vitest. The finance application at hand does not rely on Vite specifically, which is why thevitest.config.jsfile is used to configure vitest instead ofvite.config.js.
Activity 2 - Test Script
Next, add a script named
testthat simply runs the commandvitestinside yourpackage.jsonfile. 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 testin the Terminal to run your application’svitesttests. 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 anddescribeblock. Currently, the application has six test suites (made up of three test files and threedescribeblocks). 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 testin Terminal, and your output should match the screenshot below:
- Add a
-
Challenge
Test the `TransactionTable` Client Component
Introduction
In this step, you will be implementing the tests found in the
__tests__/(ui)/components/transactionTable.test.jsxdirectory.Vitest links this test file with the component found in the
app/(ui)/components/transactionTable.jsxdirectory. 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.testbefore the file extension.Throughout this step, you will become familiar with different Vitest API options (e.g.
expect,test,describe,beforeEach, andafterEach). You will also userender,screen, andcleanupfrom the React Testing Library to test theTransactionTableclient component.The test file you have been given to test the
TransactionTableclient component already imports theTransactionTablecomponent and other methods or modules you will use. This includes all imports you will need fromvitestand@testing-library/reactto complete this step.It may be helpful to also look at the
app/lib/transactions/data/mockData.jsonfile 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 thegetTransactionsmethod already given to you in the__tests__/(ui)/components/transactionTable.test.jsxfile.
Activity 1 - Create the
TransactionTableComponent’s Test SuiteTest suites can be created implicitly (with a test at the top level of a test file) or explicitly using Vitest’s
describe. By usingdescribeyou 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.describetakes 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 theTransactionTableclient component.- Under the
getTransactionsmethod, start adescribeblock. - Name the test suite
”Transaction Table client component”. - For now have an empty test suite by passing
() => {}as the second parameter.
Example
TransactionTabledescribeblockdescribe("Transaction Table client component", () => {});
Activity 2 - Before Each Test in the
TransactionTableTest Suite Render theTransactionTableVitest’s
beforeEachis used to set up a testing suite.beforeEachregisters a callback to be called before each of the tests in the current context runs.beforeEachtakes 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’srenderto render theTransactionTablebefore each test.- In the
describeblock from the first activity, callbeforeEach. - Pass the
beforeEachmethod as an asynchronous function lambda. - Inside the lambda, assign a variable called
transactionsto what is returned fromgetTransactions():awaitthe result ofgetTransactions.- Define the
transactionsvariable without assigning it to anything at the top of thedescribeblock.
- Call
renderwith theTransactionTablecomponent withtransactionsfor thetransactionsprop.
Example
TransactionTabledescribeblock withbeforeEachblockdescribe("Transaction Table client component", () => { let transactions; beforeEach(async () => { transactions = await getTransactions(); render(<TransactionTable transactions={transactions} />); }); });
Activity 3 - After Each Test Cleanup the
TransactionTableComponentVitest’s
afterEachmethod works similarly tobeforeEachexcept 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.- Under the
beforeEachblock, callafterEach. - Pass a function that calls React Testing Library’s
cleanupmethod.
Example
TransactionTabledescribeblock withafterEachblockdescribe("Transaction Table client component", () => { let transactions; beforeEach(async () => { transactions = await getTransactions(); render(<TransactionTable transactions={transactions} />); }); afterEach(() => { cleanup(); }); });
Activity 4 - Assert that the
TransactionTableComponent Renders Transaction DataVitest’s
testmethod defines a set of related expectations. It receives the test name and a function that holds the expectations to test. Vitest’sexpectis used to create assertions. In this context,assertionsare 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
testand useexpectto assert that certain text appears on the React Testing Library’sscreen.- Under the
afterEachblock, call Vitest’stest. - Name the test
”displays each transaction's data in a table”. - Pass a lambda function for the second parameter.
- Inside the lambda function iterate over every
transactionusing Javascript’sforEachmethod on thetransactionsarray. - Use Vitest’s
expectto assert thatscreen.getByText(transaction.amount)is defined.
Example
TransactionTabledescribeblock with firsttestblockdescribe("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
TransactionTableclient component. If you wanted to continue to extensively test theTransactionTablecomponent, 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 testrunning 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:
For the rest of this lab, please quit your test execution in Terminal by typing
qand then rerun thenpm run testcommand. 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:
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 componentis passing. This is because of the test you wrote in this step. This step is in theTransaction Table client componenttest suite and the__tests__/(ui)/components/transactionTable.test.jsxtest 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 thecanaryversion of React this error would go away, but given it is still experimental, this lab does not run off of thecanaryversion of React. - Under the
-
Challenge
Test the `ExpensesPage` Server Component
Introduction
In this step, you will be implementing the tests found in the
__tests__/(ui)/expenses/page.test.jsxfile.Vitest associates this test file with the component found in the
app/(ui)/expenses/page.jsxfile.Throughout this step, you will use the Vitest methods you learned about in the previous step (e.g.
expect,test,describe,beforeEach, andafterEach). You will userender,screen, andcleanupfrom the React Testing Library like in the previous step to test theExpensesPageserver component. In this step, you will learn about Vitest’sviutility to help you create mocks on modules.The test file you have been given to test the
ExpensesPageserver component already imports theExpensesPagecomponent and all other methods or modules you will use to complete this step.The
ExpensesPagetesting suite has already been created for you within thedescribeblock 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
ExpensesPageTest Suite Render theExpensesPageVitest 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
asyncserver components are new to the React ecosystem. For this reason, you will often see end-to-end (E2E) tests usingplaykitto test server components. In this step, you will test theasyncExpensesPageserver component similar to how you would test a client component. You willawaitthe 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
beforeEachcall is used to set up a testing suite.beforeEachregisters a callback before each of the tests in the current context runs.beforeEachtakes 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’srenderto render theTransactionTablebefore each test.- In the
ExpensesPagedescribeblock, callbeforeEach. - Pass the
beforeEachmethod as an asynchronous function lambda. - Inside the lambda, create a constant variable called
Expensesand assign it to what is returned fromExpensesPage():awaitthe result ofExpensesPagesince it is an asynchronous server component.
- Call
renderand pass itExpenses.
Example
ExpensesPagedescribeblock withbeforeEachblockdescribe("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
awaitthe 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
ExpensesPageComponent Renders a Header- Under the
afterEachblock, call Vitest’stest. - Name the test
”renders a header”. - Pass a lambda function for the second parameter.
- Inside the lambda function, call
screen.getByText("Expenses");.- Note: You did not have to wrap
getByTextin anexpectblock 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 anexpectblock to see if it is defined if you choose not to.
- Note: You did not have to wrap
Example
ExpensesPagedescribeblock with firsttestblockdescribe("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
TransactionTableComponentVitest provides utility functions to help you out through its
vihelper.vi.mockallows you to mock modules and change the functionality of the module in the test.The
ExpensesPageserver component renders theTransactionTableserver component. You want to verify that theTransactionTableis rendered with another test without knowing the specifics of what theTransactionTablerenders (also known as separation of concerns), this is where mocking steps in.You will mock the
TransactionTablecomponent in theapp/(ui)/components/createTransactionForm.jsxfile using Vitest’sviutility. The component will now simply return a string of transaction ids separated by spaces.- Above the
describeblock, callvi.mock. - Pass the relative path to the
TransactionTablecomponent,”../../../app/(ui)/components/transactionTable”asvi.mock’s first parameter. - Pass a lambda function for the second parameter.
- 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
TableTransactionwas a default export and not a named export, it is under thedefaultproperty. In a future activity, you will have the chance to mock a module that is a named export.
- Note: This object that is returned will essentially become the signature for the mocked module. Since
- The value of the
defaultproperty is another lambda function that takespropsas a parameter and returnsprops.transactions.map((transaction) => transaction.id).join(" ");.
Example
ExpensesPagetest fileimport { 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
TransactionTableComponent by Spying on a Functionvi.fnallows you to spy on functions and objects.vi.fncan 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.fnin this activity.- Above the
vi.mockmethod call, initialize a constant variable titledmockTransactionTableand assign it tovi.fn(). - Inside the
defaultvalue lambda function, callmockTransactionTable(props)before returning the string of ids.
This will allow you to see what props were passed to
TransactionTablewhen it was rendered byExpensesPagein a future test. This is possible because of the spy on behaviorvi.fnprovides.Example
ExpensesPagetest fileimport { 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
TransactionTableIs Rendered byExpensesPageUsing a Test- Under the
afterEachblock, call Vitest’stest. - Name the test
”renders a transactions table”. - Pass a lambda function for the second parameter.
- Inside the lambda function, call
screen.getByText(transactionsIdsString);. - Inside the lambda function, assert that
mockTransactionTablewas called with an object that looks like{ transactions: transactions }.- Note: You can call
toHaveBeenCalledWithon any Vitestexpectassertion. - Hint: Pass
toHaveBeenCalledWithexpect.objectContaining({ transactions: transactions }).
- Note: You can call
Example
ExpensesPagetest fileimport { 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
getTransactionsandgetPlanCategoriesServer ActionsAt 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 thegetTransactionsandgetPlanCategoriesserver actions.You will mock the
getTransactionsandgetPlanCategoriesserver actions in theapp/lib/actions.jsfile using Vitest’sviutility.-
Above the
describeblock, callvi.mock. -
Pass the relative path to the
TransactionTablecomponent,”../../../app/lib/actions”asvi.mock’s first parameter. -
Pass a lambda function for the second parameter.
-
The lambda function should return an object with the properties
getTransactionsandgetPlanCategories.- 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.
-
The value of the
getTransactionsproperty is a lambda function that looks like the code block below:async (transactionType) => { return await getTransactions(); } -
The value of the
getPlanCategoriesis 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
qin Terminal and you rerun thenpm run testcommand to run all of your tests, all tests in the__tests__/(ui)/components/transactionTable.test.jsxand__tests__/(ui)/expenses/page.test.jsxfiles should pass. Your output should look like below:
- In the
-
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.jsfile.Vitest associates this test file with the code found in the
app/lib/actions.jsfile.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, andvi).The test file you have been given to test server actions already imports the appropriate server actions from the
app/lib/actions.jsfile and all other modules you will need to complete this step.
Activity 1 - Mock the
revalidatePathMethod from the External Module“next/cache”You cannot call
revalidatePathwhen testing a Next.js application or you get the following error:Error: Invariant: static generation store missing in revalidatePath “path”The
createTransactionserver action callsrevalidatePathon 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 thecreateTransactionserver action, you will mock”next/caches”revalidatePathmethod.- Above the
"Transaction CRUD Server Actions"describeblock, callvi.mock. - Pass the
”next/cache”module path asvi.mock’s first parameter. - Pass a lambda function for the second parameter.
- The lambda function should return an object with the property
revalidatePath. - The value of the
revalidatePathproperty is a lambda function that looks like the code block below:
(path) => { return "Path Validated Once More"; },Example
actions.test.jsimport { 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
createTransactionServer Action- In the
”createTransaction”describeblock and under thisdescribe’sbeforeEachblock, call Vitest’stest. - Name the test
”returns the new transaction in JSON format”. - Pass a lambda function for the second parameter.
- Inside the lambda function,
awaitthe results of callingcreateTransactionwithTRANSACTION_TYPE.EXPENSEandformDataas parameters respectively. - Store the result of
createTransactionin a constant variable titledtransaction. - Assert that
transactionequals theexpectedNewTransactionobject initialized earlier in”createTransaction”test suite.- Note: This should be a deep comparison which can be done with the
chaitoStrictEqualassertion.
- Note: This should be a deep comparison which can be done with the
Example
createTransactiondescribeblockdescribe("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
createTransactionserver action is not what you expected. If you look closely, you will notice it is theidandplanIdthat is never what you expect because it is a random guid determined by Prisma.To fix this issue, you will mock the
Prisma Clientso you are not interacting with the database when running tests. This will allow you to establish what ids you expect to receive from thecreateTransactionserver 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.
- Above the
"Transaction CRUD Server Actions"describeblock, callvi.mock. - Pass
../../app/dbas 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.tsis whereprismais defined globally to be an instance of aPrismaClient. You are mocking this module because you want to override the global instance ofprismawith a mocked version ofPrismaClient.
Example mock to add to
actions.test.jsvi.mock("../../app/db");
Activity 4 - Deep Mock the Prisma Client
Currently, Vitest will attempt to mock the module found in the
app/db.tsfile, however it will not be able to automatically mock the "deep" or "nested" properties of theprismaobject. For example,prisma.transaction.create()will not be mocked properly as it is a deeply nested property of thePrisma Clientinstance. 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.tsfile, do the following:- Call Vitest’s
beforeEachmethod to reset theprismamock before each test is ran.- Note: This is done by calling the
”vitest-mock-extended”mockResetmethod and passingprismaas a parameter.
- Note: This is done by calling the
- Assign the constant variable
prismatomockDeep<PrismaClient>();instead of a new instance ofPrismaClient.- Note: Essentially,
mockDeepwill set everyPrisma Clientfunction's value tovi.fn().
- Note: Essentially,
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.tsfile contentsimport { 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 Clientvariableprismafrom theapp/__mocks__/db.tsfile in the__tests__/lib/actions.test.jsfile.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
prismamock has been put in place correctly. Yourprisma.type.findUniqueinvocation in thegetTypeByNameserver action in theapp/lib/actions.jsfile is no longer hitting the database. Currently, that function essentially does nothing and returnsundefined. Then thegetPlanByTypeAndPlanNameserver action tries to accessidonundefinedwhich is aTypeError.You need to tell Vitest what
prisma.type.findUnique,prisma.plan.findUnique, andprisma.transaction.createshould 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.- Add the following code to your
beforeEachlambda function inside the”createTransaction”describeblock:
prisma.transaction.create.mockResolvedValue({ ...expectedNewTransaction, }); prisma.type.findUnique.mockResolvedValue({ ...expenseType, }); prisma.plan.findUnique.mockResolvedValue({ ...mortgagePlan, });Example
actions.test.jsfile contentsimport { 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
qand rerunning tests with the commandnpm run test, your output should look like the picture below. You can ignore any warnings and only focus on what comes after them.
- Above the
-
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!
About the author
Real skill practice before real-world application
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.
Learn by doing
Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.
Follow your guide
All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.
Turn time into mastery
On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.