Vitest Browser Mode: Running Tests in Real Browsers vs jsdom
Vitest's browser mode runs your tests inside a real browser instead of a simulated DOM environment like jsdom or happy-dom. This matters because jsdom does not execute real JavaScript rendering, does not run CSS layout, and does not implement browser APIs like ResizeObserver, IntersectionObserver, or Web Components. When tests pass in jsdom but fail in production, browser mode closes that gap.
jsdom vs Browser Mode: What Changes
| Feature | jsdom / happy-dom | Browser Mode |
|---|---|---|
| JavaScript engine | Node.js (V8) | Chromium / Firefox / WebKit V8 |
| CSS layout | Not implemented | Real layout engine |
window.matchMedia |
Must be mocked | Works natively |
ResizeObserver |
Must be mocked | Works natively |
IntersectionObserver |
Must be mocked | Works natively |
| Web Components | Partial support | Full support |
<canvas> |
No rendering | Real GPU rendering |
| Clipboard API | Not implemented | Available (with permissions) |
| Network requests | Intercepted by Node.js | Real browser network stack |
| Performance profiles | Not available | DevTools integration |
Browser mode is not a replacement for jsdom in every situation. For pure utility function tests, jsdom is faster. For component tests that interact with the DOM, CSS, or browser APIs, browser mode gives you confidence that the test environment matches production.
Installation and Setup
Browser mode requires a browser provider. Vitest supports Playwright and WebdriverIO:
# Install Vitest and the Playwright provider
npm install -D vitest @vitest/browser playwright
npx playwright install chromiumConfigure vitest.config.ts:
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
browser: {
enabled: true,
provider: 'playwright',
name: 'chromium', // 'firefox' | 'webkit' also supported
headless: true, // Set to false to see the browser during development
},
},
})That is all the configuration needed to run tests in a real browser.
Your First Browser Mode Test
// src/components/Counter.tsx
import { useState } from 'react'
export function Counter({ initialCount = 0 }: { initialCount?: number }) {
const [count, setCount] = useState(initialCount)
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>
)
}// src/components/Counter.test.tsx
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { page } from '@vitest/browser/context'
import { Counter } from './Counter'
describe('Counter', () => {
it('displays initial count', async () => {
render(<Counter initialCount={5} />)
const countEl = page.getByTestId('count')
await expect.element(countEl).toHaveTextContent('Count: 5')
})
it('increments count on button click', async () => {
render(<Counter />)
const button = page.getByRole('button', { name: 'Increment' })
await button.click()
await button.click()
await expect.element(page.getByTestId('count')).toHaveTextContent('Count: 2')
})
it('decrements count on button click', async () => {
render(<Counter initialCount={3} />)
await page.getByRole('button', { name: 'Decrement' }).click()
await expect.element(page.getByTestId('count')).toHaveTextContent('Count: 2')
})
it('resets count to zero', async () => {
render(<Counter initialCount={10} />)
await page.getByRole('button', { name: 'Reset' }).click()
await expect.element(page.getByTestId('count')).toHaveTextContent('Count: 0')
})
})The page object from @vitest/browser/context provides browser locators and assertions. Notice that assertions use await expect.element(...) — browser mode assertions are async because they wait for DOM updates.
Locators: The Core API
Browser mode uses locators that retry automatically until the element is found or a timeout is reached. This eliminates most waitFor calls:
import { page } from '@vitest/browser/context'
// By role (preferred — semantic and accessible)
const button = page.getByRole('button', { name: 'Submit' })
const heading = page.getByRole('heading', { level: 1 })
const input = page.getByRole('textbox', { name: 'Email' })
const checkbox = page.getByRole('checkbox', { name: 'Remember me' })
// By text content
const link = page.getByText('Learn more')
// By label (for form fields)
const emailField = page.getByLabel('Email address')
// By placeholder
const searchInput = page.getByPlaceholder('Search...')
// By test id (fallback for complex cases)
const widget = page.getByTestId('data-grid')
// Chaining locators
const formSubmit = page.getByRole('form', { name: 'Login' })
.getByRole('button', { name: 'Submit' })Locator methods:
await locator.click()
await locator.fill('text to type')
await locator.clear()
await locator.selectOption('option-value')
await locator.check() // for checkboxes
await locator.uncheck()
await locator.hover()
await locator.press('Enter')
await locator.screenshot()Testing Browser-Native APIs
This is where browser mode shines. Testing a component that uses ResizeObserver:
// src/components/ResizeTracker.tsx
import { useEffect, useRef, useState } from 'react'
export function ResizeTracker() {
const ref = useRef<HTMLDivElement>(null)
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
useEffect(() => {
if (!ref.current) return
const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect
setDimensions({ width: Math.round(width), height: Math.round(height) })
})
observer.observe(ref.current)
return () => observer.disconnect()
}, [])
return (
<div ref={ref} style={{ width: '100%' }}>
<span data-testid="dimensions">
{dimensions.width} × {dimensions.height}
</span>
</div>
)
}In jsdom, ResizeObserver is undefined. In browser mode:
// src/components/ResizeTracker.test.tsx
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { page } from '@vitest/browser/context'
import { ResizeTracker } from './ResizeTracker'
describe('ResizeTracker', () => {
it('displays current dimensions', async () => {
render(
<div style={{ width: '400px', height: '200px' }}>
<ResizeTracker />
</div>
)
// ResizeObserver fires asynchronously — locators wait automatically
const dims = page.getByTestId('dimensions')
await expect.element(dims).toHaveTextContent('400 × 200')
})
})Component Testing with @vitest/browser
For deeper component testing, use render from @vitest/browser/react (experimental) or @testing-library/react. Both work in browser mode:
// src/components/Modal.test.tsx
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { page } from '@vitest/browser/context'
import { Modal } from './Modal'
describe('Modal', () => {
it('opens and closes', async () => {
let isOpen = false
render(
<Modal
isOpen={isOpen}
onClose={() => { isOpen = false }}
title="Confirm Action"
>
<p>Are you sure?</p>
</Modal>
)
// Dialog should not be visible when closed
await expect.element(page.getByRole('dialog')).not.toBeVisible()
// Open the modal externally (re-render)
// In real tests, trigger through UI interactions
})
it('traps focus inside modal', async () => {
render(
<Modal isOpen title="Focus Trap Test" onClose={() => {}}>
<input placeholder="First input" />
<button>Action</button>
<input placeholder="Last input" />
</Modal>
)
const firstInput = page.getByPlaceholder('First input')
const lastInput = page.getByPlaceholder('Last input')
// Tab from last element should cycle back to first
await lastInput.click()
await lastInput.press('Tab')
// In browser mode, focus behavior is real
await expect.element(firstInput).toBeFocused()
})
})Coverage in Browser Mode
Enable Istanbul coverage for browser-side code:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
browser: {
enabled: true,
provider: 'playwright',
name: 'chromium',
},
coverage: {
provider: 'istanbul', // 'v8' not supported in browser mode
reporter: ['text', 'lcov', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.test.{ts,tsx}'],
},
},
})Run with coverage:
npx vitest run --coverageCoverage is instrumented in the browser context and reported back to the Node.js process. The HTML report includes per-file and per-line coverage for your component code.
Splitting Tests: Browser vs Node
Most projects need both jsdom tests (fast, for utilities) and browser tests (accurate, for components). Use separate configs:
// vitest.config.ts — shared base
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export const baseConfig = {
plugins: [react()],
}
// vitest.node.config.ts — for utility functions
import { defineConfig, mergeConfig } from 'vitest/config'
import { baseConfig } from './vitest.config'
export default mergeConfig(baseConfig, defineConfig({
test: {
environment: 'jsdom',
include: ['src/**/*.unit.test.{ts,tsx}'],
},
}))
// vitest.browser.config.ts — for component tests
import { defineConfig, mergeConfig } from 'vitest/config'
import { baseConfig } from './vitest.config'
export default mergeConfig(baseConfig, defineConfig({
test: {
browser: {
enabled: true,
provider: 'playwright',
name: 'chromium',
headless: true,
},
include: ['src/**/*.browser.test.{ts,tsx}'],
},
}))// package.json scripts
{
"scripts": {
"test": "vitest run --config vitest.node.config.ts && vitest run --config vitest.browser.config.ts",
"test:unit": "vitest --config vitest.node.config.ts",
"test:browser": "vitest --config vitest.browser.config.ts",
"test:browser:ui": "vitest --config vitest.browser.config.ts --headless false"
}
}CI Configuration
Browser tests in CI require a browser binary. Playwright handles this automatically:
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx vitest run --config vitest.node.config.ts
browser-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Run browser tests
run: npx vitest run --config vitest.browser.config.ts
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.infoThe --with-deps flag installs system dependencies for Chromium on Ubuntu. On macOS and Windows, Playwright browsers are self-contained.
Cross-Browser Testing
Run the same tests across Chromium, Firefox, and WebKit:
// vitest.browser.config.ts
export default defineConfig({
test: {
browser: {
enabled: true,
provider: 'playwright',
instances: [
{ browser: 'chromium' },
{ browser: 'firefox' },
{ browser: 'webkit' },
],
},
},
})Vitest runs each test suite once per browser instance and reports failures per browser. This is particularly useful for CSS feature compatibility and Web API availability tests.
When to Use Browser Mode vs jsdom
Use browser mode for:
- Component tests that interact with user events (click, type, drag)
- Tests that depend on layout:
getBoundingClientRect,offsetWidth, scroll - Tests using browser APIs:
ResizeObserver,IntersectionObserver,matchMedia - Web Components and Shadow DOM
- Canvas rendering tests
- Accessibility testing (real ARIA tree, focus order)
- Cross-browser compatibility
Use jsdom for:
- Pure function tests (utility, formatting, parsing)
- State management (Zustand, Redux, Jotai stores)
- Simple presence checks where layout doesn't matter
- Tests where speed matters more than environment accuracy
The rule: if your component has a bug that only appears in a real browser, jsdom won't catch it. Migrate those tests to browser mode. Keep everything else in jsdom for the speed advantage.
Debugging Browser Tests
Run with a visible browser to debug:
# Interactive mode — browser stays open
npx vitest --config vitest.browser.config.ts --headless <span class="hljs-literal">falseVitest's UI mode shows test results alongside a live preview of the browser:
npx vitest --ui --config vitest.browser.config.tsAdd breakpoints by pausing execution with page.pause():
it('my test', async () => {
render(<MyComponent />)
await page.pause() // Browser pauses, DevTools opens
// ... rest of test
})Browser mode brings your test suite closer to production reality. The initial setup cost — adding a browser provider, splitting configs — pays off in eliminated "passes locally, fails in production" incidents and higher confidence in component-level behaviour.