React Router v7 Testing: Complete Guide for 2026

React Router v7 Testing: Complete Guide for 2026

React Router v7 unified the React Router and Remix libraries into one package, introducing routes.ts, framework mode SSR, and new testing APIs. This guide covers how to test every layer of a v7 app — from individual loaders to full E2E flows — using Vitest, React Testing Library, and Playwright.

Key Takeaways

createRemixStub is gone — use createRoutesStub. The testing stub moved from @remix-run/testing to react-router itself. Any existing Remix test that imports createRemixStub will break immediately after migration. Loaders and actions are plain async functions. Test them in isolation without mounting any component. This is the fastest, most reliable test layer and where most of your test coverage should live. E2E tests should verify SSR hydration. Framework mode renders on the server first. A Playwright test that checks content before JS loads catches hydration mismatches that unit tests never will.

What Changed in React Router v7

React Router v7 arrived in late 2024 as a unification of the React Router and Remix projects. If you used Remix, you were already using React Router v7's patterns — the merge made that official. For apps coming from React Router v6, the change is more significant.

The key differences that affect testing:

routes.ts is the new route manifest. Instead of JSX-based route definitions inside a component tree, v7 uses a routes.ts file that exports a route config array. This file is the single source of truth for your routing structure.

// app/routes.ts
import { type RouteConfig, route, index } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("dashboard", "routes/dashboard.tsx", [
    index("routes/dashboard/index.tsx"),
    route("settings", "routes/dashboard/settings.tsx"),
  ]),
  route("login", "routes/login.tsx"),
] satisfies RouteConfig;

Framework mode vs library mode. Framework mode (SSR) works like Remix — loaders run on the server, HTML is streamed, React hydrates the client. Library mode is a traditional SPA. Your testing strategy differs between them, particularly for E2E.

Import paths changed. Everything that was @remix-run/react is now react-router. Your test files import createRoutesStub from react-router, not from a separate testing package.

Testing Philosophy for React Router v7

A healthy test suite for a v7 app has three layers:

  1. Unit tests for loaders and actions — these are plain async functions, test them directly with Vitest
  2. Integration tests for route components — mount routes with createRoutesStub, simulate navigation, assert rendered output
  3. E2E tests — full browser automation with Playwright, covers SSR hydration, real network requests, and multi-step user flows

The most common mistake is skipping layer 1 and trying to test loaders by rendering a full component. That's slower, harder to debug, and doesn't tell you whether the loader itself is correct.

Unit Testing: Loaders

Loaders are async functions that receive a LoaderFunctionArgs object. Test them directly.

// app/routes/dashboard.tsx
export async function loader({ request, params }: LoaderFunctionArgs) {
  const userId = requireUserId(request); // throws redirect if not authed
  const data = await db.user.findUnique({ where: { id: userId } });
  if (!data) throw new Response("Not Found", { status: 404 });
  return json({ user: data });
}
// app/routes/dashboard.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { loader } from "./dashboard";
import { db } from "~/db.server";
import { createRequest } from "~/test-utils/request";

vi.mock("~/db.server");
vi.mock("~/session.server", () => ({
  requireUserId: vi.fn().mockResolvedValue("user-123"),
}));

describe("dashboard loader", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("returns user data for authenticated user", async () => {
    vi.mocked(db.user.findUnique).mockResolvedValue({
      id: "user-123",
      name: "Alice",
      email: "alice@example.com",
    });

    const response = await loader({
      request: new Request("http://localhost/dashboard"),
      params: {},
      context: {},
    });

    const data = await response.json();
    expect(data.user.name).toBe("Alice");
  });

  it("throws 404 when user not found", async () => {
    vi.mocked(db.user.findUnique).mockResolvedValue(null);

    await expect(
      loader({
        request: new Request("http://localhost/dashboard"),
        params: {},
        context: {},
      })
    ).rejects.toMatchObject({ status: 404 });
  });
});

Unit Testing: Actions

Actions handle form submissions and mutations. Same pattern — test them as functions.

// app/routes/login.tsx
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const email = String(formData.get("email"));
  const password = String(formData.get("password"));

  if (!email || !password) {
    return json({ error: "Email and password required" }, { status: 400 });
  }

  const user = await authenticateUser(email, password);
  if (!user) {
    return json({ error: "Invalid credentials" }, { status: 401 });
  }

  const session = await createUserSession(user.id);
  return redirect("/dashboard", {
    headers: { "Set-Cookie": session },
  });
}
// app/routes/login.test.ts
import { describe, it, expect, vi } from "vitest";
import { action } from "./login";

vi.mock("~/auth.server");
vi.mock("~/session.server");

function buildRequest(formFields: Record<string, string>) {
  const formData = new FormData();
  for (const [key, value] of Object.entries(formFields)) {
    formData.set(key, value);
  }
  return new Request("http://localhost/login", {
    method: "POST",
    body: formData,
  });
}

