VOOZH about

URL: https://www.sitepoint.com/vitest-4-browser-mode-component-testing-without-playwright/

โ‡ฑ Vitest 4 Browser Mode: Component Testing Without Playwright


This metrics tool terrifies bad developers

Start free trial

This metrics tool terrifies bad developers

Start free trial
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

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.json with "jsx": "react-jsx" (or equivalent) under compilerOptions to enable JSX in .tsx files.

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.

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
 plugins: [react()],
 test: {
 // Enable browser mode for this project
 browser: {
 enabled: true,
 // Specify the browser instance โ€” Vitest manages this internally
 name: 'chromium',
 // Run without a visible browser window in CI; set to false for local debugging
 headless: true,
 },
 // Include only browser test files matching this pattern
 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 # browser-mode test
โ”‚ โ”œโ”€โ”€ utils/
โ”‚ โ”‚ โ”œโ”€โ”€ math.ts
โ”‚ โ”‚ โ””โ”€โ”€ math.test.ts # standard Node-based unit test
โ”œโ”€โ”€ vitest.config.ts # browser-mode config
โ”œโ”€โ”€ vitest.workspace.ts # optional: multi-project workspace
โ”œโ”€โ”€ 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.

// src/components/Counter.tsx
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)}>Increment</button><button onClick={() => setCount((c) => c - 1)}>Decrement</button></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.

// src/components/Counter.browser.test.tsx
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;
 // Vitest browser mode provides built-in locators
 // Verify that expect.element() is available in your installed version.
 // In earlier versions, use: expect(await screen.getByTestId('count').textContent()).toBe('0')
 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.

// src/components/Counter.browser.test.tsx
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.

// src/components/Counter.browser.test.tsx
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');
 // Access the real browser's getComputedStyle โ€” this returns actual values,
 // not the empty strings that jsdom produces for layout/paint properties.
 // Note: .element() is a synchronous DOM accessor on Vitest browser locators.
 // Verify this method is available in your installed version before use.
 const element = countLocator.element();
 if (!(element instanceof HTMLElement)) {
 throw new Error('count element not found or not an HTMLElement');
 }
 const styles = window.getComputedStyle(element);
 // In a real browser, display will be a resolved value, never empty
 expect(styles.display).not.toBe('');
 // Note: This assertion depends on no CSS reset or global stylesheet
 // setting visibility to another value. Adjust for your project's styles.
 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.

// src/components/UserProfile.browser.test.tsx
import { render } from '@vitest/browser/react';
import { expect, test, vi, beforeEach } from 'vitest';
import { UserProfile } from './UserProfile';
import * as api from './api';
// Mock the API module at the module level โ€” vi.mock() is hoisted by Vitest's transform
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" />);
 // Wait for async data to render in the real browser DOM
 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.

// vitest.workspace.ts
import { defineWorkspace } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineWorkspace([
 {
 // Node-based unit tests
 plugins: [react()],
 test: {
 name: 'unit',
 environment: 'node',
 include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
 exclude: ['**/*.browser.test.{ts,tsx}'],
 },
 },
 {
 // Browser-based component tests
 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:

  1. Convert all mount() calls to Vitest render() from @vitest/browser/react
  2. 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.
  3. Replace @playwright/test's expect assertions with Vitest expect.element() assertions
  4. Replace Playwright's page.route() network interception with vi.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.appendChild can load stylesheets after the component renders, causing assertions on computed styles to fail.

Common Pitfalls

  • "browser not found" on first run? Run npx 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-react is installed.
  • JSX compilation errors typically mean your tsconfig.json is missing "jsx": "react-jsx" under compilerOptions.
  • expect.element is undefined? Your installed Vitest version does not support this API. Check the release notes and fall back to direct locator value assertions.
  • vi.mock not 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.

๐Ÿ‘ SitePoint Team
SitePoint Team

Sharing our passion for building incredible internet things.

SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

Stuff we do
Contact
About
Connect
Subscribe to our newsletter

Get the freshest news and resources for developers, designers and digital creators in your inbox each week

ยฉ 2000 โ€“ 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.
Privacy PolicyTerms of Service