Setup Vitest Browser Mode with Playwright in React

Why Browser Mode?

If you've been writing frontend tests with Vitest or Jest, you've likely been using jsdom or happy-dom as your test environment. These tools simulate browser APIs in Node.js, and while they work for many cases, they have a fundamental problem: they're not real browsers.

This means tests can pass in your test suite but fail in production. CSS doesn't exist. IntersectionObserver is stubbed or missing. ResizeObserver needs a polyfill. Layout calculations return zeros. Events don't bubble through a real rendering pipeline. You end up writing mocks for browser APIs that shouldn't need mocking.

Vitest browser mode solves this by running your tests inside an actual Chromium, Firefox, or WebKit browser powered by Playwright. Your code executes in the same environment your users interact with. No simulation, no mocks for native APIs, no surprises.

Benefits of Vitest Browser Mode

Before we dive into setup, here's what you get:

  • Real browser environment - window, document, localStorage, IntersectionObserver, ResizeObserver, CSS computed styles, and every other browser API work natively
  • Real event dispatching - Click events, keyboard events, and focus behavior work exactly like they do for real users, using Chrome DevTools Protocol instead of synthetic event simulation
  • Component testing - Render React components into a real DOM and interact with them using Playwright's locator API
  • Visual regression testing - Capture screenshots with toMatchScreenshot() and catch unintended UI changes automatically
  • Playwright traces - Debug failing tests with full trace recordings, just like you would with Playwright end-to-end tests
  • Parallel execution - Playwright supports running tests across multiple browser instances simultaneously
  • Familiar Vitest API - Same describe, it, expect you already know. Your existing test logic works, you're just swapping the environment

Installation

Start by installing the required packages. We need Vitest itself, the Playwright browser provider, and the React rendering package for component tests.

pnpm add -D vitest @vitest/browser-playwright vitest-browser-react

Playwright will automatically download the Chromium browser binary when you first run your tests. If you want to install it ahead of time:

pnpm exec playwright install chromium

Configuration

Create or update your vitest.config.ts at the project root. The key change is switching from the default Node.js environment to the browser environment with Playwright as the provider.

import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";

export default defineConfig({
  plugins: [react()],
  test: {
    browser: {
      enabled: true,
      provider: playwright(),
      instances: [{ browser: "chromium" }],
    },
  },
});

That's the minimal configuration. Let's break down what each piece does:

  • browser.enabled: true tells Vitest to run tests inside a browser instead of Node.js
  • provider: playwright() specifies Playwright as the browser automation driver
  • instances defines which browsers to test against. You can add firefox and webkit here too for cross-browser testing

Add a test script to your package.json:

{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui"
  }
}

Writing Your First Browser Test

Let's start with a basic DOM test to see how the API works. Create a file called src/dom.test.ts:

import { page } from "@vitest/browser/context";
import { expect, test } from "vitest";

test("can interact with the DOM directly", () => {
  // Create a real DOM element in a real browser
  const button = document.createElement("button");
  button.textContent = "Click me";
  document.body.appendChild(button);

  // Use Playwright locators to find elements
  const locator = page.getByRole("button", { name: "Click me" });

  // Assert with element matchers
  expect.element(locator).toBeInTheDocument();
  expect.element(locator).toHaveTextContent("Click me");
});

There are a few differences from what you're used to with jsdom:

  • page comes from @vitest/browser/context and gives you Playwright's locator API
  • expect.element() wraps a locator for DOM assertions like toBeInTheDocument() and toHaveTextContent()
  • The DOM you're manipulating is a real browser DOM, not a simulation

Run it:

pnpm test

The test runs inside a headless Chromium instance. You'll see Vitest's output just like normal, but your code actually executed in a browser.

Component Testing

This is where browser mode shines. You can render full React components into a real browser DOM and interact with them using Playwright's locators.

Let's say you have a counter component in src/Counter.tsx:

import { useState } from "react";

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p data-testid="count">Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
      <button onClick={() => setCount((c) => c - 1)}>Decrement</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

Now write a test in src/Counter.test.tsx:

import { page } from "@vitest/browser/context";
import { render } from "vitest-browser-react";
import { expect, test } from "vitest";
import { Counter } from "./Counter";

