Tom Hughes

Testing Components that Use React Router Hooks

1st June, 2020

Recently I've been working on a React project that uses React Router and requires one of the components to make use of the current URL to request data from the API.

For example, if we visited the page '/posts/590' it would hit the API to request information about the post with the ID of 590.

Instead of using the withRouter higher-order component I have been using the more recently added hooks provided by React Router, these include:

  • useHistory
  • useLocation
  • useParams
  • useRouteMatch

One thing I initially struggled with was how to test my code when using these new hooks.

Here's a simplified example of the code we are working on.

import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";

function Post() {
  const { id } = useParams();
  const [post, setPost] = useState(null);

  useEffect(() => {
    PostsAPI.get(id).then(setPost);
  }, [id]);

  if (!post) return null;

  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </div>
  );
}

In a separate test file I want to assert that the PostsAPI.get function is called with the correct ID, we're going to be using Jest and React Testing Library for this.

it("should request and show the data from the API", async () => {
  const spy = jest.spyOn(PostsAPI, "get").mockImplementationOnce(
    () =>
      new Promise((resolve) =>
        resolve({
          title: "Hello World!",
          content: "My first post."
        })
      )
  );

  const { findByText } = render(<Post />);

  await findByText("Hello World!");
  await findByText("My first post.");
  expect(spy).toHaveBeenCalledTimes(1);
  expect(spy).toHaveBeenCalledWith("590");
});

This test will fail at the assertion that our PostsAPI.get method was called with the correct post ID as currently our test has no knowledge of the post we are viewing.

Let's fix that.

Passing the Test with a Mock

We can get our test to pass pretty easily by mocking the hooks that React Router provides to us.

jest.mock("react-router-dom", () => ({
  ...jest.requireActual("react-router-dom"),
  useParams: () => ({
    id: 590
  })
}));

If we run our test case again it will pass, however this is a pretty naive solution for several reasons.

Firstly, mocks in Jest are hoisted, this makes it difficult to override our mock for a single test.

Secondly, our test is now tied to our implementation. Our test is now reliant on us using the useParams hook, if we change the way we extract the post ID from the URL our test will fail.

Thirdly, using a mock means our test is now coupled with the test runner we are using, if you do decide to change test runners in the future it will require deeper changes to your test suite for it to work.

A Decoupled Solution

Using a mock has allowed us to write a test but also removes a level of confidence that our code works as expected in a real-world environment.

To gain that confidence back we will replace the mock and make our test more closely resemble how the code is used.

Lets remove the mock and render our component within a MemoryRouter instead,

it("should request and show the data from the API", async () => {
  const spy = jest.spyOn(PostsAPI, "get").mockImplementationOnce(
    () =>
      new Promise((resolve) =>
        resolve({
          title: "Hello World!",
          content: "My first post."
        })
      )
  );

  const { findByText } = render(
    <MemoryRouter initialEntries={["/posts/590"]}>
      <Route path="/posts/:id">
        <Post />
      </Route>
    </MemoryRouter>
  );

  await findByText("Hello World!");
  await findByText("My first post.");
  expect(spy).toHaveBeenCalledTimes(1);
  expect(spy).toHaveBeenCalledWith("590");
});

In The Merits of Mocking by Kent C. Dodds he states:

"When you mock something, you're making a trade-off. You're trading confidence for something else. For me, that something else is usually practicality"

If you look at the change we made the trade-off was that we had to add a router to our test, however this now more closely resembles the way we use our component within the codebase and we have therefore gained confidence.

To prove that the test is more resilient we can refactor the component to no longer use hooks, instead of hooks we are going to use the withRouter higher-order component.

import React, { useEffect, useState } from "react";
import { withRouter } from "react-router-dom";

const Post = withRouter(
  ({
    match: {
      params: { id }
    }
  }) => {
    const [post, setPost] = useState(null);

    useEffect(() => {
      PostsAPI.get(id).then(setPost);
    }, [id]);

    if (!post) return null;

    return (
      <div>
        <h1>{post.title}</h1>
        <p>{post.content}</p>
      </div>
    );
  }
);

The old test, where we mocked the useParams hook, will now fail even though our application code is working. Our improved test continues to pass with no changes required, despite the change of implementation, because we have already made our test more closely resemble the actual usage!

You can find the source code for this blog post on my GitHub.