Testing React Router v7 Loaders and Actions with Vitest

Testing React Router v7 Loaders and Actions with Vitest

Loaders and actions in React Router v7 are plain async functions. Testing them directly — without rendering any component — gives you fast, reliable, and surgical feedback. This post covers every scenario: happy paths, error responses, redirects, validation failures, and async mocking patterns using Vitest.

Key Takeaways

Test loaders and actions as functions, not through components. Mounting a component to test a loader is slow and hides whether the loader itself is the problem. Call the function directly with a constructed request object. Redirects are thrown Responses, not return values. Use try/catch or expect().rejects to assert redirect behavior. A loader that redirects will never resolve — it throws. Mock at the module boundary, not inside the function. Use vi.mock() at the top of the test file to replace database calls and session utilities. This keeps tests fast and deterministic.

Why Test Loaders and Actions Directly

React Router v7 makes a clean architectural decision: loaders and actions are exported functions. They receive a typed LoaderFunctionArgs or ActionFunctionArgs object and return either a Response or a plain serializable value.

This means you don't need a browser, a JSDOM environment, or a rendered component to test them. You just call the function.

Compare the two approaches:

// Slow: renders component, waits for loader, inspects DOM
const Stub = createRoutesStub([{ path: "/", Component: Home, loader }]);
render(<Stub initialEntries={["/"]} />);
await screen.findByText("Alice");

// Fast: calls loader directly, inspects response
const response = await loader({ request: new Request("http://localhost/"), params: {}, context: {} });
const data = await response.json();
expect(data.user.name).toBe("Alice");

The direct approach runs in milliseconds, gives you a precise error message when it fails, and doesn't depend on React rendering behavior at all.

Setting Up Vitest for Loader/Action Tests

Loader and action tests don't need JSDOM — they run in Node. Configure Vitest to use the node environment for server-side test files:

// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node", // for loader/action tests
    globals: true,
  },
});

Or use a per-file comment if your project mixes browser and server tests:

// app/routes/dashboard.test.ts
// @vitest-environment node

import { describe, it, expect } from "vitest";

Building Request Objects for Tests

Every loader and action receives a Request. Build a helper to reduce boilerplate:

// test/helpers/request.ts
export function buildRequest(
  url: string,
  options?: { method?: string; formData?: Record<string, string>; headers?: Record<string, string> }
): Request {
  if (options?.formData) {
    const fd = new FormData();
    for (const [key, value] of Object.entries(options.formData)) {
      fd.set(key, value);
    }
    return new Request(url, {
      method: options.method ?? "POST",
      body: fd,
      headers: options.headers,
    });
  }

  return new Request(url, {
    method: options?.method ?? "GET",
    headers: options.headers,
  });
}

Testing Loaders: Happy Path

// app/routes/products/$id.tsx
export async function loader({ params }: LoaderFunctionArgs) {
  const product = await db.product.findUnique({
    where: { id: params.id },
    include: { category: true },
  });

  if (!product) {
    throw new Response("Not Found", { status: 404 });
  }

  return json({ product });
}
// app/routes/products/$id.test.ts
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from "vitest";
import { loader } from "./products.$id";
import { db } from "~/db.server";

vi.mock("~/db.server", () => ({
  db: {
    product: {
      findUnique: vi.fn(),
    },
  },
}));

const mockProduct = {
  id: "prod-1",
  name: "Test Widget",
  price: 29.99,
  category: { id: "cat-1", name: "Widgets" },
};

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

  it("returns product with category", async () => {
    vi.mocked(db.product.findUnique).mockResolvedValue(mockProduct);

    const response = await loader({
      request: new Request("http://localhost/products/prod-1"),
      params: { id: "prod-1" },
      context: {},
    });

    const data = await response.json();
    expect(data.product.name).toBe("Test Widget");
    expect(data.product.category.name).toBe("Widgets");
  });

  it("passes correct id to database query", async () => {
    vi.mocked(db.product.findUnique).mockResolvedValue(mockProduct);

    await loader({
      request: new Request("http://localhost/products/prod-42"),
      params: { id: "prod-42" },
      context: {},
    });

    expect(db.product.findUnique).toHaveBeenCalledWith({
      where: { id: "prod-42" },
      include: { category: true },
    });
  });
});

Testing Loader Error Responses

