Recommended approach for route-based tests within routes of react-router

I'm using react-testing-library within a project of mine and am trying to write tests that validate in-app routing.

e.g. testing that a button on the AccessDenied page brings you back to the Home page.

I've been able to write these sorts of tests successfully for my App component because it defines all of the app routes. But if AccessDenied is one of those routes, how do I need to set up my tests to validate a button clicked there will route my back to Home?

Here is a contrived example:

App.tsx

<>
  <Router>
    <Route exact path="/" component={Home} />
    <Route exact path="/access-denied" component={AccessDenied} />
  </Router>
  <Footer />
</>

AccessDenied.tsx

<div>
  <div>Access Denied</div>
  <p>You don't have permission to view the requested page</p>
  <Link to="/">
    <button>Go Home</button>    <--- this is what i want tested
  </Link>
</div>

As I said earlier the reason my tests work inside App.test.tsx is because my App component defines the routes inside itself, whereas my AccessDenied is just one of those routes. However, is it possible to leverage the router defined in my App.tsx in my AccessDenied.test.tsx tests? Perhaps I'm approaching this problem incorrectly? That's where I'm struggling. For reference, here is my working App.test.tsx tests.

App.test.tsx

describe('App', () => {
  it('should allow you to navigate to login', async () => {
    const history = createMemoryHistory()
    const { findByTestId, getByTestId } = render(
      <MockedProvider mocks={mocks} addTypename={false}>
        <AuthContext.Provider
          value={{
            authState: AUTH_STATES.UNAUTHENTICATED,
          }}
        >
          <Router history={history}>
            <App />
          </Router>
        </AuthContext.Provider>
      </MockedProvider>,
    )

    fireEvent.click(getByTestId('sidebar-login-button'))
    expect(await findByTestId('login-page-login-button')).toBeInTheDocument()

    fireEvent.click(getByTestId('login-page-register-button'))
    expect(await findByTestId('register-page-register-button')).toBeInTheDocument()
  })
})

Any thoughts or suggestions are appreciated!


Solution 1:

If you think about the responsibility of the AccessDenied component, it isn't really to send the user home. That's the overall behaviour you want, but the component's role in that is simply to send the user to "/". At the component unit level, therefore, the test could look something like this:

import React, { FC } from "react";
import { Link, Router } from "react-router-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { createMemoryHistory } from "history";

const AccessDenied: FC = () => (
    <div>
        <div>Access Denied</div>
        <p>You don't have permission to view the requested page</p>
        <Link to="/">
            <button>Go Home</button>
        </Link>
    </div>
);

describe("AccessDenied", () => {
    it("sends the user back home", () => {
        const history = createMemoryHistory({ initialEntries: ["/access-denied"] });
        render(
            <Router history={history}>
                <AccessDenied />
            </Router>
        );

        fireEvent.click(screen.getByText("Go Home"));

        expect(history.location.pathname).toBe("/");
    });
});

Note that "/" is the default path, so if you don't provide initialEntries the test passes even if the click doesn't do anything...

At that point you might be thinking "but what if the home route changes?" If you moved the home page to "/home", for example, this test would continue to pass but the application would no longer actually work. This is a common problem with relying too much on very low-level tests and is where higher-level tests come into play, including:

  • Integration: render the whole App and use fireEvent to simulate navigation. This is challenging in your current setup, because the Router is at the App level; I'd move the Router to index.tsx and have a Switch in App.tsx instead, so you can render App within a MemoryRouter or use the createMemoryHistory method I show above (I've done this in this starter kit for example).

  • End-to-end: use a browser driver (e.g. Cypress or the various Selenium-based options) to automate actual user interactions with the app.

I haven't got as far as showing tests for routing, but do cover these different levels of test for a simple React app on my blog.


In React Router v6, you need to update the Router usage slightly (see "Cannot read properties of undefined (reading 'pathname')" when testing pages in the v6 React Router for details):

render(
  <Router location={history.location} navigator={history}>
    <AccessDenied />
  </Router>
);