Testing React Components with Testing Library and Mock Service Worker

This blog post explains the installation process and use of Mock Service Worker and the benefits of using it over mocking modules

If you ever wondered: how can I test a component that makes API calls?, or should I mock the HTTP client?, then this post is for you!

When writing tests it's quite common to find ourselves in situations where we think about solutions that become too complex over timeto try to cover most of the use cases (including edge cases).

Writing a test in React is simple and straightforward. Using TestingLibrary leads to applying the AAA pattern which is a standard of good practices in unit testing:

  1. Render the component (Arrange)
  2. Run an event (Act)
  3. Make an assertion (Assert)

We may want to repeat steps 2 and 3 as many times as possible to have better test suites and increase test coverage.

In order to render the component, we need to import it first. Now, the problem occurs when our component needs to fetch data from the server. How do we test these kinds of components? How do we assert asynchronous behavior?

One quick solution is to mock the module like this:

const mockedFetch = jest.fn(() =>
  Promise.resolve({
    json: () => Promise. resolve({}),
  })
) as jest.Mock;

global.fetch = mockedFetch;

However, this is not recommended, since we're basically converting the fetch module into a dummy function, which doesn't reflect what our app does. The problem with this approach is that when mocking the client for testing an API the HTTP module doesn't handle the use case as it should (making the actual HTTP request). It merely makes up a simulation, due to having to mock the function which takes care of making the request to the server.

Probably, most of us have probably done something similar to this in the past:

const mockedFetch = jest.fn(() =>
  Promise.resolve({
    json: () => Promise.resolve({}),
  })
) as jest.Mock;

global.fetch = mockedFetch;

test("Renders the component with posts", async () => {
  render(<Posts />);
  expect(fetch).toHaveBeenCalledTimes(1);
  const postsItems = await screen.findAllByRole("article");
  expect(postsItems).toHaveLength(2);
});

In this case, we're mocking the fetch module as well as the function we'll use to read the response from the server; then, we would validate that the module was called with the expect(fetch).toHaveBeenCalledTimes(1) statement. Even though our tests pass, we're not really making any request to the API.

When we "execute" the HTTP client, in this case using the fetch module, we're only asserting that the function was called, but not the actual request.

This is what happens when calling the mocked fetch module behind the scenes: "hey, I will pretend to call you, but don't do anything! Just stay there, don't even think to bother the API, and just return this expected response".

The problem with this approach is that, even if the tests pass locally, the code that is being tested might still fail for various reasons, such as the wrong URL, a server error, differences in security protocols (HTTP vs HTTPS), etc.

But, don't panic! Mock Service Worker to the rescue! MSW gives us the tools needed to avoid mocking the HTTP client and offers a more "realistic" behavior when requesting data from the server, which in the end results in an improved experience while testing the behavior of our components.

MSW works this way: when we run our tests and trigger an action that calls an endpoint, MSW intercepts the request using server handlers (similar to how Express exposes endpoints), and responds in a similar way to how the server does.

This guarantees that:

  1. Our HTTP client actually runs (MSW intercepts the call), and
  2. It gets the expected response, even with the expected latency and HTTP status code (can be configured)

Now, how do we set up MSW? First things first, install the package:

npm install msw

Once installed, we need to create some directories. The first one is the mocks directory, which will contain the expected responses from the server:

mkdir src/mocks

Next, we need a file to store our request handlers; they will work similarly to how Express does:

touch src/mocks/handlers.ts

MSW lets us work both with REST as well as GraphQL APIs. This time, we'll go with REST.

In our handlers.ts file we'll add the request handlers that will intercept our requests:

import { rest } from "msw";
import CONSTANTS from "../constants";

function getPosts() {
  return rest.get(`${CONSTANTS.API_URL}/posts`, (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json([
        {
          userId: 1,
          id: 1,
          title: "Post 1 title",
          body: "Post 1 body",
        },
        {
          userId: 1,
          id: 2,
          title: "Post 2 title",
          body: "Post 2 body",
        },
      ])
    );
  });
}

export const handlers = [getPosts()];

In this case, the handler will intercept the request on the /posts route, then it will return a successful response with HTTP status code 200 and a default JSON object, taken from JsonPlaceHolder.

The next step is to configure our mock server which will take care of handling all of our endpoints.

First things first, we need to create a new file src/mocks/server.ts and create a new server instance using the setupServer function from msw/node. Then. we'll pass all of our handlers to the server instance, which means that the server will intercept these requests when we run our tests:

import { setupServer } from "msw/node";
import { handlers } from "./handlers";

const server = setupServer(...handlers);

