How to test React components that depend on route changes


In this article, we'll go over a process to test React components that exhibit some behavior when the current route changes.

To illustrate the process, we'll test a component that scrolls to the top of the page whenever there's a change in the current route.

// Uses React router, Jest and react testing library
function ScrollToTop({ children, scrollTo = window.scrollTo }) {
  const prevLocation = useRef(null)
  const location = useLocation()
  useEffect(() => {
    if (prevLocation.current && location !== prevLocation.current) {
      scrollTo(0, 0)
    }
    return () => (prevLocation.current = location)
  }, [location, scrollTo])
  return children
}
// NOTE: This implementation serves the purpose of supporting this article.
// It doesn't have production concerns in mind.

To test the code above, we'll have to figure out how to simulate a change in the current route. We could try to mock useLocation and return different values each time we call it, but this approach exposes to our test that we use the useLocation hook. This means that if we refactor ScrollToTop not to use useLocation, our tests will break. A more robust way to write this test is to recreate the environment in which ScrollToTop will be used and trigger a route change similarly to how a user would.

For our test, we'll start by wrapping ScrollToTop in a Router and have it render two routes. The first route will have a link that points to the second route, and the second route will have some text. Then we'll simulate a click on the link of the first route and wait for the text on the second route to appear. After that, all that's left is to assert that we've scrolled to the top of the page.

To assert we've scrolled to the top of the page, we'll mock the scrollTo function and check that we called it with the coordinates that correspond to the top of the page. You might be wondering if this approach has the same issues that mocking useLocation had. The answer is that it does, but we don't have a better option. Since Jest uses JSDOM to simulate a browser, and since JSDOM doesn't implement window.scrollTo the best we can do in this environment is to mock scrollTo and confirm we're calling it correctly.

This approach results in the test bellow.

test("Scrolls to top of page when route changes", async function test() {
  const scrollTo = jest.fn()
  render(
    <MemoryRouter initialEntries={["/"]}>
      <ScrollToTop scrollTo={scrollTo}>
        <Route exact path="/">
          <Link to={"/other"}>Change route</Link>
        </Route>
        <Route exact path="/other">
          <p>Other route</p>
        </Route>
      </ScrollToTop>
    </MemoryRouter>
  )
  fireEvent.click(screen.getByText("Change route"))
  await screen.findByText("Other route")

  expect(scrollTo).toHaveBeenCalledTimes(1)
  expect(scrollTo).toHaveBeenLastCalledWith(0, 0)
})

Put it into practice

We can look at the work we did to test ScrollToTop as the following 4 step process:

  1. Recreate a routing environment similar to that in which the component will be used.
  2. Perform an action that causes navigation between routes.
  3. Assert that the navigation was done.
  4. Assert the component under test behaved as expected.

Next time you have to test a component that exhibits some behavior when a route changes, try the steps above.

Exercise: test component that shows an alert when routes change

Try to use the process delineated in this article to test the following component that creates an alert with the message "Hello world!" when there's a change in the current route.

function AlertOnRouteChange({ children, message, alert = window.alert }) {
  const prevLocation = useRef(null)
  const location = useLocation()
  useEffect(() => {
    if (prevLocation.current && location !== prevLocation.current) {
      alert("Hello world!")
    }
    return () => (prevLocation.current = location)
  }, [alert, location])
  return children
}
Solution
test("Alerts when route changes", async function test() {
  const alert = jest.fn()
  render(
    <MemoryRouter initialEntries={["/"]}>
      <AlertOnRouteChange alert={alert}>
        <Route exact path="/">
          <Link to={"/other"}>Change route</Link>
        </Route>
        <Route exact path="/other">
          <p>Other route</p>
        </Route>
      </AlertOnRouteChange>
    </MemoryRouter>
  )
  fireEvent.click(screen.getByText("Change route"))
  await screen.findByText("Other route")

  expect(alert).toHaveBeenCalledTimes(1)
  expect(alert).toHaveBeenLastCalledWith("Hello world!")
})