Using Mock Service Worker to Improve Jest Unit Tests
In this blog
The full set of example code from this article is available on GitHub.
What is Mock Service Worker?
Mock Service Worker (MSW) is a tool that uses the browser's Service Worker API to create a mock server that intercepts network calls and handles them with responses we specify. The Service Worker API is traditionally used to allow web apps to run offline. Although the Service Worker API is a browser-only feature, the MSW team has implemented support for it under Node as well. This means we can use it while running unit tests in Node-specific test frameworks like Jest.
Yet another to-do app
The easiest way to see how MSW improves our unit tests is to compare tests for a component with and without using MSW. Let's look at an example React application with a component that needs to make an API call to fulfill its purpose. Our component is part of a To-Do app that fetches a list of tasks and displays them to the user.
Our To-Do app has a few layers of code to handle API calls. There is an HTTP client that leverages Axios to send HTTP requests to our API. (If you aren't familiar with it, Axios is a library that standardizes the access methods and response shapes for REST API calls.)
// http-client.js
import axios from 'axios';
export const baseUrl = 'http://localhost:5000/api';
export const httpClient = axios.create({
baseURL: baseUrl,
timeout: 1000,
});
The next layer is a service that bundles related API business logic in one module and depends on the HTTP client. tasks-service
contains the getTasks
function for fetching data from the /tasks
API endpoint.
// tasks-service.js
import { httpClient } from './http-client';
export const getTasks = () => httpClient.get('/tasks');
The layers (shown here) have clearly been stripped down to the bare minimum and could be improved or extended in any number of ways, but they are sufficient to represent typical elements of a client-side architecture for handling network calls.
The component TaskList
depends on our tasks-service
module to fetch and then display tasks retrieved from our API. A useEffect
hook runs when the component mounts, awaits a call to tasks-service.getTasks
and then displays the returned tasks. If the API call fails, an error message is displayed instead.
// TaskList.js
import React, { useState, useEffect } from 'react';
import { getTasks } from './api/tasks-service';
import Task from './Task';
import './tasklist.css';
const TaskList = () => {
const [error, setError] = useState(null);
const [tasks, setTasks] = useState([]);
useEffect(() => {
async function fetchTasks() {
try {
const { data } = await getTasks();
setTasks(data);
setError(null);
} catch (error) {
setError('Failed to fetch tasks');
}
}
fetchTasks();
}, []);
return (
<div>
<h2 className="tasklist">Task List</h2>
{error && <h4 className="error">{error}</h4>}
{tasks.map((task) => (
<Task task={task} key={task.id} />
))}
</div>
);
};
export default TaskList;
Unit testing with traditional dependency mocks
Unit testing components that make API calls require us to confront the awkward reality that in the context of a unit test our code cannot successfully reach that API. The standard approach to handle this situation is to mock the portion of our code that makes a network call so we can substitute our desired API response when our test runs.
In the following example, Jest's mocking capabilities are used to replace the actual implementation of tasks-service.getTasks
with two different responses to test the happy path and error case of TaskList
behavior.
// TaskList.test.js
import { render, screen } from '@testing-library/react';
import { getTasks } from './api/tasksService';
import TaskList from './TaskList';
jest.mock('./api/tasksService');
describe('Component: TaskList', () => {
it('displays returned tasks on successful fetch', async () => {
getTasks.mockResolvedValue({
data: [
{ id: 0, name: 'Task Zero', completed: false },
{ id: 1, name: 'Task One', completed: true },
],
});
render(<TaskList />);
const displayedTasks = await screen.findAllByTestId(/task-id-\d+/);
expect(displayedTasks).toHaveLength(2);
expect(screen.getByText('Task Zero')).toBeInTheDocument();
expect(screen.getByText('Task One')).toBeInTheDocument();
});
it('displays error message when fetching tasks raises error', async () => {
getTasks.mockRejectedValue(new Error('broken'));
render(<TaskList />);
const errorDisplay = await screen.findByText('Failed to fetch tasks');
expect(errorDisplay).toBeInTheDocument();
const displayedTasks = screen.queryAllByTestId(/task-id-\d+/);
expect(displayedTasks).toEqual([]);
});
});
This works and the main functionality of the component has tolerable test coverage. However, there are a few drawbacks to this approach. Our TaskList
component directly depends on tasks-service
which in turn depends on http-client
. Unfortunately, due to mocking tasks-service
our test will no longer trigger calls to either of those modules. Our test has lost the ability to assure us that the integration points of TaskList
with tasks-service
and tasks-service
with http-client
continue to work as expected. Suppose someone changes the URL path that tasks-service.getTasks
calls from /tasks
to /todos
. Our application is now broken at runtime but our test will still happily pass.
Another drawback is revealed if we start work on a unit test for a parent component that renders TaskList
as part of its work. Our parent component's unit test needs to mock the getTasks
service call yet again to successfully render for its own tests.
Mocking the service call means we lose the integration test coverage inherent in using the genuine implementation of TaskList
. Additionally, we may need to mock the service call in many locations of our test codebase. Any changes to the shape of the getTasks
response would require corrections within each test where the service is mocked.
Unit testing with MSW network layer mocks
Having seen what a traditional mocking solution might look like let's now examine how MSW helps us handle network calls in unit tests without the drawbacks of mocking the service layer.
MSW provides a REST helper for defining route handlers using a syntax similar to Express routes. (MSW also provides a helper for intercepting GraphQL requests but that won't be covered in this article.) In the handlers
module two request handlers are defined.
// handlers.js
import { rest } from 'msw';
import { baseUrl } from '../api/http-client';
const mockTasks = [
{ id: 0, name: 'Task Zero', completed: false },
{ id: 1, name: 'Task One', completed: true },
];
const getTasksPath = `${baseUrl}/tasks`;
const tasksHandler = rest.get(getTasksPath, async (req, res, ctx) =>
res(ctx.json(mockTasks))
);
export const tasksHandlerException = rest.get(
getTasksPath,
async (req, res, ctx) =>
res(ctx.status(500), ctx.json({ message: 'Deliberately broken request' }))
);
export const handlers = [tasksHandler];
Both handlers specify a response returned when any GET requests are made to the URL of the /tasks
endpoint. The first, tasksHandler
, is the happy path handler. It returns an HTTP 200 OK result along with JSON data for two tasks. The second handler, tasksHandlerException
, responds with a 500 Internal Server Error and an error message. The happy path handler is exported as part of an array of standard request handlers. The handler that returns an exception is exported individually so we can make use of it as an override for a specific test.
In msw-server
the standard handlers are imported and used to define an MSW server that will intercept and respond to any network requests with a defined handler that matches their path.
// msw-server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const mswServer = setupServer(...handlers);
There is one last bit of setup needed to make use of our MSW server in our unit tests. A few lines are added to our Jest setupTests
file to define behavior that applies to all Jest test runs.
// setupTests.js
import '@testing-library/jest-dom';
import { mswServer } from './api-mocks/msw-server';
beforeAll(() => mswServer.listen());
afterEach(() => mswServer.resetHandlers());
afterAll(() => mswServer.close());
A beforeAll
statement starts our MSW server listening at the beginning of any test run. A corresponding afterAll
statement shuts down the MSW server when the test run is complete. The afterEach
statement ensures that between each individual test the MSW server's defined handlers are reset to their initial chosen values. This prevents an individual test that overrides a specific handler from polluting the handler setup used by any other test that follows.
Having completed the necessary setup to utilize MSW in our tests let's look at TaskList.test.js
again, modified now to remove the mocking of our service layer and depend instead on MSW intercepting API calls.
// TaskList.test.js with MSW
import { render, screen } from '@testing-library/react';
import { tasksHandlerException } from './api-mocks/handlers';
import { mswServer } from './api-mocks/msw-server';
import TaskList from './TaskList';
describe('Component: TaskList', () => {
it('displays returned tasks on successful fetch', async () => {
render(<TaskList />);
const displayedTasks = await screen.findAllByTestId(/task-id-\d+/);
expect(displayedTasks).toHaveLength(2);
expect(screen.getByText('Task Zero')).toBeInTheDocument();
expect(screen.getByText('Task One')).toBeInTheDocument();
});
it('displays error message when fetching tasks raises error', async () => {
mswServer.use(tasksHandlerException);
render(<TaskList />);
const errorDisplay = await screen.findByText('Failed to fetch tasks');
expect(errorDisplay).toBeInTheDocument();
const displayedTasks = screen.queryAllByTestId(/task-id-\d+/);
expect(displayedTasks).toEqual([]);
});
});
Our test file is smaller as it requires less setup code. The tests are easier to follow with less setup code obscuring their purpose. Only the test of the exception case requires any additional setup at all. In that test, the mswServer.use
statement overrides the MSW server to use our exception handler. The afterEach
statement in our Jest overall setup ensures that any tests that follow will once again use the happy path handler initially chosen for the server.
Any unit test, at any level of the component tree, that triggers a call to GET /tasks
will operate without any further configuration or thought. The API call will always be handled by the MSW server returning our standard handler's response. If need be, we can easily override the standard response with a unique handler for any specific test. Furthermore, if, for some reason, it would be desirable to mock the service layer for a given test suite that option still exists. The service mock would take precedence and no actual network request would be made. Using the MSW mock server, we gain the benefit of centrally defined API mocks that require little or no setup within our tests without losing any ability to fall back to other mocking techniques if they would be useful.
Optionally using MSW at runtime for development
We defined handlers that intercept GET requests for our unit tests. MSW lets us take that work a step further and leverage those same handlers at runtime. Doing so allows us to develop against APIs that aren't yet built or are difficult to access from a development environment.
Using the built-in MSW CLI we install their canned mockServiceWorker
file to our project. For our Create React App
based project this is put into the /public
folder. For the Node test environment, we created a mswServer
built with our defined handlers. For the browser environment, we can also define a mswWorker
using those same handlers. This looks nearly identical to our mswServer
module, but it uses the MSW setupWorker
call instead of setupServer
.
// msw-worker.js
import { setupWorker } from 'msw';
import { handlers } from './handlers';
export const mswWorker = setupWorker(...handlers);
Now we can add a conditional switch at the top of our app to require
and start the mswWorker
when an environment variable we define is set to yes
.
// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import './index.css';
if (process.env.REACT_APP_USE_MSW_MOCK_API === 'yes') {
const { mswWorker } = require('./api-mocks/msw-worker');
mswWorker.start();
}
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
All that remains is to add a new NPM script command in package.json
. When we start the application by calling npm run start:msw
, the environment variable is set to yes
and our app runs with the mswWorker
loaded and listening for calls to intercept.
// package.json scripts
{
"scripts": {
"start": "react-scripts start",
"start:msw": "REACT_APP_USE_MSW_MOCK_API=yes react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"api": "json-server ./src/api/db.json"
}
}
From that point on any network calls that match one of our defined handlers will receive our defined response. Using this technique, we can optionally enable mocked APIs in a runtime environment with a single command. This can be very beneficial for developing against specific data scenarios, speeding up debugging feedback loops, or interacting with the application realistically in environments where accessing the real API is cumbersome or unavailable.
Conclusion
MSW is a powerful tool to mock network calls without mocking our own app code. This centralizes mock setup, removes noise from our tests, makes our components easier to refactor without changing test code and produces more valuable and reliable tests that exercise all the layers of code involved in a component's work.
The work done to define API mocks for our unit tests can also be leveraged at runtime as an aid to development and debugging.
For further reading on Mock Service Worker, see the MSW documentation and Kent C. Dodds' article with his suggestion to use MSW for testing.
The full set of example code from this article is available on GitHub.