Testing React Router v7 with Vite + Vitest: Setup and Best Practices
React Router v7 uses Vite as its default build tool, which makes Vitest the natural testing choice — it reuses the same Vite config, understands import.meta.env natively, and runs 10–40x faster than Jest on Vite-based projects. This guide covers the full Vitest setup for a React Router v7 app, including jsdom configuration, coverage, browser mode, and common pitfalls.
Key Takeaways
Vitest reuses your Vite config — that's the whole point. Unlike Jest, which requires separate Babel transforms and module aliasing, Vitest reads your vite.config.ts directly. Path aliases, plugins, and environment variables all work without extra configuration.
Split your test environments. Server-side code (loaders, actions, utilities) runs in node environment — no JSDOM overhead. Client-side components run in jsdom. Use @vitest-environment node comments or a projects config to keep them separate.
Coverage with @vitest/coverage-v8 is accurate for Vite projects. V8 coverage is native to Node — no source map transformations or Istanbul instrumentation needed. Set thresholds in vitest.config.ts to enforce coverage gates in CI.
Why Vitest Over Jest for React Router v7
React Router v7 ships with Vite as the default build tool. Your app's vite.config.ts defines path aliases, environment variables, and plugins. Jest doesn't read that config — you have to duplicate everything in jest.config.js and add Babel transforms.
Vitest reads vite.config.ts directly. Your ~ path alias works in tests without any extra config. Your Vite plugins run during test transforms. import.meta.env is available natively.
The performance difference is substantial. A cold Vitest run on a 200-file project takes 3–8 seconds. Jest on the same project, after Babel transform, takes 30–60 seconds.
Installing Vitest
npm install -D vitest @vitest/coverage-v8 jsdom @testing-library/react @testing-library/user-event @testing-library/jest-domThe vitest.config.ts Setup
React Router v7 already has a vite.config.ts. You can either extend it in vitest.config.ts or add test configuration directly to vite.config.ts. The separate file approach is cleaner for large projects:
// vitest.config.ts
import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config";
export default mergeConfig(
viteConfig,
defineConfig({
test: {
globals: true,
setupFiles: ["./tests/setup.ts"],
// Run component tests in jsdom, server tests in node
environment: "jsdom",
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["app/**/*.{ts,tsx}"],
exclude: [
"app/**/*.test.{ts,tsx}",
"app/entry.client.tsx",
"app/entry.server.tsx",
"app/root.tsx",
],
thresholds: {
lines: 80,
branches: 75,
functions: 80,
statements: 80,
},
},
},
})
);The mergeConfig call ensures your path aliases, plugins, and resolve settings from vite.config.ts apply to all test files. No duplication.
Setup File
// tests/setup.ts
import "@testing-library/jest-dom";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// Clean up after each test to prevent state leakage
afterEach(() => {
cleanup();
});Splitting Environments: Node vs JSDOM
Server-side code — loaders, actions, session utilities, database helpers — shouldn't run in JSDOM. JSDOM is slower to initialize and adds browser globals you don't need on the server.
Option 1: Per-file comment
// app/routes/dashboard.test.ts
// @vitest-environment node
import { loader } from "./dashboard";Option 2: Vitest projects config (cleanest for large apps)
// vitest.config.ts
import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config";
export default mergeConfig(
viteConfig,
defineConfig({
test: {
globals: true,
setupFiles: ["./tests/setup.ts"],
projects: [
{
// Server-side: loaders, actions, utilities
test: {
name: "node",
include: ["app/**/*.server.test.ts", "app/routes/**/*.test.ts"],
environment: "node",
},
},
{
// Client-side: components, hooks, UI interactions
test: {
name: "browser",
include: ["app/components/**/*.test.tsx", "app/hooks/**/*.test.ts"],
environment: "jsdom",
setupFiles: ["./tests/setup.ts"],
},
},
],
},
})
);Mocking import.meta.env
React Router v7 with Vite uses import.meta.env for environment variables. In Jest, this requires a plugin or manual mocking. In Vitest, it works natively with vi.stubEnv:
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
describe("feature flag behavior", () => {
beforeEach(() => {
vi.stubEnv("VITE_FEATURE_NEW_CHECKOUT", "true");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("shows new checkout UI when feature flag is enabled", async () => {
const { render, screen } = await import("@testing-library/react");
const { CheckoutButton } = await import("~/components/CheckoutButton");
render(<CheckoutButton />);
expect(screen.getByText("New Checkout")).toBeInTheDocument();
});
});For values that need to be set before module initialization, use the env option in vitest.config.ts:
// vitest.config.ts
test: {
env: {
VITE_API_BASE_URL: "http://localhost:3001",
VITE_STRIPE_KEY: "pk_test_fake",
},
}Testing Route Modules in Isolation
A React Router v7 route module exports several functions and components. Test each export independently:
// app/routes/products/$id.test.tsx
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { createRoutesStub } from "react-router";
// Import individual exports
import ProductPage, { loader, meta, ErrorBoundary } from "./products.$id";
import { db } from "~/db.server";
vi.mock("~/db.server");
const mockProduct = {
id: "prod-1",
name: "Widget Pro",
price: 29.99,
description: "The best widget",
images: [{ url: "https://example.com/widget.jpg", alt: "Widget" }],
};
describe("products/$id route", () => {
beforeEach(() => vi.clearAllMocks());
describe("loader", () => {
it("returns product data", 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("Widget Pro");
});
});
describe("meta function", () => {
it("returns correct title and description", () => {
const tags = meta({
data: { product: mockProduct },
params: { id: "prod-1" },
matches: [],
location: { pathname: "/products/prod-1", search: "", hash: "", state: null, key: "" },
});
expect(tags).toContainEqual({ title: "Widget Pro" });
expect(tags.some((t: any) => t.name === "description")).toBe(true);
});
});
describe("component", () => {
it("renders product details from loader data", async () => {
vi.mocked(db.product.findUnique).mockResolvedValue(mockProduct);
const Stub = createRoutesStub([
{
path: "/products/:id",
Component: ProductPage,
loader,
},
]);
render(<Stub initialEntries={["/products/prod-1"]} />);
expect(await screen.findByRole("heading", { name: "Widget Pro" })).toBeInTheDocument();
expect(screen.getByText("$29.99")).toBeInTheDocument();
});
});
describe("ErrorBoundary", () => {
it("renders error message for 404", async () => {
vi.mocked(db.product.findUnique).mockResolvedValue(null);
const Stub = createRoutesStub([
{
path: "/products/:id",
Component: ProductPage,
ErrorBoundary,
loader,
},
]);
render(<Stub initialEntries={["/products/nonexistent"]} />);
expect(await screen.findByText(/product not found/i)).toBeInTheDocument();
});
});
});Component Testing with @vitest/browser
Vitest's browser mode (stable as of Vitest 2.x) runs component tests in a real browser instead of JSDOM. This eliminates JSDOM quirks with scroll events, focus management, and CSS layout:
npm install -D @vitest/browser playwright// vitest.config.ts — add browser project
{
test: {
name: "browser-components",
include: ["app/components/**/*.browser.test.tsx"],
browser: {
enabled: true,
name: "chromium",
provider: "playwright",
headless: true,
},
},
}// app/components/DropdownMenu.browser.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DropdownMenu } from "./DropdownMenu";
test("dropdown opens on click and closes on outside click", async () => {
const user = userEvent.setup();
render(<DropdownMenu items={["Option A", "Option B"]} />);
const trigger = screen.getByRole("button", { name: "Menu" });
await user.click(trigger);
expect(screen.getByRole("listbox")).toBeInTheDocument();
// Click outside — JSDOM often fails this; browser mode handles it correctly
await user.click(document.body);
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});Running Tests with the Vite Dev Server
For integration tests that need a running server (not just a stub), use @playwright/test with the webServer option pointed at the Vite dev server. You can also use Vitest's globalSetup to start the server:
// tests/global-setup.ts
import { createServer } from "vite";
import type { GlobalSetupContext } from "vitest/node";
export async function setup({ provide }: GlobalSetupContext) {
const server = await createServer({
server: { port: 3001 },
});
await server.listen();
provide("viteServerPort", 3001);
return async () => {
await server.close();
};
}// vitest.config.ts
test: {
globalSetup: "./tests/global-setup.ts",
}Coverage Reports with @vitest/coverage-v8
V8 coverage doesn't require instrumentation — it uses the JavaScript engine's built-in coverage tracking. Run coverage with:
npx vitest run --coverageThis generates reports in coverage/ — HTML for local inspection, JSON for CI integration. Check coverage in CI:
# .github/workflows/test.yml
- name: Run tests with coverage
run: npx vitest run --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: coverage/coverage-final.jsonThe threshold enforcement in vitest.config.ts means Vitest exits with a non-zero code if coverage drops below your configured minimums — CI will fail automatically.
Why Vitest Is Better Than Jest for Vite Projects
| Feature | Vitest | Jest |
|---|---|---|
| Vite config reuse | Native | Manual duplication |
import.meta.env |
Native | Plugin required |
Path aliases (~/) |
Automatic | Manual Jest config |
| Cold start | 1–3s | 10–30s |
| HMR in watch mode | Yes | No |
| ESM support | Native | Experimental |
| Browser mode | Built-in | Third-party |
| TypeScript | Via Vite | Via Babel/ts-jest |
The main reason to stay on Jest is if you're migrating a large existing test suite — the Jest compatibility layer in Vitest means most Jest tests run unchanged. For a new React Router v7 project, Vitest is the clear choice.
Common Pitfalls
Pitfall: Using jest.fn() instead of vi.fn(). Vitest provides vi as the mock utility. With globals: true, vi is available globally — but if you have old Jest habits, you'll reach for jest.fn() and get a ReferenceError.
Pitfall: Forgetting cleanup() between tests. @testing-library/react auto-cleans after each test when used with Jest's afterEach auto-setup. With Vitest, you need to either import @testing-library/react/pure or add cleanup() to your setup file.
Pitfall: Treating import.meta.env stubs as synchronous. Module-level code that reads import.meta.env at import time won't pick up vi.stubEnv() changes. Use dynamic imports (await import(...)) after stubbing, or set env values in vitest.config.ts instead.
Pitfall: Running all tests in jsdom when some are server-only. JSDOM initialization adds 200–500ms per test worker. Server-only tests (loaders, actions) don't benefit from it. Splitting environments cuts your total test time significantly on large suites.
Putting It All Together
A complete React Router v7 test setup:
package.json scripts:
"test": "vitest run"
"test:watch": "vitest"
"test:coverage": "vitest run --coverage"
"test:ui": "vitest --ui"
"test:browser": "vitest run --project browser-components"
Coverage target: 80% lines/functions on app/ (excluding entry files)
Environments: node for routes/*.test.ts, jsdom for components/*.test.tsx
Setup: @testing-library/jest-dom in tests/setup.ts
Mocks: vi.mock() at module level, vi.clearAllMocks() in beforeEachFor cloud-hosted execution without managing Node versions, browser binaries, or CI runner configuration, HelpMeTest runs your Playwright tests on every deploy. The CLI sets up in seconds:
curl -fsSL https://helpmetest.com/install | bashFlat $100/month, unlimited runs — the testing infrastructure that lets your team focus on writing tests, not maintaining them.