DEV Community

Cover image for How to test React Router components?
Danijel Maksimovic
Danijel Maksimovic

Posted on • Originally published at danijelmaksimovic.com

How to test React Router components?

Every React app I had worked with used react-router. There isn't much sense in having an application without it. In this article, we will be looking at how to test the component that is doing the navigation and why mocking React Router's APIs isn't the optimal solution.

Component to test

Let's set an example of a component that navigates to another page on user action:

import { useNavigate } from 'react-router-dom'

export const BackButton: React.FC<{ to?: string }> = ({ to }) => {
  const navigate = useNavigate()

  return (
    <button onClick={navigate(to ?? -1)}>
      Go back
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

The component in our example is navigating the user back to the previous page or to the path provided as to prop. It's the usual stuff you see in almost every app.

The mocking approach

How do we test this component to ensure it navigates to the correct page upon clicking the button? Let's take a look at the approach with mocking:

import { useNavigate } from 'react-router-dom'
import { render, screen } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'

import { BackButton } from './BackButton'

jest.mock('react-router-dom', () => ({
  useNavigate: jest.fn(),
}));

const useNavigateMock = jest.mocked(useNavigate)

it('navigates back to the previous page if `to` prop is not passed', async () => {
  const navigateMock = jest.fn()

  useNavigateMock.mockReturnValue(navigateMock)

  render(<BackButton />)

  await userEvent.click(screen.getByRole('button', { text: /go back/i }))

  expect(navigateMock).toHaveBeenCalledTimes(1)
  expect(navigateMock).toHaveBeenCalledWith(-1)
})

it('navigates to the path provided as a prop', async () => {
  const navigateMock = jest.fn()

  useNavigateMock.mockReturnValue(navigateMock)

  render(<BackButton to='/about' />)

  await userEvent.click(screen.getByRole('button', { text: /go back/i }))

  expect(navigateMock).toHaveBeenCalledTimes(1)
  expect(navigateMock).toHaveBeenCalledWith('/about')
});
Enter fullscreen mode Exit fullscreen mode

We are testing both use cases of the component, with and without to prop, by clicking on the button and asserting that the navigate function returned by useNavigate hook is called with the adequate arguments.

Let's imagine that we don't like how we are navigating in our BackButton component and would like to refactor it to navigate in a declarative way by using the Navigate component from the react-router-dom.

import { Navigate } from 'react-router-dom'
import { useState } from 'react'

export const BackButton: React.FC<{ to?: string }> = ({ to }) => {
  const [shouldNavigate, setShouldNavigate] = useState(false)  

  if (shouldNavigate) {
    return <Navigate to={to ?? -1} />
  } 

  return (
    <button onClick={() => setShouldNavigate(true)}>
      Go back
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

Notice our functionality didn't change. We are still navigating back to the previous location if to prop isn't passed and are navigating to to path if provided. Will our test still work?

Unfortunately, no 🫠 Since we are mocking react-router-dom the module in our tests, it will scream at us that there is no Navigation named export from the module and it will fail miserably. Of course, we could refactor the test as well and provide a mock Navigate component and thus fix the test, but the test should survive code refactoring if the behavior from a user perspective didn't change.

Tests should give developers confidence to freely refactor code knowing that they will still pass. 🎉

The reason our test failed is that we have intertwined an implementation detail of how we navigate from page to page with our test. In order to write a better, more stable test we should think of what actually happens when a user clicks on the button.

When a user clicks on the back button a user is taken to the previous page, meaning the URL changes. How can we test this side-effect of the user's action? 🤔

Writing a test without mocking

A while back while I was browsing react-router-dom docs I stumbled upon the MemoryRouter component. In the docs it says it stores its state internally in memory, making it perfect for environments where there is no history object, like tests. That sounds interesting, right?

Let's see how we can use the MemoryRouter to refactor our BackButton component test and make it more resilient:

import { MemoryRouter, Routes, Route } from 'react-router-dom'
import { render, screen, waitFor } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'

import { BackButton } from './BackButton'

it('navigates back to the previous page if `to` prop is not passed', async () => {
  render(
    <MemoryRouter initialEntries={['/first-page', '/current-page']}>
      <BackButton />
      <Routes>
        <Route path='/first-page' element={<p>first page</p>} />
        <Route path='/current-page' element={<p>current page</p>} />
      </Routes>
    </MemoryRouter>,
  )

  expect(screen.getByText(/current page/i)).toBeInDocument()

  await userEvent.click(screen.getByRole('button', { name: /go back/i }))

  await waitFor(() => {
    expect(screen.getByText(/first page/i)).toBeInDocument()
    expect(screen.queryByText(/current page/i).not.toBeInDocument()
  })
})

it('navigates to the path provided as a prop', async () => {
  render(
    <MemoryRouter initialEntries={['/']}>
      <BackButton to='/about' />
      <Routes>
        <Route path='/' element={<p>home</p>} />
        <Route path='/about' element={<p>about page</p>} />
      </Routes>
    </MemoryRouter>,
  )

  expect(screen.queryByText(/about/i)).not.toBeInDocument()

  await userEvent.click(screen.getByRole('button', { name: /go back/i }))

  await waitFor(() => {
    expect(screen.getByText(/about/i)).toBeInDocument()
  })
})
Enter fullscreen mode Exit fullscreen mode

We need to wrap our BackButton component with a MemoryRouter so it can work with the react-router APIs and we need to set a realistic test environment, providing an array of paths in the history stack.

When a user clicks on the button we check if the correct component is being rendered based on the routing setup and the expected behavior. There is no mocking of the React Router module.

The test written in this way resembles the user behavior, making our test more resilient to code refactors and giving us more confidence in our test suite.

Top comments (0)