export default server;

Once the mock server is set up, we can configure our tests. For this, we need to start our mock server before running any tests at all. In case you bootstrapped your project with Create React App (CRA), it's possible to modify the src/setupTests.ts file and add the following configuration:

import server from "./mocks/server";

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

This configuration allows the mock server to run before any test suite, reset the request handlers, and close the server instance after all tests have ran.

In case you don't use CRA , it's possible to add a new file with this configuration and add it to the jest.config.ts file. For this, we can create the jest.setup.ts file, add the previous code, and reference this file in the jest configuration file with the following code:

We're almost ready to run our tests again, but before doing so, we need to get rid of the mocked fetch modules and leave only the tests:

import React from "react";
import { render, screen } from "@testing-library/react";
import Posts from "./Posts";
import server from "../../mocks/server";
import { rest } from "msw";
import CONSTANTS from "../../constants";

describe("Posts test suite", () => {
  test("Renders the component with loading state", async () => {
    render(<Posts />);
    await screen.findByText(/Loading posts.../i);
  });

  test("Renders the component without posts", async () => {
    server.use(
      rest.get(`${CONSTANTS.API_URL}/posts`, (req, res, ctx) => {
        return res(ctx.status(200), ctx.json([]));
      })
    );
    render(<Posts />);
    await screen.findByText(/No posts published/i);
  });

  test("Renders the component with posts", async () => {
    render(<Posts />);
    const postsItems = await screen.findAllByRole("article");
    expect(postsItems).toHaveLength(2);
  });
});

As you can see, we removed all the jest.mock declarations in the test suite.

Right now, our tests only focus on what they actually need to, which is to assert the component's behavior as if the users were using it.

We can also see that it's possible to have different responses from the server by using the server.use() function and passing a new JSON response while in runtime. This allows, for example, to return an empty array and test that the component will show the No posts published message.

We're now confident to run our test suites again making sure that the API calls are actually executed and intercepted by the request handlers that we have configured.

In the same way, we can refactor our remaining tests to add a new request handler to the PostItem.tsx component.

First, we add the new request handler with the expected response:

function getComments() {
  return rest.get(`${CONSTANTS.API_URL}/comments`, (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json([
        {
          id: 1,
          name: "Comment name 1",
          email: "richosojason@msn.com",
          body: "Comment body 2",
        },
        {
          id: 2,
          name: "Comment name 2",
          email: "rmunoz@stackbuilders.com",
          body: "Comment body 2",
        },
      ])
    );
  });
}

export const handlers = [getPosts(), getComments()];

We remove the mocked fetch from the test suite and run the tests again:

import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { rest } from "msw";
import CONSTANTS from "../../constants";
import IPost from "../../interfaces/Post";
import server from "../../mocks/server";
import PostItem from "./PostItem";

const post: IPost = {
  id: 1,
  body: "Body",
  title: "Title",
};

describe("PostItem test suite", () => {
  test("Render PostItem component with post data", () => {
    render(<PostItem post={post} />);
    screen.getByRole("heading", { name: /Title/i });
    screen.getByText(/Body/i);
  });

  test("PostItem doesn't have comments", async () => {
    server.use(
      rest.get(`${CONSTANTS.API_URL}/comments`, (req, res, ctx) => {
        return res(ctx.status(200), ctx.json([]));
      })
    );
    render(<PostItem post={post} />);
    await waitFor(() => screen.getByText(/No comments yet!/i));
  });

  test("PostItem has comments", async () => {
    render(<PostItem post={post} />);
    await waitFor(() => {
      const button = screen.getByRole("button", { name: /See comments/i });
      userEvent.click(button);
      const comments = screen.getAllByRole("listitem");
      expect(comments).toHaveLength(2);
    });
  });
});

As expected, all test suites are passing!

Conclusion

In this blog post we have learned how to set up MSW to avoid mocking modules like fetch or axios, and focus only on testing the behavior of our components.

This also allows us to have better code maintainability and testability, as well as reducing the noise in our tests for having mocked modules.

Finally, MSW can also be used to try new things while in development, without the need of waiting for a fully functional API. It's possible to add, remove, or modify attributes in an endpoint, test validations, or even define new endpoints that are still in development in the backend.

Keep in mind that even though MSW addresses most of the complex parts of testing components with API integrations, they still need to call actual endpoints. To achieve a more realistic behavior you can consider using an E2E testing framework like Cypress, but that's a reading for another time.

You can review the code in the following repo.

If you liked the blog, don't forget to leave your comments and share it with your friends.

Written by


Richard Muñoz

Richard Muñoz