Most JavaScript test runners default to synthetic DOM environments like jsdom or happy-dom, which fall apart at the boundaries where tests need to verify real browser behavior. This guide walks through setting up Vitest’s browser mode for component testing, writing tests that leverage real browser APIs, and migrating from Playwright Component Testing.
Note on versions: This article describes Vitest’s browser mode using APIs such as
@vitest/browser/react,expect.element(), and built-in browser orchestration. Before following these instructions, confirm your installed Vitest version supports these features by checking the Vitest release notes and your installed package’s documentation. All examples assume you have verified API availability against your specific version.
Table of Contents
Why Component Testing Needs Real Browsers
The Limitations of jsdom and happy-dom
Most JavaScript test runners default to synthetic DOM environments like jsdom or happy-dom. These environments simulate browser APIs in Node.js, which works well enough for simple DOM manipulation and state logic. But they fall apart at the boundaries where tests need to verify real browser behavior. CSS calculations return incorrect or empty values. Layout-dependent logic, such as getBoundingClientRect or IntersectionObserver, either throws errors or returns meaningless zeroes. Web APIs like ResizeObserver, matchMedia, and Web Animations API are absent or only partially stubbed.
The consequence is a class of bugs that synthetic environments structurally cannot catch. A component that relies on computed styles for conditional rendering, or one that uses viewport-dependent breakpoints, will pass every test in jsdom and break in production. Teams learn to distrust their test suites, or worse, stop writing tests for visual and layout-sensitive behavior entirely.
The Playwright/Cypress Tax
The traditional remedy has been to reach for Playwright Component Testing or Cypress Component Testing. Both run tests in real browsers, but they layer on cost that compounds quickly: two config files, two assertion libraries, two sets of CI browser-install steps, and two runner lifecycles to understand. For teams already using Vitest for unit and integration tests, adopting Playwright or Cypress just for component testing means maintaining two test runners, two configuration files, and two mental models. The context-switching cost is real: different locator APIs, different mocking strategies, different watch mode behaviors. What should be a single testing concern, verifying that components work correctly, gets fragmented across tool boundaries.
What should be a single testing concern, verifying that components work correctly, gets fragmented across tool boundaries.
What Changed in Vitest Browser Mode
From Provider-Based to Built-In Browser Orchestration
Earlier versions of Vitest required you to configure @vitest/browser with an explicit provider, either Playwright or WebdriverIO. The provider managed the browser lifecycle, and test code ran through its orchestration layer. This meant installing Playwright’s browser binaries or WebdriverIO’s dependencies as a prerequisite, and the configuration had to specify which provider to use and how to connect to it.
Recent Vitest releases restructured this architecture. The browser mode now ships with a built-in orchestration layer that launches, connects to, and manages browser instances directly. The provider-based abstraction is no longer the default path. Instead, browser mode operates as a first-class project type within the Vitest configuration, with the browser runner integrated into the core test lifecycle rather than delegated to an external framework.
Key Differences from Earlier Vitest Browser Mode
The most visible change is the removal of the mandatory provider dependency. Earlier versions required you to set browser.provider to 'playwright' or 'webdriverio'. In the current architecture, the configuration surface within vitest.config.ts is simpler: you specify browser instances by name (e.g., 'chromium'), and Vitest handles orchestration internally.
Vitest also improved HMR and watch mode for browser tests. In the provider-based architecture, file changes propagated through the provider’s refresh mechanism, which introduced latency. The built-in runner connects the file watcher directly to the browser test context — the watcher triggers HMR directly in the browser tab, skipping the provider’s page-reload cycle. Vitest consolidated the configuration surface so that browser-specific options sit cleanly alongside standard Vitest configuration without requiring a separate provider-specific config block.
Setting Up Vitest Browser Mode from Scratch
Prerequisites and Installation
Before starting, ensure you have the following:
- Node.js 18-22 (LTS), npm 9+, and Vite 5.x. Verify compatibility with your installed Vitest version’s
peerDependencies. - An existing React project (React 18.2.0+, React DOM 18.2.0+).
- A
tsconfig.jsonwith"jsx": "react-jsx"(or equivalent) undercompilerOptionsto enable JSX in.tsxfiles.
The installation requires Vitest, the browser package, the framework-specific rendering utilities, and the Vite React plugin (used by the configuration).
npm install -D vitest @vitest/browser @vitejs/plugin-react
After installing the packages, you must also install browser binaries. If using the Playwright engine (which underlies Vitest’s Chromium/Firefox/WebKit support), run:
npx playwright install --with-deps chromium
Replace chromium with firefox or webkit if targeting other browsers. The --with-deps flag ensures that required OS-level shared libraries (such as libglib, libnss, etc.) are also installed, which is critical on Linux CI runners. This step is required — Vitest does not bundle browser binaries.
The @vitest/browser package provides rendering utilities via @vitest/browser/react. Verify whether @testing-library/react is required as a peer dependency by inspecting node_modules/@vitest/browser/package.json after installation. It can coexist if already present. The minimal package.json devDependencies block looks like this:
{
"devDependencies": {
"vitest": "^2.0.0",
"@vitest/browser": "^2.0.0",
"@vitejs/plugin-react": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
Important: Replace the version specifiers above with the actual latest versions available at the time of installation. Run npm show vitest versions --json to check available versions. For reproducible CI builds, pin exact versions or commit your lockfile.
Configuring vitest.config.ts for Browser Mode
The Vitest browser mode configuration is declared directly in vitest.config.ts. The key structural difference from older provider-based setups is the absence of a provider field.
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
browser: {
enabled: true,
name: 'chromium',
headless: true,
},
include: ['**/*.browser.test.{ts,tsx}'],
},
});
The browser.name field accepts 'chromium', 'firefox', or 'webkit'. The browser.headless option controls whether the browser window is visible during test execution, which matters for CI pipelines but should be disabled during local debugging to allow DevTools access.
Tip for CI: Consider using an environment variable to control headless mode: headless: process.env.CI === 'true'. This allows headed debugging locally while keeping CI runs headless.
Project Structure Overview
Browser-mode component tests can live alongside unit tests, distinguished by file naming conventions. A recommended layout separates concerns without requiring separate directories:
project-root/
├── src/
│ ├── components/
│ │ ├── Counter.tsx
│ │ └── Counter.browser.test.tsx
│ ├── utils/
│ │ ├── math.ts
│ │ └── math.test.ts
├── vitest.config.ts
├── vitest.workspace.ts
├── package.json
└── tsconfig.json
The *.browser.test.tsx naming convention makes it straightforward to target browser tests with include patterns and to visually distinguish them in file explorers and CI logs.
Ensure your tsconfig.json includes at minimum:
{
"compilerOptions": {
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler"
}
}
Writing Your First Browser-Mode Component Test
Rendering a React Component in the Browser
Start with a minimal React component that exercises state, providing a clear surface for testing both rendering and interaction.
import { useState } from 'react';
export function Counter({ initial = 0 }: { initial?: number }) {
const [count, setCount] = useState(initial);
return (
<div>
<span data-testid="count">{count}span>
<button onClick={() => setCount((c) => c + 1)}>Incrementbutton>
<button onClick={() => setCount((c) => c - 1)}>Decrementbutton>
div>
);
}
The browser-mode test file imports rendering utilities from @vitest/browser/react rather than @testing-library/react. The render function mounts the component into a real browser DOM. Each test should clean up after itself to prevent inter-test DOM contamination.
import { render } from '@vitest/browser/react';
import { expect, test, afterEach } from 'vitest';
import { Counter } from './Counter';
let cleanup: (() => void) | undefined;
afterEach(() => {
cleanup?.();
cleanup = undefined;
});
test('renders with initial count of zero', async () => {
const screen = render(<Counter />);
cleanup = screen.unmount;
await expect.element(screen.getByTestId('count')).toHaveTextContent('0');
});
test('renders with a custom initial value', async () => {
const screen = render(<Counter initial={5} />);
cleanup = screen.unmount;
await expect.element(screen.getByTestId('count')).toHaveTextContent('5');
});
Note that assertions use expect.element() with Vitest’s built-in locators. These locators query the real browser DOM, not a simulated one, so the results reflect actual rendering behavior. Verify expect.element() is available in your installed Vitest version by running typeof expect.element in a test file — it should log 'function'.
Simulating User Interactions
User interactions in Vitest browser mode use the userEvent object from @vitest/browser/context, not the @testing-library/user-event package. The API surface differs in important ways: the userEvent API dispatches browser-native event types through the browser’s JavaScript dispatchEvent interface, providing higher fidelity than jsdom’s simulation, though not equivalent to OS-level hardware input.
import { render } from '@vitest/browser/react';
import { userEvent } from '@vitest/browser/context';
import { expect, test, afterEach } from 'vitest';
import { Counter } from './Counter';
let cleanup: (() => void) | undefined;
afterEach(() => {
cleanup?.();
cleanup = undefined;
});
test('increments count on button click', async () => {
const screen = render(<Counter />);
cleanup = screen.unmount;
const incrementButton = screen.getByRole('button', { name: 'Increment' });
await userEvent.click(incrementButton);
await expect.element(screen.getByTestId('count')).toHaveTextContent('1');
});
test('decrements count on button click', async () => {
const screen = render(<Counter initial={3} />);
cleanup = screen.unmount;
const decrementButton = screen.getByRole('button', { name: 'Decrement' });
await userEvent.click(decrementButton);
await userEvent.click(decrementButton);
await expect.element(screen.getByTestId('count')).toHaveTextContent('1');
});
The userEvent from @vitest/browser/context dispatches browser event types, so hover states, focus management, and keyboard event sequences have higher fidelity than the synthetic event dispatch in jsdom-based testing utilities. However, because events fire via dispatchEvent rather than OS-level input injection, certain edge cases (IME composition, accessibility tool interactions, complex drag-and-drop) still differ from real user input.
Testing CSS and Visual Behavior
This is where browser-mode testing delivers value that synthetic environments simply cannot. Tests can assert on computed styles, visibility states, and layout-dependent behavior using standard browser APIs.
import { render } from '@vitest/browser/react';
import { userEvent } from '@vitest/browser/context';
import { expect, test, afterEach } from 'vitest';
import { Counter } from './Counter';
let cleanup: (() => void) | undefined;
afterEach(() => {
cleanup?.();
cleanup = undefined;
});
test('count element has visible computed styles', async () => {
const screen = render(<Counter />);
cleanup = screen.unmount;
const countLocator = screen.getByTestId('count');
const element = countLocator.element();
if (!(element instanceof HTMLElement)) {
throw new Error('count element not found or not an HTMLElement');
}
const styles = window.getComputedStyle(element);
expect(styles.display).not.toBe('');
expect(styles.visibility).not.toBe('hidden');
});
In jsdom, getComputedStyle returns empty strings for layout and paint properties (such as width, height, visibility) because no CSS engine is running. Some properties set directly via inline styles may still return values, but the vast majority of computed styles are unavailable. In Vitest browser mode, the component renders in an actual browser with a full CSS engine, so computed styles reflect real cascaded values. This enables tests for responsive behavior, conditional visibility, and CSS animation states.
Advanced Patterns and Real-World Scenarios
Testing Components with API Calls
Module mocking in browser mode works differently from Node-based tests. Vitest’s vi.fn() and vi.mock() are available, but modules resolve in the browser context. Vitest’s compile-time transform hoists the vi.mock() call to the top of the file — note that hoisting behavior in browser mode can differ from Node mode in some configurations; verify with your project’s module graph.
For network-level mocking, MSW (Mock Service Worker) integrates naturally because it intercepts requests at the service worker level, which is already a browser-native mechanism.
import { render } from '@vitest/browser/react';
import { expect, test, vi, beforeEach } from 'vitest';
import { UserProfile } from './UserProfile';
import * as api from './api';
vi.mock('./api');
beforeEach(() => {
vi.mocked(api.fetchUser).mockResolvedValue({
name: 'Ada Lovelace',
email: 'ada@example.com',
});
});
test('renders user data after fetch', async () => {
const screen = render(<UserProfile userId="1" />);
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
await expect.element(screen.getByText('ada@example.com')).toBeVisible();
});
The vi.mock() call intercepts module imports at compile time via hoisting, so the component receives the mocked module. Using beforeEach to configure mock return values ensures each test starts with a clean mock state, preventing inter-test contamination from shared mock configuration. For teams using MSW, the browser-mode setup requires additional steps beyond production MSW usage: run npx msw init public/ to generate the service worker file, then call server.start() in a beforeAll block and server.stop() in afterAll. Refer to the MSW browser integration documentation for the full setup.
Running Browser and Node Tests in the Same Project
Most real projects need both Node-based unit tests (for utilities, hooks, pure logic) and browser-based component tests. Vitest’s workspace feature handles this cleanly by defining multiple projects with separate configurations.
import { defineWorkspace } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineWorkspace([
{
plugins: [react()],
test: {
name: 'unit',
environment: 'node',
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
exclude: ['**/*.browser.test.{ts,tsx}'],
},
},
{
plugins: [react()],
test: {
name: 'browser',
browser: {
enabled: true,
name: 'chromium',
headless: process.env.CI !== 'false',
},
include: ['**/*.browser.test.{ts,tsx}'],
},
},
]);
Running vitest executes both projects. Running vitest --project browser or vitest --project unit targets a specific subset (verify the --project flag is available in your version with vitest --help). This eliminates the need for separate test scripts or CI steps for different test types.
Debugging Failed Browser Tests
When a browser test fails, set browser.headless to false in the configuration or pass --browser.headless=false on the command line (verify the exact flag syntax with vitest --help for your installed version). This launches a visible browser window where the test executes, and standard browser DevTools are fully accessible. Breakpoints set with debugger statements in test code will pause execution in the browser’s JavaScript debugger.
Vitest browser mode also supports screenshot capture on test failure, which is particularly valuable in CI environments where headed mode is not available. Check your installed version’s documentation for the exact configuration keys to enable screenshot and trace capture on failure.
Migration Checklist: From Playwright Component Testing to Vitest Browser Mode
Pre-Migration Assessment
Before starting migration, audit your existing setup to understand the scope of changes:
- Catalog all existing Playwright component tests and their total count
- Identify any tests that use Playwright-specific browser APIs (e.g.,
page.route,page.evaluate) - Catalog custom Playwright fixtures and determine Vitest equivalents
- Check for Playwright-specific assertions that need locator translation
- Verify that all tested components are compatible with Vite’s module resolution
Step-by-Step Migration Checklist
Start by swapping dependencies. Remove @playwright/experimental-ct-react and @playwright/test from devDependencies, then install the Vitest browser packages: npm install -D vitest @vitest/browser @vitejs/plugin-react.
If no other project on this machine requires Playwright browsers, remove them selectively: npx playwright uninstall chromium (specify the browser). Do not run npx playwright uninstall without arguments on shared CI machines or developer workstations — this removes all Playwright-managed browsers machine-wide, affecting other projects.
Next, install browser binaries for Vitest: npx playwright install --with-deps chromium (or the browsers you need).
With dependencies in place, create vitest.config.ts with browser mode enabled, translating relevant settings from playwright-ct.config.ts. Then work through the test files:
- Convert all
mount()calls to Vitestrender()from@vitest/browser/react - Replace Playwright locators (
page.getByRole,page.getByTestId) with Vitest browser locators (screen.getByRole,screen.getByTestId). The API shape is similar, but import paths and return types differ. - Replace
@playwright/test‘sexpectassertions with Vitestexpect.element()assertions - Replace Playwright’s
page.route()network interception withvi.mock()or MSW handlers
Finally, update your CI pipeline to ensure browser binaries are installed (e.g., add npx playwright install --with-deps chromium to CI setup), and verify coverage reporting compatibility with @vitest/coverage-v8 or @vitest/coverage-istanbul.
Post-Migration Validation
Once migration is complete, run through this verification pass:
- Run the complete browser test suite in headless mode and confirm zero regressions
- Compare code coverage metrics between old and new setups to identify any gaps
- Benchmark execution time across the full suite and on a per-test basis
- Validate that watch mode correctly re-runs affected tests on file changes
- Confirm CI pipeline passes with the new configuration
Performance Comparison: Vitest Browser Mode vs. Playwright Component Testing
The following comparison reflects qualitative architectural differences, not controlled benchmarks. Actual performance varies by project size, CI hardware, and configuration. Measure against your own project before making tooling decisions.
| Dimension | Vitest Browser Mode | Playwright CT | Cypress CT |
|---|---|---|---|
| Setup complexity | Single config file | Separate CT config + browser install | Separate config + browser install |
| Cold start time | Reuses the running Vite dev server instead of launching a separate process | Launches its own browser binary on each run | Initializes the Cypress app process before tests run |
| Watch mode speed | Direct HMR integration; file watcher triggers in-tab update | Triggers a page reload cycle | Re-bundles on each change |
| CI configuration | Node.js image with browser binaries (e.g., npx playwright install --with-deps) |
Playwright browser installation step | Cypress binary cache or install |
| API learning curve | Low for existing Vitest users | Moderate; Playwright-specific locator API | Moderate; Cypress chain syntax |
| Browser support | Chromium, Firefox, WebKit | Chromium, Firefox, WebKit | Chrome, Firefox, Edge, Electron |
| Community maturity | Newer; fewer third-party plugins and community resources | Established, large community | Established, large community |
Gotchas and Current Limitations
What Doesn’t Work Yet
Multi-browser parallel testing carries caveats. Running tests simultaneously across Chromium, Firefox, and WebKit in a single Vitest run requires workspace-level configuration with separate projects per browser. Each additional browser project runs a separate browser instance, consuming more memory and CPU. There is no built-in sharding across browsers within a single project definition.
Framework support varies. React and Vue have the most mature rendering packages in Vitest browser mode. Svelte and Solid support exists but should be treated as less battle-tested — check the Vitest documentation for the current framework support matrix. Teams using these frameworks should verify compatibility with their specific component patterns before committing to migration.
Certain CSS-in-JS libraries that rely on server-side style extraction or custom document injection patterns cause problems in the browser test context. For example, libraries that inject styles via document.head.appendChild can load stylesheets after the component renders, causing assertions on computed styles to fail. Libraries that assume a specific style loading order can produce style mismatches between test and production environments. Testing with the exact same bundler configuration used in production mitigates but does not eliminate this risk.
Libraries that inject styles via
document.head.appendChildcan load stylesheets after the component renders, causing assertions on computed styles to fail.
Common Pitfalls
"browser not found"on first run? Runnpx playwright install --with-deps chromium(or the relevant browser). Vitest does not bundle browser binaries.- Missing
@vitejs/plugin-react: If your config fails to load with a module resolution error, ensure@vitejs/plugin-reactis installed. - JSX compilation errors typically mean your
tsconfig.jsonis missing"jsx": "react-jsx"undercompilerOptions. expect.elementisundefined? Your installed Vitest version does not support this API. Check the release notes and fall back to direct locator value assertions.vi.mocknot applying in browser tests? Hoisting behavior in browser context differs from Node context in some module graph configurations. Verify that the mocked module path resolves the same way in both environments.
When to Adopt Vitest Browser Mode
Vitest browser mode is most practical for teams already invested in Vitest for unit testing who need component testing without adopting a second framework. The unified configuration, shared assertion API, and integrated watch mode eliminate the tooling fragmentation that Playwright or Cypress component testing introduces. Teams starting greenfield projects or those with straightforward React or Vue component testing needs can adopt it today. Teams with large existing Playwright CT suites should use the migration checklist above to evaluate the effort systematically before committing. The built-in browser orchestration removes the second test-runner binary from the dependency tree, and the migration path is concrete enough to act on now.

