Overview
Unit tests should be small, fast, and numerous. One big challenge is testing components that interact with a network service or external database. We don't want to actually call a network API or login a user in a unit test for many reasons. Such tests would be slow, might cost money, and might require private information, such passwords and tokens, to be stored in code files. The solution is to mock those calls, i.e., replace the functions that interact with the network or database with functions that simulate such calls.
For this walk-through, we'll show how to unit test the Quick, React tutorial, unchanged except for some cleanup, using mocks to avoid actually connecting to the network or Firebase services. This tutorial uses Vitest and the React Testing Library that come with React.
Unit testing without mocks
Assume we want to check that a course scheduling app correctly fetches data from a database, like the Firebase Realtime Database, for the academic year 2018-2019. Here's code using Vitest that checks for the presence of on-screen text with the correct years.
import { it } from 'vitest'; import { render, screen } from '@testing-library/react'; import App from './App'; it('shows the schedule year', () => { render(<App />); screen.getByText(/2018-2019/); });
This code is in red because it doesn't work. If you run the tests, you will see the test fail. Why? Because when the scheduler app first starts, it has no schedule data. The data is loaded asynchronously, so the first rendering of the app is empty. That first rendering is what the test sees, so it fails.
We can fix this by using a findBy query. These methods are designed for testing for elements that appear after some delay. Since these methods are asynchronous, we need to add async and await to our test code.
it('shows the schedule year', async () => { render(<App />); await screen.findByText(/2018-2019/); });
If your network is reasonably fast, the test should pass. If not, make the query wait a little longer by passing a third argument to findByText() like this:
await screen.findByText(/.../, {}, { timeout: 3000 })
But this is a poor solution for a number of reasons:
- The test is slow. Waiting a second to detect failure is forever in computer time.
- The test is depending on the network being up.
- The test will fail when data for the next year is added.
Even worse, this approach would be terrible for testing interactions with the app that save data. You should never let untested development code write test data to your real database, nor do you want to pay for thousands of test calls to a third-party information servce.
This is why mocking is important. Mocking means replacing calls to external services -- for data or authentication -- with calls to local functions that simulate the behavior of those services. Mocking is useful for fast cheap safe testing. It is also useful for early development when some service might not yet be available, e.g., communication with hardware yet to be built.
Mocking a network call
The simplest case of a third-party service is making a network call, such as a GET or POST to some URL. To demonstrate, here's code for an app that has a button to get a list of users from DummyJSON when clicked. Our test code checks that there are no users initially, but there are after the button is clicked.
import React, { useState } from 'react'; export const App = () => { const [users, setUsers] = useState([]); const fetchUsers = async () => { const response = await fetch('https://dummyjson.com/users'); const data = await response.json(); setUsers(data.users); // `users` is a field in the returned JSON }; return ( <div> <button onClick={fetchUsers}>Get Users</button> <ul> {users.map(user => ( <li key={user.id}> {user.firstName} {user.lastName} </li> ))} </ul> </div> ); }
import {describe, test} from 'vitest'; import {fireEvent, render, screen} from '@testing-library/react'; import { server } from './mocks/server'; import App from './App'; beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); describe('users load only when requested', () => { test("no users should appear on launch", () => { render(<App />); screen.getByText(/Get Users/i); screen.queryByText(/Emily Johnson/i).not.toBeInTheDocument; }); test("users appear when button clicked", async () => { render(<App />); const counter = screen.getByRole('button'); fireEvent.click(counter); await screen.findByText(/Emily Johnson/i); }); });
When unit testing, we don't want to do a real network call. It's slow, it might cost money if we're using a third-party API, and it's fragile because we're not in control of the data returned.
For this situation, the Vitest documentation recommends using Mock Service Workers (MSW). MSW is a library creates a local server that intercepts network calls. Any calls that match URLs that we have specified will be intercepted and return the data we have defined.
The MSW API changed greatly in November 2024. Beware of any code on the web from before 2025 or created by generative AI.
First, install MSW:
npm install -D msw@latest
Then create a file to define an MSW server that intercepts the network calls we want to mock. We'll put it a mocks folder.
import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; const handlers = [ http.get('https://dummyjson.com/users', (info) => { return HttpResponse.json([ { "users": [ { "firstName": "Emily", "lastName": "Johnson", "maidenName": "Smith", "email": "emily.johnson@x.dummyjson.com" }, { "firstName": "Michael", "lastName": "Williams", "maidenName": "", "email": "michael.williams@x.dummyjson.com" }, { "firstName": "Sophia", "lastName": "Brown", "maidenName": "", "email": "sophia.brown@x.dummyjson.com" } ], "total": 3, "skip": 0, "limit": 30 } ]) }) ]; export const server = setupServer(...handlers);
This defines an MSW server that intercepts GET requests to the URL https://dummyjson.com/users and returns a few users with just the data we care about for testing. This gives us total control over what data is in the test set. This lets us test for special cases, e.g., names with special characdters, without polluting a real database.
You call server.listen() to start intercepting all HTTP requests. This should be done before running your tests. The best place to do this is in a Vitest setup file, so that is run before any tests in any files. For brevity, we'll put it in the test file.
For mocking HTTP POST requests and responses, header information, and such, see the MSW documentation. You can even mock error responses.
When your network call involves returning a large JSON object, it's often easier to put the data in a separate file and import it. This makes the test code easier to read and maintain. See the Vite documentation for examples.
Mocking Firebase
Firebase code communicates by opening a network socket. We can't just intercept GET and POST calls. MSW does not support the socket library that Firebase uses. That library has its own mocking system, but we will mock the Firebase service itself instead.
Mocking Firebase is simplest if all Firebase calls are in a single module. This is a good practice anyway, because it makes it easy to change the database service without changing the rest of the app. In the Quick, React tutorial, all Firebase calls are in a module called firebase.js.
Here's a minimal example, where firebase.js defines a hook useData() that gets (and subscribes) to data at some path in a Firebase Realtime database, and App.jsx uses that hook to get a course schedule. It returns a tuple of three values:
- the schedule, if loaded
- a boolean that is true only if the data has not arrived yet and there has been no error
- an error object, if any, or null
import { useState } from 'react'; import { getDatabase, initializeApp } from "firebase/app"; const firebase = initializeApp(...); const database = getDatabase(firebase); export const useData = (path) => { const [data, setData] = useState([undefined, true, null]); useEffect(() => ( onValue(ref(database, path), (snapshot) => { setData( [snapshot.val(), false, null] ); }, (error) => { setData([undefined, false, error]); }) ), [ path ]); return data; };
import { useData } from './utilities/firebase'; const App = () => { const [schedule, loading, error] = useData('/'); if (error) return <h1>There was an error loading the schedule: {`${error}`}</h1>; if (loading) return <h1>Loading ...</h1>; if (!schedule) return <h1>No schedule found</h1>; return ( <div> <h1>{schedule.title}</h1> <ul> { Object.values(schedule.courses).map(course => <li>CS {course.number} {course.title}</li>) } </ul> </div> ); };
To mock a module,
- import the functions from the module as usual
- call vi.mock() to tell Vitest to the module functions with mock functions
- define what the mock functions should return for each test
When you mock a module, Vitest will replace every exported function with a mock
definition that, by default, does nothing and returns undefined
.
It uses
vi.fn()
to create an empty mock function for every function exported from the module.
We can assign specific behavior to the mocked functions for each test. To define the same behavior for every test, use beforeEach().
We must mock every module function that is called by the component we are rendering,
and its subcomponents. Otherwise, your code may
break when it calls a mocked function that returns undefined
.
There are many ways to define what a mocked function should do, but the two most common ways are:
-
Use
mocked-function.mockReturnValue(value)
to specify the return value of a normal function. -
Use
mocked-function.mockResolvedValue(value)
to specify the return value for an async function, i.e., a function that returns a promise.
Since useData() is not asynchronous, i.e., it does not return a promise, we use mockReturnValue to specify its behavior. For our "happy path" test we define the mock version of useData() to return the mock schedule. In real testing, we would also write tests to check that the app does what we want when an error occurs.
import { it } from 'vitest'; import { render, screen } from '@testing-library/react'; import { useData }from './utilities/firebase'; import App from './App'; vi.mock('./utilities/firebase'); const mockSchedule = { "title": "CS Courses for 1850-1851", "courses": { "F110": { "term": "Fall", "number": "110", "meets": "MWF 10:00-10:50", "title": "Intro Programming for non-majors" }, "F111": { "term": "Fall", "number": "111", "meets": "MWF 13:00-13:50", "title": "Fundamentals of Computer Programming I" }, "F211": { "term": "Fall", "number": "211", "meets": "MWF 12:30-13:50", "title": "Fundamentals of Computer Programming II" }, } }; describe('launch tests', () => { it('shows the fall schedule on launch year', async () => { useData.mockReturnValue([mockSchedule, false, null]); render(<App />); await screen.getByText(/CS Courses for 1850-1851"/i); screen.getByText(/CS 110 Intro Programming for non-majors/i); }); }
Note that the unit tests can use getByText() because there is no asynchronous code involved.
Mocking storing data on Firebase
Testing data storage can be confusing. If you don't have a real database, how can you test changing it? In the case of Firebase, how can you test responding to an update if no update occurs?
The key is to understand that whether a database updates correctly is not something you test. You don't write tests for external systems. What you need to test is that your code:
- correctly displays fetched from the database
- correctly calls the database to make an update
Mocking Firebase describes how to test that data is displayed correctly. To test that Firebase is called correctly to do an update you need to mock the Firebase method that sends the data, and then verify that it was called with the right data.
To verify that our app code calls a function properly, we make use of the fact that a mock function is both a stub and a spy. It is a stub because we can use the mockReturnValue() method to define a return value for the mock function. It is a spy because a mock function records how often it was called and with what arguments. We can test for what happened by call methods such as toHaveBeenCalled() and toHaveBeenCalledWith().
For a minimal example, consider a Like button where we want a user to like a post at most once. There are three pieces of code:
- Code to define a simple hook useDataUpdate() that returns an update function that stores a value under a path
- Code for a Like button associated with a post to like that post, but at most once per user.
- Code to test that the first "like" is counted and repeated are ignored, by verifying that the update function is called only once.
import { useCallback } from 'react'; import { getDatabase, initializeApp, update } from "firebase/app"; ... const firebase = initializeApp(firebaseConfig); const database = getDatabase(firebase); export const useDataUpdate = (path) => ( useCallback((value) => update(ref(database, path), value), [database, path]) );
import { useDataUpdate } from '../utilities/firebase'; const LikeButton = ({ user, post, hasLiked}) => { const [updateFn, result] = useDataUpdate(`/posts/${post.id}/likedBy/${user.id}); const handler = () => { if (!hasLiked) { updateFn(true) } } return <button onClick={handler}>Like</button>; };
import { it } from 'vitest'; import { render, screen } from '@testing-library/react'; import { useDataUpdate }from '../utilities/firebase'; import LikeButton from './App'; vi.mock('../utilities/firebase'); describe('like tests', () => { it('only likes once', async () => { const mockUpdater = vi.fn(); useDataUpdate.mockReturnValue(mockUpdater); render(<LikeButton />); const button = await screen.getByText(/Like/i); fireEvent.click(button); fireEvent.click(button); expect(mockUpdater).toHaveBeenCalledTimes(1); }); };
If you have multiple update tests, then you need to reset the mock functions between tests to clear the spy data. You can do this by calling mockReset() on the mock function.
Mocking a logged in user
Many apps must do different things when there's a user logged in. This kind of code is important to test, but tricky to write and run. We don't want our test code to have user names and passwords in it. We don't want to make our unit tests wait for an authentication service to process a log in. With mocks, we can simulate having a user with none of these issues.
Here's a minimal example of code to test an app that should show a Sign In button when no user is logged in, and a Sign Out button when there is a user. There are three parts to the example:
- Code to define a hook useAuthState() for Firebase authentication that returns the current user, or null
- A simple banner that shows either sign in or sign out button, depending on whether there's an authenticated user
- Code to test both cases, by mocking useAuthState() to return null or a dummy Firebase User object.; While there are many properties for the Firebase user object, a displayName is sufficient for testing.
import { getAuth, initializeApp, onAuthStateChanged } from "firebase/app"; const firebase = initializeApp(...); export const useAuthState = () => { const [user, setUser] = useState(); useEffect(() => ( onAuthStateChanged(getAuth(firebase), setUser) ), []); return user; };
import { useAuthState } from '../utilities/firebase.js'; const Banner = () => { const [user] = useAuthState(); const label = user ? 'Sign Out' : 'Sign In'; const handler = user ? signOutHandler : signInHandler; return ( <div class="flex flex-row m-4 p-4 bg-black text-amber-400"> <h1 class="flex-grow text-2xl">Scheduler</h1> <button class="bg-blue-500 text-white p-2 rounded-lg" onClick={handler}>{ label }</button> </div> <div> ) };
import { it } from 'vitest'; import { render, screen } from '@testing-library/react'; import { useAuthState }from '../utilities/firebase'; import Banner from './Banner'; vi.mock('../utilities/firebase'); describe('sign in/out buttons', () => { it('shows sign in with no user', () => { useAuthState.mockReturnValue([null]); render(<Banner />); expect(screen.getByText(/Sign In/i); }); it('shows sign out with authenticated user', () => { useAuthState.mockReturnValue([{ displayName: 'Joe' }]); render(<Banner />); expect(screen.getByText(/Sign Out/i); }); };
If your app has a login screen, be sure that your app skips that if the user is already logged in. Forcing a login when the user is known not only makes testing difficult, it makes the app annoying for real users. The normal way to do this is to redirect the user to your login page, e.g., Tanstack Router redirect function.
Verifying proper calls with spies
So far, we have just used our mock functions as stubs, that simulate getting values from some service. Mocks have another important function: they are spies that record every time they are called. That lets us write unit tests to make sure that our code calls a service correctly, the right number of times, with the right data. Vitest provides several expect() methods to make it easy to test how often a mock function was called and with what arguments.
For example, here is a test to verify that App calls useData() once, when rendered, and when it does, it passes the string '/schedule' and some function.
it('asks for data once with a schedule path', () => { useData.mockReturnValue([mockSchedule, false, null]); useAuthState.mockReturnValue([null]); render(<App />); expect(useData).toHaveBeenCalledTimes(1); expect(useData).toHaveBeenCalledWith('/schedule', expect.any(Function))); });
As before, we define return values for the two mocked methods and then render App. Instead of looking at what's rendered, we test that useData() was called just once, and that when it was called, it was passed the proper arguments.
Add this test to App.test.jsx. Verify that all the tests pass. Try changing what's expected and verify that it fails.
Resetting mocks
Unit tests should be independent. That means that they can run in any order. No test should depend on another test running first or affect the results of any test running later. Some testing frameworks enforce this by running tests in a random order.
Using mocks to avoid calls to a database helps keep tests independent but the mock operations themselves have side effects. If a mock function is changed or called in a test, that can affect how the same mock behaves in another test.
You can call Vitest functions to clear, reset, or restore all mocks in your test code, but an even simpler thing is to configure Vitest to do this automatically by adding the following to vite.config.js:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
mockReset: true
}
});
Vitest tips and pitfalls
Tip: While Vitest expect() is very useful, the following test is overkill.
expect(screen.getByText(/welcome/i)).toBeDefined();
To check for the existence of some element, just ask for it.
screen.getByText(/welcome/i);
Pitfall: A test like the following will usually fail.
screen.getByText('welcome');
This test only passes if some item has exactly welcome
, no more or less.
To look for elements that contain some text, ignoring case, use regular expressions.
screen.getByText(/welcome/i);
Pitfall: The following test will always pass, no matter what's on the page.
screen.findByText(/welcome/i);
That's because findByText() is asynchronous and always returns a promise. You can't tell if it failed unless you await the result.
await screen.findByText(/welcome/i);
Measuring test coverage
An important characteristic of any test suite is code coverage. This measures what percentage of your code has been executed by at least one test. If code coverage is less than 100%, it means there are lines of code that your tests never execute. Unexecuted lines of code can be time bombs, waiting to fail in real use.
Vitest makes it fairly easy to evaluate your test code coverage. First, add the following script to your package.json file.
"scripts": {
...
"test": "vitest --ui",
"coverage": "vitest run --coverage",
...
The vitest run part says to run Vitest just once, rather than running and waiting for changes. Collecting code coverage data is a slow process so you don't normally do it repeatedly.
Add the following to your vite.config.js file:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
mockReset: true
coverage: {
reporter: ['text', 'html'],
},
}
});
This tell Vitest to print the code coverage data in the console and also create an interactive HTML file to explore what is and isn't covered.
To get coverage date,
npm run coverage
Vitest will ask if you want to install c8, if it's not already installed. This is a code coverage library that Vitest uses. Say yes.
When it finishes, a table will be printed that summarizes how much code was covered in each component file. Even better, there will be an HTML file in the coverage directory that you can open in a browser to see exactly what pieces of code were and were not executed by the tests.
100% code coverage does not mean you have tested everything. It just means that every line of code was executed at least once. Most code has conditionals. That means that there's more than one possible path through the code. Many bugs only occur when certain paths are taken. Path coverage measures how many of these paths are tested. It's much harder to get 100% path coverage than 100% code coverage.
Final Notes and Resources
There's a lot more to unit testing and Vitest. For example, most unit testing should be on components of the app, not the entire app itself. There's an art to writing tests that focus on how the app is used, but aren't so fragile that they break with every small change in the interface. There are many pages on this topic.
There are many other functions to help define mock function behavior. For the above examples, we only needed the Vitest method mockReturnValue(). If the return value depends on the arguments passed to the mocked function, use mockImplementation(). If we need to simulate a sequence of return values, use mockReturnValueOnce(). For more ways to get elements from a page, see types of queries.
Documentation on how to do this was surprisingly hard to find. Most pages on mocking with Jest or Vitest only show how to mock individual functions and modules that export a single class component. None of that is useful here.
Richard Kotze's Mocking React hooks when unit testing using Jest was particularly helpful for writing this tutorial.