describe("login action", () => {
  it("returns 400 when fields are missing", async () => {
    const response = await action({
      request: buildRequest({ email: "" }),
      params: {},
      context: {},
    });
    expect(response.status).toBe(400);
    const data = await response.json();
    expect(data.error).toBe("Email and password required");
  });

  it("returns 401 for invalid credentials", async () => {
    vi.mocked(authenticateUser).mockResolvedValue(null);
    const response = await action({
      request: buildRequest({ email: "x@x.com", password: "wrong" }),
      params: {},
      context: {},
    });
    expect(response.status).toBe(401);
  });

  it("redirects to dashboard on successful login", async () => {
    vi.mocked(authenticateUser).mockResolvedValue({ id: "user-1" });
    vi.mocked(createUserSession).mockResolvedValue("session=abc");

    const response = await action({
      request: buildRequest({ email: "alice@example.com", password: "correct" }),
      params: {},
      context: {},
    });

    expect(response.status).toBe(302);
    expect(response.headers.get("Location")).toBe("/dashboard");
  });
});

Integration Testing: Route Components with createRoutesStub

When you need to test a component that uses useLoaderData, useActionData, or React Router hooks, use createRoutesStub from react-router.

// app/routes/dashboard/index.test.tsx
import { render, screen } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { describe, it, expect } from "vitest";
import DashboardIndex from "./index";

describe("DashboardIndex", () => {
  it("renders user name from loader data", async () => {
    const Stub = createRoutesStub([
      {
        path: "/dashboard",
        Component: DashboardIndex,
        loader() {
          return { user: { name: "Alice", email: "alice@example.com" } };
        },
      },
    ]);

    render(<Stub initialEntries={["/dashboard"]} />);

    // Wait for loader data to appear
    expect(await screen.findByText("Alice")).toBeInTheDocument();
  });

  it("shows error state when loader returns error flag", async () => {
    const Stub = createRoutesStub([
      {
        path: "/dashboard",
        Component: DashboardIndex,
        loader() {
          return { error: "Failed to load data" };
        },
      },
    ]);

    render(<Stub initialEntries={["/dashboard"]} />);

    expect(await screen.findByText("Failed to load data")).toBeInTheDocument();
  });
});

Testing Error Boundaries

React Router v7 uses ErrorBoundary exports on route modules. Test them with createRoutesStub by having the loader throw.

import { render, screen } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { Dashboard, ErrorBoundary } from "./dashboard";

it("renders error boundary when loader throws", async () => {
  const Stub = createRoutesStub([
    {
      path: "/dashboard",
      Component: Dashboard,
      ErrorBoundary,
      loader() {
        throw new Response("Not Found", { status: 404 });
      },
    },
  ]);

  render(<Stub initialEntries={["/dashboard"]} />);

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

Testing Redirect Behavior

Redirects from loaders and actions are responses — test them directly without mounting a component.

it("redirects unauthenticated users to login", async () => {
  vi.mocked(requireUserId).mockImplementation(() => {
    throw redirect("/login");
  });

  try {
    await loader({ request: new Request("http://localhost/dashboard"), params: {}, context: {} });
    expect.fail("Expected redirect");
  } catch (response) {
    expect(response instanceof Response).toBe(true);
    expect((response as Response).headers.get("Location")).toBe("/login");
  }
});

E2E Testing with Playwright

For end-to-end coverage, especially SSR hydration verification:

// e2e/dashboard.spec.ts
import { test, expect } from "@playwright/test";

test("dashboard loads with server-rendered content", async ({ page }) => {
  // Disable JS to verify SSR
  await page.context().setJavaScriptEnabled(false);
  await page.goto("/dashboard");
  // Content should be visible even without JS (SSR)
  await expect(page.getByRole("heading", { name: /dashboard/i })).toBeVisible();
});

test("form action submits and redirects", async ({ page }) => {
  await page.goto("/login");
  await page.fill('[name="email"]', "alice@example.com");
  await page.fill('[name="password"]', "password123");
  await page.click('[type="submit"]');
  await expect(page).toHaveURL("/dashboard");
});

Running Tests in CI

Your package.json scripts should separate unit and E2E runs:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:e2e": "playwright test",
    "test:coverage": "vitest run --coverage"
  }
}

For cloud-hosted E2E without managing Playwright infrastructure locally or in CI, HelpMeTest runs your Robot Framework + Playwright tests on demand — $100/month flat, unlimited tests. It removes the overhead of maintaining browser environments across machines and CI runners.

Summary

React Router v7 testing works best in three layers: unit-test loaders and actions as plain functions (fastest feedback), integration-test route components with createRoutesStub (replaces createRemixStub), and E2E-test critical flows with Playwright including SSR hydration. Most of the coverage should live in the unit layer — loaders and actions are pure functions and trivially testable.

Read more