5

I migrated from React Router v5 to v6 following this tutorial. I want to test it with react-testing-library, but my old unit tests (using the pattern in this doc) stopped working.

My app with React Router v6 is like this

const router = createBrowserRouter([
    {
        path: "/",
        element: (
            <>
                <SiteHeader />
                <Outlet />
            </>
        ),
        errorElement: <NotFound />,
        children: [
            { path: "/", element: <Home /> },
            { path: "/posts", element: <Posts /> },
            { path: "/post/:postId", element: <PostPage /> },
        ],
    },
]);

function App() {
    return (
        <div className="app">
            <RouterProvider router={router} />
        </div>
    );
}

As you can see, it's using RouterProvider instead of Switch/Route (so I'm confused that this SO question says it's using React Router v6 but it looks so different.).

The code in official doc of testing-library is not using RouterProvider either.

I want to test some routing logic like this pseudo code:

renderWithRouter(<App />, "/posts"); // loads /posts page initially
await user.click(screen.getByText("some post title")); // trigger click
expect(getUrl(location)).toEqual("/post/123"); // checks the URL changed correctly

How can I create a renderWithRouter function like this with RouterProvider? Note that this renderWithRouter worked for me when I used React Router v5, but after migrating to v6, it stopped working.

My current dependency versions:

  • "react": "^18.2.0",
  • "react-dom": "^18.2.0",
  • "react-router-dom": "^6.4.3",
  • "@testing-library/jest-dom": "^5.16.5",
  • "@testing-library/react": "^13.4.0",
  • "@testing-library/user-event": "^14.4.3",

I tried this

test("click post goes to /post/:postId", async () => {
    render(
        <MemoryRouter initialEntries={["/posts"]}>
            <App />
        </MemoryRouter>,
    );
    // ...
});

but I got error

You cannot render a <Router> inside another <Router>. You should never have more than one in your app.

      31 | test("click post goes to /post/:postId", async () => {
    > 32 |     render(
         |     ^
      34 |         <MemoryRouter initialEntries={["/posts"]}>
      36 |             <App />
lzl124631x
  • 4,485
  • 2
  • 30
  • 49
  • 1
    What are you trying to ***unit*** test? You should be testing units ***of your code***, not 3rd-party code. What is there to stop you from wrapping the `Posts` component in a `MemoryRouter` and testing `Posts` behavior, as an example? – Drew Reese Nov 11 '22 at 07:57
  • @DrewReese Updated. Please see the pseudo code part for what I want to test. I got error message when using `MemoryRouter` (see the end) – lzl124631x Nov 11 '22 at 08:10
  • 1
    I don't think you are thinking of a "unit" correctly, as in testing the smallest ***unit*** of code necessary. If you are trying to test the `Post` component, then try rendering only `Post`. Again, it depends on what you are trying to unit test. Sometimes *some* components need to be rendered within a routing context, so a router is necessary to provide the context. – Drew Reese Nov 11 '22 at 08:13
  • FWIW a test for navigating from one page to another isn't a unit test, this borders more on integration testing (*i.e. how two or more units of code integrate together*). `react-testing-library` isn't the correct tool for the job of integration testing. For this look for something like puppeteer, selenium, cypress, etc. – Drew Reese Nov 11 '22 at 08:16
  • @DrewReese I do think this is a kind of unit test -- testing just the routing logic. See [this doc](https://testing-library.com/docs/example-react-router/#reducing-boilerplate) which is testing /home to /about page navigation. Unit testing clicking a button to open a panel is not so different from unit testing clicking a link to navigate to a different page. Besides, my unit test worked for React Router V5 following [this doc](https://testing-library.com/docs/example-react-router/#reducing-boilerplate). It just broke after migrating to V6. – lzl124631x Nov 11 '22 at 08:23

2 Answers2

8

If you want to test your routes configuration as a whole, using the new react-router-dom@6.4 Data Routers, then I'd suggest a bit of a refactor of the code to allow being able to stub in a MemoryRouter for any unit testing.

Declare the routes configuration on its own and export.

const routesConfig = [
  {
    path: "/",
    element: (
      <>
        <SiteHeader />
        <Outlet />
      </>
    ),
    errorElement: <NotFound />,
    children: [
      { path: "/", element: <Home /> },
      { path: "/posts", element: <Posts /> },
      { path: "/post/:postId", element: <PostPage /> },
    ],
  },
];

export default routesConfig;

In the app code import routesConfig and instantiate the BrowserRouter the app uses.

import {
  RouterProvider,
  createBrowserRouter,
} from "react-router-dom";
import routesConfig from '../routes';

const router = createBrowserRouter(routesConfig);

function App() {
  return (
    <div className="app">
      <RouterProvider router={router} />
    </div>
  );
}

For unit tests import the routesConfig and instantiate a MemoryRouter.

import {
  RouterProvider,
  createMemoryRouter,
} from "react-router-dom";
import { render, waitFor } from "@testing-library/react";
import routesConfig from '../routes';

...

test("click post goes to /post/:postId", async () => {
  const router = createMemoryRouter(routesConfig, {
    initialEntries: ["/posts"],
  });

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

  // make assertions, await changes, etc...
});
Drew Reese
  • 165,259
  • 14
  • 153
  • 181
  • Awesome. Thanks! This works! With MemoryRouter, I need to use `router.state.location` when I want to check the current pathname or search. – lzl124631x Nov 11 '22 at 09:11
  • 1
    BTW, https://testing-library.com/docs/example-react-router should probably be updated with this. :D – lzl124631x Nov 11 '22 at 09:15
  • What about integration tests ? – Norayr Ghukasyan May 23 '23 at 16:36
  • MemoryRouter is a good choice ? – Norayr Ghukasyan May 23 '23 at 16:40
  • 1
    @NorayrGhukasyan Ah, I suppose it depends on the testing environment. Integration tests can oftentimes run in an actual browser context, e.g. Puppeteer, Selenium, Cypress, etc., then you could use the same router the app uses. If the test environment is still a Node.js env or non-browser env, then the `MemoryRouter` will still be useful. – Drew Reese May 23 '23 at 16:44
2

FWIW, I created my own renderWithRouter for React Router V6.

export const renderWithRouter = (route = "/") => {
    window.history.pushState({}, "Test page", route);
    return {
        user: userEvent.setup(),
        ...render(<RouterProvider router={createBrowserRouter(routes)} />),
    };
};

And this is an example test.

test("click Posts => shows Posts page", async () => {
    const { user } = renderWithRouter();
    const postsLink = screen.getByText("Posts").closest("a");
    expect(postsLink).not.toHaveClass("active");
    await user.click(postsLink as HTMLAnchorElement);
    expect(postsLink).toHaveClass("active");
    expect(getUrl(location)).toEqual("/posts");
});

lzl124631x
  • 4,485
  • 2
  • 30
  • 49