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.
Before we dive into setup, here's what you get:
window, document, localStorage, IntersectionObserver, ResizeObserver, CSS computed styles, and every other browser API work nativelytoMatchScreenshot() and catch unintended UI changes automaticallydescribe, it, expect you already know. Your existing test logic works, you're just swapping the environmentStart 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
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.jsprovider: playwright() specifies Playwright as the browser automation driverinstances defines which browsers to test against. You can add firefox and webkit here too for cross-browser testingAdd a test script to your package.json:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui"
}
}
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 APIexpect.element() wraps a locator for DOM assertions like toBeInTheDocument() and toHaveTextContent()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.
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.
Here's where the gap between jsdom and a real browser becomes obvious. Let's test some APIs that jsdom can't handle.
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.
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");
});
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",
});
});
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.
Here's a direct comparison of what works differently:
| Feature | jsdom / happy-dom | Vitest Browser Mode |
|---|---|---|
| CSS & computed styles | No CSS engine, returns empty values | Full CSS engine, real computed styles |
| IntersectionObserver | Must be mocked manually | Works natively |
| ResizeObserver | Must be mocked manually | Works natively |
| Layout calculations | getBoundingClientRect() returns zeros | Returns real dimensions |
| Event handling | Synthetic, doesn't match browser behavior | Real browser events via CDP |
| Canvas / WebGL | Requires canvas polyfill | Native support |
| Web Animations API | Not supported | Native support |
| Visual regression | Not possible | Built-in toMatchScreenshot() |
| Media queries | Not supported | Works with real viewport |
| Focus management | Partial, inconsistent | Matches real browser behavior |
| Speed | Faster (no browser startup) | Slightly slower, but parallelizable |
Browser mode is not a wholesale replacement for jsdom in every scenario. Here's when to use each:
Use browser mode when:
Use jsdom when:
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.
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.
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.