| | |

React Router 19 ⚛️ Testing Routes — Testing with createMemoryRouter

⏱️ Estimated read: 9 min | |

The previous chapters covered the route configuration, the loaders, the actions, the error boundaries, the route guards, and the lazy loading. This chapter covers the testing: the verification that the routes render the correct components, that the loaders fetch the correct data, that the actions mutate the state, and that the guards redirect the unauthenticated users. The LFCS exam does not test React Router, but the front-end engineering certification paths do. The critical distinction is between the createMemoryRouter and the createBrowserRouter. The createBrowserRouter uses the browser’s history API, which is not available in the test environment. The createMemoryRouter uses the in-memory history and accepts the initialEntries for the starting URL. The RouterProvider renders either. The createMemoryRouter is the test-only router, and it is the recommended for the component tests and the full-route tests.

Key point: The createMemoryRouter(routes, { initialEntries: ['/posts'], initialIndex: 0 }) creates the router with the in-memory history. The RouterProvider renders the router. The test uses the render from the @testing-library/react. The screen.findByText waits for the async data. The userEvent simulates the user actions. The createMemoryRouter supports the loaders, the actions, the error elements, and the useNavigation state, which makes it the complete test harness.


Why the createMemoryRouter requires a specific mental model

The browser history problem. The createBrowserRouter uses the window.history and the window.location. In the test environment (the jsdom or the node), the window.history exists but the navigation is the real. The createMemoryRouter keeps the history in the memory, which is the isolated per test. The initialEntries sets the starting URL, and the initialIndex sets the position in the history stack.

The loader problem. The loader fetches the data asynchronously. The createMemoryRouter runs the loader on the route match. The test must wait for the loader to complete. The router.state.initialized or the waitFor from the @testing-library/react is the wait mechanism. The await router.load() is the more direct approach.

The action problem. The action runs on the form submission. The <Form> submits to the action, and the createMemoryRouter handles it. The test uses the userEvent.click on the submit button, the waitFor on the action result, and the assertion on the new state.

The error element problem. The loader throws. The createMemoryRouter renders the errorElement. The test asserts the error message. The error boundary is the part of the route tree, and the createMemoryRouter exercises it.

The guard problem. The loader redirects. The createMemoryRouter follows the redirect. The test asserts the new route (the login) and the redirect param. The guard is the loader, and the createMemoryRouter is the full.

The trade-off. The createMemoryRouter is the most complete test harness. It exercises the loaders, the actions, the errors, the guards, and the lazy loading. The component test with the plain render and the mocked hooks is the faster but the incomplete. The createMemoryRouter is the correct for the integration tests.


a. The basic test with the createMemoryRouter

The test creates the router with the initialEntries and renders it.

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

