React Router 19 ⚛️ Testing Routes — Testing with createMemoryRouter
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
| Tool | Purpose |
|---|---|
createMemoryRouter | In-memory router |
initialEntries | Starting URL |
initialIndex | Starting position |
RouterProvider | Render the router |
Async Testing
| Tool | Purpose |
|---|---|
await router.load() | Await the initial loaders |
findByText | Wait for the element |
waitFor | Wait for the condition |
User Events
| Tool | Purpose |
|---|---|
userEvent.setup() | Setup the user |
userEvent.type | Type the input |
userEvent.click | Click the button |
State Reading
| Property | Purpose |
|---|---|
router.state.location.pathname | Current URL |
router.state.navigation.state | Navigation 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Data not found | No findByText | Use the async |
| Test hangs | No router.load() | Await the load |
| Form not submitting | No userEvent | Use the click |
| Redirect not verified | No router.state | Read the location |
| Error not showing | No ErrorBoundary | Add 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
| Item | Value |
|---|---|
| Test router | createMemoryRouter |
| Initial URL | initialEntries |
| Render | <RouterProvider /> |
| Await | router.load() |
| Async find | findByText |
| User events | userEvent |
| Location check | router.state.location |
| Error | ErrorBoundary |
| Redirect | router.state.location.pathname |
Key takeaways:
- The
createMemoryRouteris the test-only router. It uses the in-memory history, not the browser’s. TheinitialEntriessets the starting URL, and theinitialIndexsets the position. The full route tree is exercised. - The async assertions wait for the loaders. The
router.load()awaits the initial loaders. ThefindByTextwaits for the async elements. ThegetByTextis the synchronous and fails on the async. - The
userEventsimulates the user actions. TheuserEvent.setup(), theuserEvent.type, theuserEvent.click. The form submission is the real, and the action runs. - The
router.stateis the verifiable. Therouter.state.location.pathnamereads the current URL. The redirect is the assertion. The navigation state is the assertion. - The
ErrorBoundaryis the route property. TheErrorBoundaryin 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.
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!