Testing React Router v7 with Vite + Vitest: Setup and Best Practices

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-dom

The 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 --coverage

This 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.json

The 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 beforeEach

For 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 | bash

Flat $100/month, unlimited runs — the testing infrastructure that lets your team focus on writing tests, not maintaining them.

Read more