How to test routing logic with React Router v6 and testing-library?
Asked Answered
S

4

14

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 />
Selective answered 11/11, 2022 at 7:53 Comment(5)
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?Ledoux
@DrewReese Updated. Please see the pseudo code part for what I want to test. I got error message when using MemoryRouter (see the end)Selective
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.Ledoux
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.Ledoux
@DrewReese I do think this is a kind of unit test -- testing just the routing logic. See this doc 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. It just broke after migrating to V6.Selective
L
26

If you want to test your routes configuration as a whole, using the new [email protected] 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...
});
Ledoux answered 11/11, 2022 at 8:39 Comment(5)
Awesome. Thanks! This works! With MemoryRouter, I need to use router.state.location when I want to check the current pathname or search.Selective
BTW, testing-library.com/docs/example-react-router should probably be updated with this. :DSelective
What about integration tests ?Chartulary
MemoryRouter is a good choice ?Chartulary
@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.Ledoux
S
3

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");
});

Selective answered 12/11, 2022 at 8:7 Comment(0)
O
1

(Copied from @Drew's answer)

My idea is to test a piece of UI component, so I don't want to pass a full route config(I thought about performance). I just used a straightforward route config for each test file.

const router = createMemoryRouter([{ path: '/', element: <MyComponent /> }]);

test('should render MyComponent', () => {
  render(<RouterProvider router={router} />);

  //   assertions
});

I ended up creating the renderWithRouter method like this

export const renderWithRouter = (
  ui: ReactElement,
  path = '/',
  options?: Omit<RenderOptions, 'wrapper'>
) => {
  const { pathname } = new URL(`http://www.test.com${path}`);

  const router = createMemoryRouter(
    [{ path: pathname, element: <Providers>{ui}</Providers> }],
    { initialEntries: [path] }
  );

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

Usage

renderWithRouter(<MyCompoent />);

// to assert search params
renderWithRouter(<MyCompoent />, '/user?user=1234');
Olenta answered 9/10, 2023 at 7:52 Comment(0)
P
0

I think it's also important to clearly point out what was causing the error in this question.

If you are encountering a similar error but your routing config does not look like this or you are not using the [email protected] Data Routers:

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 />

It is still just as the error indicated:

You have embedded the logic for BrowserRouter into your App component somewhere, and you are now wrapping it again with a MemoryRouter in your tests.

For those still using the older Routers i.e <BrowserRouter>, the simplest fix would be to ensure you are not wrapping App component with it. A good place for the BrowserRouter would be in your index.js file.

Plummet answered 25/5 at 2:6 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.