test("renders with initial count of 0", async () => {
  render(<Counter />);

  await expect
    .element(page.getByTestId("count"))
    .toHaveTextContent("Count: 0");
});

test("increments the count when clicking increment", async () => {
  render(<Counter />);

  await page.getByRole("button", { name: "Increment" }).click();

  await expect
    .element(page.getByTestId("count"))
    .toHaveTextContent("Count: 1");
});

test("decrements the count", async () => {
  render(<Counter />);

  await page.getByRole("button", { name: "Increment" }).click();
  await page.getByRole("button", { name: "Increment" }).click();
  await page.getByRole("button", { name: "Decrement" }).click();

  await expect
    .element(page.getByTestId("count"))
    .toHaveTextContent("Count: 1");
});

test("resets the count to zero", async () => {
  render(<Counter />);

  await page.getByRole("button", { name: "Increment" }).click();
  await page.getByRole("button", { name: "Increment" }).click();
  await page.getByRole("button", { name: "Reset" }).click();

  await expect
    .element(page.getByTestId("count"))
    .toHaveTextContent("Count: 0");
});

Notice that the API is very similar to @testing-library/react, but instead of screen.getByRole you use page.getByRole. The render function comes from vitest-browser-react instead of @testing-library/react. The assertions use expect.element() which wraps Playwright locators.

The key difference is that click() here triggers a real browser click event through the Chrome DevTools Protocol, not a synthetic fireEvent.click(). This means focus management, event bubbling, and side effects all behave exactly like they do for your users.

Testing Real Browser APIs

Here's where the gap between jsdom and a real browser becomes obvious. Let's test some APIs that jsdom can't handle.

IntersectionObserver

With jsdom, you'd need to mock IntersectionObserver entirely. In browser mode, it just works:

import { page } from "@vitest/browser/context";
import { render } from "vitest-browser-react";
import { expect, test } from "vitest";
import { useEffect, useRef, useState } from "react";

function LazyImage({ src, alt }: { src: string; alt: string }) {
  const ref = useRef<HTMLImageElement>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setIsVisible(true);
        observer.disconnect();
      }
    });

    if (ref.current) observer.observe(ref.current);

    return () => observer.disconnect();
  }, []);

  return (
    <img
      ref={ref}
      src={isVisible ? src : undefined}
      alt={alt}
      data-testid="lazy-image"
      data-visible={isVisible}
    />
  );
}

test("LazyImage loads when scrolled into view", async () => {
  render(<LazyImage src="https://example.com/photo.jpg" alt="A photo" />);

  // The IntersectionObserver fires in a real browser
  // because the element is rendered into the actual viewport
  await expect
    .element(page.getByTestId("lazy-image"))
    .toHaveAttribute("data-visible", "true");
});

No mocking required. The IntersectionObserver sees the element in the real viewport and fires its callback.

CSS Computed Styles

With jsdom, getComputedStyle returns empty values because there's no CSS engine. In browser mode, styles are real:

import { page } from "@vitest/browser/context";
import { render } from "vitest-browser-react";
import { expect, test } from "vitest";

function Badge({ variant }: { variant: "success" | "error" }) {
  return (
    <span
      data-testid="badge"
      style={{
        backgroundColor: variant === "success" ? "green" : "red",
        padding: "4px 8px",
        borderRadius: "4px",
        color: "white",
      }}
    >
      {variant === "success" ? "Passed" : "Failed"}
    </span>
  );
}

test("success badge has green background", async () => {
  render(<Badge variant="success" />);

  const badge = document.querySelector('[data-testid="badge"]') as HTMLElement;
  const styles = window.getComputedStyle(badge);

  expect(styles.backgroundColor).toBe("green");
  expect(styles.borderRadius).toBe("4px");
});

localStorage

While jsdom does provide a localStorage implementation, it's not the same as a real browser's storage. In browser mode you get the real thing:

import { expect, test, beforeEach } from "vitest";

beforeEach(() => {
  localStorage.clear();
});

test("localStorage persists data correctly", () => {
  localStorage.setItem("theme", "dark");
  localStorage.setItem("lang", "en");

  expect(localStorage.getItem("theme")).toBe("dark");
  expect(localStorage.length).toBe(2);

  // Storage events work in real browsers
  const storageData = JSON.stringify({ theme: "dark", lang: "en" });
  localStorage.setItem("settings", storageData);

  expect(JSON.parse(localStorage.getItem("settings")!)).toEqual({
    theme: "dark",
    lang: "en",
  });
});