describe("product loader errors", () => {
  it("throws 404 when product does not exist", async () => {
    vi.mocked(db.product.findUnique).mockResolvedValue(null);

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

  it("propagates database errors", async () => {
    vi.mocked(db.product.findUnique).mockRejectedValue(
      new Error("DB connection failed")
    );

    await expect(
      loader({
        request: new Request("http://localhost/products/prod-1"),
        params: { id: "prod-1" },
        context: {},
      })
    ).rejects.toThrow("DB connection failed");
  });
});

Testing Redirect Behavior in Loaders

Authentication redirects are one of the most common patterns in React Router v7. The requireUser utility throws a redirect response when the session is invalid.

// app/routes/dashboard.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireUser(request); // throws redirect("/login") if not auth'd
  const stats = await getUserStats(user.id);
  return json({ user, stats });
}
// app/routes/dashboard.test.ts
import { redirect } from "react-router";
import { requireUser } from "~/session.server";

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

describe("dashboard loader authentication", () => {
  it("redirects to login when unauthenticated", async () => {
    vi.mocked(requireUser).mockImplementation(() => {
      throw redirect("/login");
    });

    let caught: Response | null = null;
    try {
      await loader({
        request: new Request("http://localhost/dashboard"),
        params: {},
        context: {},
      });
    } catch (e) {
      caught = e as Response;
    }

    expect(caught).not.toBeNull();
    expect(caught?.status).toBe(302);
    expect(caught?.headers.get("Location")).toBe("/login");
  });

  it("redirects to specific path with flash message", async () => {
    vi.mocked(requireUser).mockImplementation(() => {
      throw redirect("/login?error=session-expired");
    });

    try {
      await loader({
        request: new Request("http://localhost/dashboard"),
        params: {},
        context: {},
      });
      expect.fail("Expected redirect to be thrown");
    } catch (e) {
      const response = e as Response;
      expect(new URL(response.headers.get("Location")!, "http://localhost").searchParams.get("error")).toBe("session-expired");
    }
  });
});

Testing Actions: Validation Errors

// app/routes/profile/edit.tsx
export async function action({ request }: ActionFunctionArgs) {
  const user = await requireUser(request);
  const formData = await request.formData();

  const name = String(formData.get("name") ?? "").trim();
  const bio = String(formData.get("bio") ?? "").trim();

  const errors: Record<string, string> = {};
  if (!name) errors.name = "Name is required";
  if (name.length > 100) errors.name = "Name must be 100 characters or less";
  if (bio.length > 500) errors.bio = "Bio must be 500 characters or less";

  if (Object.keys(errors).length > 0) {
    return json({ errors }, { status: 422 });
  }

  await db.user.update({ where: { id: user.id }, data: { name, bio } });
  return json({ success: true });
}
// app/routes/profile/edit.test.ts
import { buildRequest } from "~/test/helpers/request";

describe("profile edit action", () => {
  beforeEach(() => {
    vi.mocked(requireUser).mockResolvedValue({ id: "user-1" });
  });

  it("returns 422 with field errors when name is missing", async () => {
    const response = await action({
      request: buildRequest("http://localhost/profile/edit", {
        formData: { name: "", bio: "Some bio" },
      }),
      params: {},
      context: {},
    });

    expect(response.status).toBe(422);
    const data = await response.json();
    expect(data.errors.name).toBe("Name is required");
    expect(data.errors.bio).toBeUndefined();
  });

  it("returns 422 when name exceeds max length", async () => {
    const response = await action({
      request: buildRequest("http://localhost/profile/edit", {
        formData: { name: "a".repeat(101), bio: "" },
      }),
      params: {},
      context: {},
    });

    expect(response.status).toBe(422);
    const data = await response.json();
    expect(data.errors.name).toContain("100 characters");
  });

  it("updates user and returns success on valid data", async () => {
    vi.mocked(db.user.update).mockResolvedValue({ id: "user-1", name: "Alice", bio: "Hello" });

    const response = await action({
      request: buildRequest("http://localhost/profile/edit", {
        formData: { name: "Alice", bio: "Hello" },
      }),
      params: {},
      context: {},
    });

    expect(response.status).toBe(200);
    const data = await response.json();
    expect(data.success).toBe(true);
    expect(db.user.update).toHaveBeenCalledWith({
      where: { id: "user-1" },
      data: { name: "Alice", bio: "Hello" },
    });
  });
});

Testing Actions That Redirect on Success

Some actions redirect after a successful write. Since redirects are thrown in React Router v7, you need to catch them:

// app/routes/posts/new.tsx
export async function action({ request }: ActionFunctionArgs) {
  const user = await requireUser(request);
  const formData = await request.formData();
  const title = String(formData.get("title"));

  if (!title) {
    return json({ error: "Title required" }, { status: 400 });
  }

  const post = await db.post.create({ data: { title, authorId: user.id } });
  throw redirect(`/posts/${post.id}`);
}
it("redirects to new post after creation", async () => {
  vi.mocked(db.post.create).mockResolvedValue({ id: "post-99", title: "Hello" });

  let redirectResponse: Response | null = null;
  try {
    await action({
      request: buildRequest("http://localhost/posts/new", {
        formData: { title: "Hello World" },
      }),
      params: {},
      context: {},
    });
  } catch (e) {
    redirectResponse = e as Response;
  }

  expect(redirectResponse?.status).toBe(302);
  expect(redirectResponse?.headers.get("Location")).toBe("/posts/post-99");
});

Testing Actions with Side Effects

If your action sends an email, posts to a webhook, or writes to a cache — mock those dependencies and assert they were called with the right arguments:

it("sends welcome email when user completes onboarding", async () => {
  const sendEmail = vi.mocked(emailService.send);
  sendEmail.mockResolvedValue(undefined);

  await action({
    request: buildRequest("http://localhost/onboarding/complete", {
      formData: { step: "done" },
    }),
    params: {},
    context: {},
  });

  expect(sendEmail).toHaveBeenCalledOnce();
  expect(sendEmail).toHaveBeenCalledWith(
    expect.objectContaining({
      to: "user@example.com",
      template: "welcome",
    })
  );
});

Common Mistakes

Mistake: Testing through the component instead of the function. If your loader has a bug, the component test will fail with a cryptic React error instead of a clear assertion failure. Test the loader directly first.

Mistake: Not calling response.json() before asserting. json() returns a Response. The data is serialized — you must await .json() to read it.

Mistake: Forgetting that redirects throw. If you return redirect(...) inside a loader, React Router v7 still throws it internally. Always use throw redirect(...) explicitly, and always catch it in tests.

Mistake: Sharing mock state between tests. Use beforeEach(() => vi.clearAllMocks()) in every test file. Stale mock return values cause hard-to-diagnose flaky tests.

Running Loader/Action Tests Fast

With a clean Vitest setup, a full suite of loader and action tests for a mid-size app should complete in under 5 seconds. No browser, no JSDOM, no component mounting.

For the E2E layer — where you need a real browser, real network, and real redirects — HelpMeTest provides cloud-hosted Playwright test execution at $100/month flat. Write the test once, run it on every deploy without managing browser environments.

Read more