?
Digital Application Development
11 minute read

Using Mock Service Worker to Improve Jest Unit Tests

Mock Service Worker (MSW) improves unit tests of components that make API calls by defining mocks at the network level instead of mocking our own custom code. Better still, we get that benefit while also making our test code smaller, easier to read and easier to reuse.

In This Article

The full set of example code from this article is available on GitHub.

copy link

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.

copy link

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;

copy link

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.

copy link

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.

copy link

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.

copy link

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.