Visual Regression Testing

Vitest 4.0 introduced built-in visual regression testing with toMatchScreenshot(). This captures a screenshot of your component and compares it against a baseline image on subsequent runs.

import { page } from "@vitest/browser/context";
import { render } from "vitest-browser-react";
import { expect, test } from "vitest";

function Card({ title, body }: { title: string; body: string }) {
  return (
    <div
      style={{
        border: "1px solid #e2e8f0",
        borderRadius: "8px",
        padding: "16px",
        maxWidth: "320px",
        fontFamily: "system-ui, sans-serif",
      }}
    >
      <h2 style={{ margin: "0 0 8px 0", fontSize: "18px" }}>{title}</h2>
      <p style={{ margin: 0, color: "#64748b", fontSize: "14px" }}>{body}</p>
    </div>
  );
}

test("Card component matches visual snapshot", async () => {
  render(
    <Card
      title="Setup Complete"
      body="Your Vitest browser mode is configured and ready to go."
    />
  );

  const card = page.getByRole("heading", { name: "Setup Complete" });
  await expect.element(card).toBeVisible();

  // Capture and compare screenshot
  await expect(page.screenshot()).toMatchScreenshot({
    name: "card-component",
  });
});

The first time you run this test it creates a baseline screenshot. On subsequent runs it compares the current screenshot to the baseline. If there's a visual difference, the test fails and generates a diff image showing exactly what changed.

This is useful for catching CSS regressions, layout shifts, and unintended visual changes that unit tests would never catch.

jsdom vs Browser Mode: A Comparison

Here's a direct comparison of what works differently:

Featurejsdom / happy-domVitest Browser Mode
CSS & computed stylesNo CSS engine, returns empty valuesFull CSS engine, real computed styles
IntersectionObserverMust be mocked manuallyWorks natively
ResizeObserverMust be mocked manuallyWorks natively
Layout calculationsgetBoundingClientRect() returns zerosReturns real dimensions
Event handlingSynthetic, doesn't match browser behaviorReal browser events via CDP
Canvas / WebGLRequires canvas polyfillNative support
Web Animations APINot supportedNative support
Visual regressionNot possibleBuilt-in toMatchScreenshot()
Media queriesNot supportedWorks with real viewport
Focus managementPartial, inconsistentMatches real browser behavior
SpeedFaster (no browser startup)Slightly slower, but parallelizable

When to Use Each

Browser mode is not a wholesale replacement for jsdom in every scenario. Here's when to use each:

Use browser mode when:

  • Testing components that rely on CSS, layout, or visual appearance
  • Testing browser APIs like IntersectionObserver, ResizeObserver, or Web Animations
  • You need confidence that tests match real user behavior
  • You want visual regression testing
  • You're debugging test failures caused by jsdom inconsistencies

Use jsdom when:

  • Running simple unit tests for utility functions or hooks that don't touch the DOM
  • You need maximum speed and have thousands of tests
  • CI environments with constrained resources where browser startup overhead matters

You can also mix both in the same project by using Vitest's workspace feature to run some tests in browser mode and others in Node.

Debugging with Playwright Traces

One more benefit worth calling out. You can enable Playwright traces to get a full recording of what happened during your test, including screenshots at each step, DOM snapshots, and network requests.

Update your config:

import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";

export default defineConfig({
  plugins: [react()],
  test: {
    browser: {
      enabled: true,
      provider: playwright({
        trace: "retain-on-failure",
      }),
      instances: [{ browser: "chromium" }],
    },
  },
});

When a test fails, Vitest saves a trace file that you can open with:

pnpm exec playwright show-trace path/to/trace.zip

This gives you a step-by-step timeline of everything that happened during the test, making debugging significantly easier than staring at a stack trace from jsdom.

Summary

Vitest browser mode with Playwright gives you the real browser environment you need for reliable frontend tests, without the overhead of writing full end-to-end tests. Setup takes five minutes, the API is familiar, and you get access to real browser APIs, real CSS, real events, and visual regression testing out of the box.