function PostsPage() {
  const posts = useLoaderData();
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

const routes = [
  {
    path: "/posts",
    Component: PostsPage,
    loader: () => [
      { id: "1", title: "First" },
      { id: "2", title: "Second" },
    ],
  },
];

test("renders the posts", async () => {
  const router = createMemoryRouter(routes, { initialEntries: ["/posts"] });
  render(<RouterProvider router={router} />);

  expect(await screen.findByText("First")).toBeInTheDocument();
  expect(await screen.findByText("Second")).toBeInTheDocument();
});

The createMemoryRouter(routes, { initialEntries: ["/posts"] }) creates the router. The render renders it. The findByText waits for the async loader. The assertion confirms.

The await router.load() is the alternative for the explicit waiting.

test("renders with the explicit load", async () => {
  const router = createMemoryRouter(routes, { initialEntries: ["/posts"] });
  render(<RouterProvider router={router} />);
  await router.load();

  expect(screen.getByText("First")).toBeInTheDocument();
});

The router.load() awaits the initial loaders. The getByText is the synchronous after the load. The two approaches are equivalent.


b. The action and the form submission

The test simulates the form submission and the action result.

import userEvent from "@testing-library/user-event";

test("submits the form and redirects", async () => {
  const router = createMemoryRouter(
    [
      {
        path: "/new",
        Component: NewPost,
        action: async ({ request }) => {
          const data = await request.formData();
          posts.push({ id: "3", title: data.get("title") });
          return redirect("/posts");
        },
      },
      {
        path: "/posts",
        Component: PostsPage,
        loader: () => posts,
      },
    ],
    { initialEntries: ["/new"] }
  );

  render(<RouterProvider router={router} />);
  const user = userEvent.setup();

  await user.type(screen.getByRole("textbox"), "Third post");
  await user.click(screen.getByRole("button", { name: /save/i }));

  expect(await screen.findByText("Third post")).toBeInTheDocument();
});

The userEvent.type types the input. The userEvent.click submits the form. The action adds the post and returns the redirect. The router follows the redirect. The findByText waits for the new post.

The waitFor is the alternative for the specific state.

await waitFor(() => {
  expect(router.state.location.pathname).toBe("/posts");
});

The router.state.location reads the current location. The waitFor waits for the state change.


c. The loader error and the guard redirect

The test asserts the error element and the guard redirect.

test("renders the error element", async () => {
  const router = createMemoryRouter(
    [
      {
        path: "/posts/:id",
        Component: PostPage,
        loader: () => {
          throw new Response("Not Found", { status: 404 });
        },
        ErrorBoundary: PostError,
      },
    ],
    { initialEntries: ["/posts/999"] }
  );

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

  expect(await screen.findByText(/not found/i)).toBeInTheDocument();
});

The loader throws the Response. The ErrorBoundary renders the PostError. The findByText waits for the error message.

The guard redirect test.

test("redirects the unauthenticated user", async () => {
  const router = createMemoryRouter(
    [
      {
        path: "/app",
        loader: () => {
          throw redirect("/login");
        },
        Component: AppLayout,
      },
      {
        path: "/login",
        Component: () => <p>Login page</p>,
      },
    ],
    { initialEntries: ["/app"] }
  );

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

  expect(await screen.findByText("Login page")).toBeInTheDocument();
  expect(router.state.location.pathname).toBe("/login");
});

The loader throws the redirect. The router follows it. The findByText and the router.state.location.pathname confirm the new route.


Complete Example Session

# ============================================
# PART 1: BASIC ROUTER
# ============================================

const router = createMemoryRouter(routes, { initialEntries: ["/posts"] });
render(<RouterProvider router={router} />);
# ============================================
# PART 2: AWAIT LOADER
# ============================================

await router.load();
# ============================================
# PART 3: ASYNC ASSERTION
# ============================================

expect(await screen.findByText("First")).toBeInTheDocument();
# ============================================
# PART 4: USER EVENT
# ============================================

const user = userEvent.setup();
await user.type(screen.getByRole("textbox"), "New");
# ============================================
# PART 5: CLICK SUBMIT
# ============================================

await user.click(screen.getByRole("button"));
# ============================================
# PART 6: ACTION REDIRECT
# ============================================

action: async () => redirect("/posts")
# ============================================
# PART 7: ERROR ELEMENT
# ============================================

loader: () => { throw new Response("Not Found", { status: 404 }); }
# ============================================
# PART 8: GUARD REDIRECT
# ============================================

loader: () => { throw redirect("/login"); }
# ============================================
# PART 9: VERIFY LOCATION
# ============================================

expect(router.state.location.pathname).toBe("/login");

The nine parts trace the testing: the router, the await, the assertion, the user event, the click, the action, the error, the guard, and the location.


Quick Reference

Router Setup

ToolPurpose
createMemoryRouterIn-memory router
initialEntriesStarting URL
initialIndexStarting position
RouterProviderRender the router

Async Testing

ToolPurpose
await router.load()Await the initial loaders
findByTextWait for the element
waitForWait for the condition

User Events

ToolPurpose
userEvent.setup()Setup the user
userEvent.typeType the input
userEvent.clickClick the button

State Reading

PropertyPurpose
router.state.location.pathnameCurrent URL
router.state.navigation.stateNavigation state

Best Practices

✅ Do This:

createMemoryRouter(routes, { initialEntries: ["/posts"] })
<!-- Use the memory router for the tests -->          # ✅
await screen.findByText("First")
<!-- Use the async assertion -->                     # ✅
expect(router.state.location.pathname).toBe("/login")
<!-- Verify the redirect -->                        # ✅

❌ Don’t Do This:

# Use the createBrowserRouter in the test
<!-- The browser history is the test state -->       # ❌
# Use the getByText for the async data
<!-- The element may not be ready -->                # ❌
# Skip the user event
<!-- The form submission is not tested -->           # ❌

Common Pitfalls

PitfallWhy It HappensFix
Data not foundNo findByTextUse the async
Test hangsNo router.load()Await the load
Form not submittingNo userEventUse the click
Redirect not verifiedNo router.stateRead the location
Error not showingNo ErrorBoundaryAdd the boundary

Real-World Examples

1. Memory Router

createMemoryRouter(routes, { initialEntries: ["/"] })

2. Render

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

3. Await Load

await router.load()

4. Async Find

await screen.findByText("...")

5. User Setup

const user = userEvent.setup()

6. User Type

await user.type(input, "text")

7. User Click

await user.click(button)

8. Assert Location

expect(router.state.location.pathname).toBe("/login")

9. Error Element

ErrorBoundary: PostError

10. Redirect

throw redirect("/login")

Visual

Test Flow

┌──────────────────────────────────────────────┐
│  createMemoryRouter(routes, {                │
│    initialEntries: ["/posts"]                │
│  })                                          │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  render(<RouterProvider router={router} />)  │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  await router.load()                         │
│  → Loader runs → Data in the router state    │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  await screen.findByText("First")            │
│  → Assert the rendered content               │
└──────────────────────────────────────────────┘

Form Test Flow

┌──────────────────────────────────────────────┐
│  render + userEvent.setup()                  │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  user.type(input, "text")                    │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  user.click(button)                          │
│  → Form submits to the action                │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  await screen.findByText("New post")         │
│  → Assert the result                         │
└──────────────────────────────────────────────┘

Summary

ItemValue
Test routercreateMemoryRouter
Initial URLinitialEntries
Render<RouterProvider />
Awaitrouter.load()
Async findfindByText
User eventsuserEvent
Location checkrouter.state.location
ErrorErrorBoundary
Redirectrouter.state.location.pathname

Key takeaways:

  • The createMemoryRouter is the test-only router. It uses the in-memory history, not the browser’s. The initialEntries sets the starting URL, and the initialIndex sets the position. The full route tree is exercised.
  • The async assertions wait for the loaders. The router.load() awaits the initial loaders. The findByText waits for the async elements. The getByText is the synchronous and fails on the async.
  • The userEvent simulates the user actions. The userEvent.setup(), the userEvent.type, the userEvent.click. The form submission is the real, and the action runs.
  • The router.state is the verifiable. The router.state.location.pathname reads the current URL. The redirect is the assertion. The navigation state is the assertion.
  • The ErrorBoundary is the route property. The ErrorBoundary in the route object catches the loader throw. The test asserts the error message. The error is the part of the route tree.

Remember: The createMemoryRouter is the test. The initialEntries is the URL. The render is the component tree. The router.load() is the await. The findByText is the async. The userEvent is the action. The router.state is the verification. The exam tests the test harness and the async patterns.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!