Miroslav Nikolov

Miroslav Nikolov

How to Avoid Mocking in React Router v6 Tests

May 24, 2023

React Router Lifecycle

Testing React Router (v6) applications from the ground up, requires you to have a particular setup configured. An example one may consist of a wrapper function around React Testing Library’s (RTL) render() that abstracts a data router provider to help you, in the end, reliably test page navigation (even programmatically with useNavigate) without mocking.

// Test a page navigation

it("should navigate to the Contacts page", () => {
  // Wrapper function around RTL's render()
  renderWithRouter(<About />, [
    {
      path: "/contacts",
      element: <h2>Contacts page</h2>,
    },
  ]);

  fireEvent.click(screen.getByText("Contacts"));

  expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
    "Contacts page"
  );
});

The renderWithRouter wrapper function above can also be called like that:

// 1️⃣ Render a page component under "/" by default
renderWithRouter(<AboutPage />);

// 2️⃣ Render a page component under a custom path
renderWithRouter({
  element: <AboutPage />,
  path: "/about",
});

// 3️⃣ Render a page component under "/" and set additional routes.
// Useful for testing route navigation
renderWithRouter(<AboutPage />, [
  {
    path: "/",
    element: <h2>Home page</h2>,
  },
  {
    path: "/contacts",
    element: <h2>Contacts page</h2>,
  },
]);

You can render the component under a specific route alongside additional routes defined with a data router under the hood. Lets see how do renderWithRouter’s internals look like.

renderWithRouter Implementation #

This setup assumes Jest, RTL, and React Router v6.

import React, { isValidElement } from "react";
import { render } from "@testing-library/react";
import { RouterProvider, createMemoryRouter } from "react-router-dom";

export function renderWithRouter(children, routes = []) {
  const options = isValidElement(children)
    ? { element: children, path: "/" }
    : children;

  const router = createMemoryRouter([{ ...options }, ...routes], {
    initialEntries: [options.path],
    initialIndex: 1,
  });

  return render(<RouterProvider router={router} />);
}

renderWithRouter builds on top of RTL's render() function and returns what it does when called.

Fx. cosnt { container } = renderWithRouter(<AboutPage />);.

The central part is createMemoryRouter which is useful for tests, in contrast to createBrowserRouter — what you'll set in the production code instead. RouterProvider is the primary data provider to accept the routes array here.

This setup will allow you to avoid errors like

useNavigate() may be used only in the context of a <Router> component.

when testing components calling the useNavigate hook.

renderWithRouter Test Cases #

  1. Render a page.
it("should ensure the page is rendered", () => {
  renderWithRouter(<About />);

  expect(screen.getByText("About page")).toBeInTheDocument();
});
  1. Navigate to another page.
it("should navigate to the Contacts page", () => {
  renderWithRouter(<About />, [
    {
      path: "/contacts",
      element: <h2>Contacts page</h2>,
    },
  ]);

  fireEvent.click(screen.getByText("Contacts"));

  expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
    "Contacts page"
  );
});
  1. Navigate to another page via useNavigate and an API call.
it("should navigate to the Home page (with useNavigate)", () => {
  renderWithRouter(<About />, [
    {
      path: "/",
      element: <h2>Welcome</h2>,
    },
  ]);

  fireEvent.click(screen.getByRole("button"));

  waitFor(() => {
    expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
      "Welcome"
    );
  });
});

The following codesandbox ⬇️ configures everything described so far and runs some tests on a side.

Final Words #

Something like the renderWithRouter function is not necessarily limited to the router setup itself. You can fx., put more providers and configs there — SWR.js, Redux, etc. Ultimately, it should serve as a render util that ensures your component gets mounted with all the required surroundings. And you are then writing integration tests most of the time by default.

I plan a follow-up on the tests topic — a few words on mocking but mostly how to avoid it for different aspects of your app — CSS modules, fetching/cashing, global store, etc. Stay tuned.


Follow on Linkedin