Vitest Browser Mode vs Playwright Component Testing 2026
Vitest Browser Mode vs Playwright Component Testing vs WebdriverIO in 2026
TL;DR
JSDOM has served as the default testing environment for years, but it has a fundamental flaw: it's not a real browser. Shadow DOM, CSS custom properties, ResizeObserver, intersection observers — these either don't work or behave differently in JSDOM. In 2026, three tools compete for the "test components in a real browser" niche: Vitest browser mode (the newcomer — run Vitest tests directly in Chromium/Firefox/Safari via Playwright or WebdriverIO providers), Playwright component testing (@playwright/test with mount()), and WebdriverIO (mature, WebDriver-based, cross-browser). For most React/Vue/Svelte projects already using Vitest, browser mode is the natural evolution — same test syntax, real browser execution.
Key Takeaways
- Vitest browser mode runs your existing Vitest unit tests in a real browser — same
test(),expect(), Testing Library APIs, just a browser instead of JSDOM - Playwright component testing is Playwright's dedicated component testing mode with
mount()— excellent if you're already committed to Playwright for E2E - WebdriverIO is the most mature option — real WebDriver protocol, true cross-browser (including Safari on real devices), but significantly more setup
- The JSDOM problem: ~20% of web APIs are missing or broken in JSDOM; if your components use CSS custom properties, Web Animations API, canvas, or WebGL, browser mode is essential
- Vitest browser mode performance: surprisingly fast — tests run in headless Chromium, parallel across workers, ~2-3x slower than JSDOM but significantly faster than full E2E tests
- When to stick with JSDOM: pure logic, hooks without DOM side effects, utility functions — JSDOM is still the fastest option
The Problem With JSDOM
JSDOM is a JavaScript implementation of the browser's DOM. It's used by Jest and Vitest as the default test environment — fast, no browser needed. But it has gaps:
// These fail or behave unexpectedly in JSDOM:
// ResizeObserver — commonly used in UI components
const observer = new ResizeObserver(() => {})
// TypeError: ResizeObserver is not defined in JSDOM
// CSS custom properties don't compute
const style = getComputedStyle(element)
style.getPropertyValue('--theme-color')
// Returns '' (empty string) in JSDOM — real browsers return the value
// Web Animations API
element.animate([{ opacity: 0 }, { opacity: 1 }], 300)
// Not implemented in JSDOM
// Intersection Observer (sort of works but is unreliable)
const io = new IntersectionObserver(callback)
// Canvas 2D context (limited implementation)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
ctx?.measureText('hello') // Returns { width: 0 } — not real measurements
Projects that rely on these APIs either mock them (brittle) or use a real browser (correct).
Vitest Browser Mode
Vitest browser mode was introduced in Vitest 1.x and reached maturity in Vitest 2.x. It runs your Vitest tests inside a real browser via two providers: Playwright (recommended) or WebdriverIO.
Setup
npm install -D vitest @vitest/browser playwright
npx playwright install chromium
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
// Multiple projects: JSDOM for unit tests, browser for component tests
workspace: [
{
extends: true,
test: {
name: 'unit',
environment: 'jsdom',
include: ['src/**/*.unit.test.ts'],
},
},
{
extends: true,
test: {
name: 'browser',
browser: {
enabled: true,
name: 'chromium', // 'firefox', 'webkit' also available
provider: 'playwright',
headless: true,
},
include: ['src/**/*.browser.test.tsx'],
},
},
],
},
})
Writing Browser Tests
The key insight: your test syntax doesn't change. The same @testing-library/react APIs work in browser mode:
// src/components/AutoSuggest.browser.test.tsx
import { render, screen, userEvent } from '@vitest/browser/context'
import { expect, test } from 'vitest'
import { AutoSuggest } from './AutoSuggest'
test('ResizeObserver-based dropdown positions correctly', async () => {
// This works in browser mode — ResizeObserver is real
const user = userEvent.setup()
render(
<AutoSuggest
options={['apple', 'apricot', 'avocado']}
placeholder="Type a fruit"
/>
)
const input = screen.getByRole('textbox')
await user.type(input, 'ap')
// The dropdown uses ResizeObserver to position itself — works correctly
const dropdown = screen.getByRole('listbox')
expect(dropdown).toBeVisible()
// Real computed styles work in browser mode
const style = getComputedStyle(dropdown)
expect(style.position).toBe('absolute')
})
test('CSS custom properties are readable', async () => {
render(<ThemeProvider theme="dark"><Button>Click me</Button></ThemeProvider>)
const button = screen.getByRole('button')
const style = getComputedStyle(button)
// This works in real browser, returns '' in JSDOM
const color = style.getPropertyValue('--button-bg')
expect(color).toBe('#1a1a2e')
})
The @vitest/browser Context Imports
Browser mode provides specialized imports via @vitest/browser/context:
import {
render, // Renders component in browser DOM
screen, // Testing Library screen queries
userEvent, // Browser-native user events (real pointer/keyboard events)
page, // Playwright Page object (for navigation, screenshots)
server, // Server-side utilities
commands, // Custom browser commands
} from '@vitest/browser/context'
// Access the Playwright page for advanced operations
test('takes screenshot on failure', async () => {
render(<MyComponent />)
try {
// Test assertions
expect(screen.getByRole('button')).toBeInTheDocument()
} catch (err) {
// Take a screenshot on failure
await page.screenshot({ path: 'test-failure.png' })
throw err
}
})
Cross-Browser Testing
Vitest browser mode supports testing in multiple browsers simultaneously:
// vitest.config.ts
export default defineConfig({
test: {
workspace: [
{
test: {
name: 'chromium',
browser: { enabled: true, name: 'chromium', provider: 'playwright' },
},
},
{
test: {
name: 'firefox',
browser: { enabled: true, name: 'firefox', provider: 'playwright' },
},
},
{
test: {
name: 'webkit',
browser: { enabled: true, name: 'webkit', provider: 'playwright' },
},
},
],
},
})
Playwright Component Testing
Playwright's component testing (@playwright/experimental-ct-react) uses Playwright's browser automation to render and test components. It's a separate mode from Playwright's E2E testing:
Setup
npm install -D @playwright/experimental-ct-react
npx playwright install
// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-react'
export default defineConfig({
testDir: './src',
use: {
ctPort: 3100,
ctViteConfig: {
// Your Vite config for component tests
},
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
})
Writing Playwright Component Tests
// AutoSuggest.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react'
import { AutoSuggest } from './AutoSuggest'
test('shows suggestions when typing', async ({ mount }) => {
const component = await mount(
<AutoSuggest
options={['apple', 'apricot', 'avocado']}
placeholder="Type a fruit"
/>
)
await component.getByRole('textbox').fill('ap')
await expect(component.getByRole('listbox')).toBeVisible()
await expect(component.getByText('apple')).toBeVisible()
await expect(component.getByText('apricot')).toBeVisible()
})
test('calls onSelect when option is chosen', async ({ mount }) => {
let selectedValue = ''
const component = await mount(
<AutoSuggest
options={['apple', 'apricot']}
onSelect={(value) => { selectedValue = value }}
/>
)
await component.getByRole('textbox').fill('app')
await component.getByText('apple').click()
expect(selectedValue).toBe('apple')
})
Playwright Component Testing Strengths
Native Playwright assertions: All Playwright locators and assertions work — toBeVisible(), toHaveText(), toHaveScreenshot(), etc.
Visual regression testing: Built-in screenshot comparison for component states:
test('button visual states', async ({ mount }) => {
const component = await mount(<Button variant="primary">Submit</Button>)
await expect(component).toHaveScreenshot('button-primary.png')
await component.hover()
await expect(component).toHaveScreenshot('button-primary-hover.png')
})
WebdriverIO Component Testing
WebdriverIO offers component testing via @wdio/browser-runner:
npm init wdio@latest
# Select: Component Testing
# Select: React / Vue / Svelte / etc.
// test/component/AutoSuggest.test.tsx
import { $, expect } from '@wdio/globals'
import { render } from '@testing-library/react'
import AutoSuggest from '../../src/AutoSuggest'
describe('AutoSuggest', () => {
it('shows suggestions', async () => {
render(<AutoSuggest options={['apple', 'banana']} />)
const input = await $('input[role="textbox"]')
await input.setValue('app')
const dropdown = await $('[role="listbox"]')
await expect(dropdown).toBeDisplayed()
})
})
WebdriverIO's Unique Strength: Real Safari Testing
WebdriverIO is the only option that supports real Safari on macOS and Safari on real iOS devices via WebDriver:
// wdio.conf.ts
export const config = {
services: ['safaridriver'],
capabilities: [
{ browserName: 'safari' }, // macOS Safari (real browser)
{ // Real iPhone
'appium:deviceName': 'iPhone 15',
'appium:platformName': 'iOS',
browserName: 'safari',
},
],
}
Playwright's webkit is a port of WebKit — similar but not identical to Safari. If Safari compatibility is business-critical (e.g., you have a financial product and many users are on iOS Safari), WebdriverIO's real Safari support is the deciding factor.
Comparison: Vitest Browser Mode vs Playwright CT vs WebdriverIO
| Feature | Vitest Browser Mode | Playwright CT | WebdriverIO |
|---|---|---|---|
| Setup complexity | Low | Medium | High |
| Syntax | Vitest/Testing Library | Playwright | WebdriverIO/Testing Library |
| JSDOM migration | Near-zero | Rewrite | Partial rewrite |
| Real browser | ✅ | ✅ | ✅ |
| Real Safari | Via webkit | Via webkit | ✅ (actual Safari) |
| Real iOS Safari | ❌ | ❌ | ✅ (via Appium) |
| Visual regression | Via Playwright page | ✅ built-in | Via wdio-image-service |
| Speed | Fast (headless) | Fast (headless) | Slower (real WebDriver) |
| Parallel tests | ✅ | ✅ | ✅ |
| Existing Vitest tests | ✅ reuse | Rewrite | Partial rewrite |
When to Use Each
Use Vitest browser mode when:
- You're already using Vitest and want to test components that use JSDOM-incompatible APIs
- You want to minimize the testing infrastructure footprint
- Your tests use
@testing-library/react(or Vue/Svelte variants) — they work identically - You need fast feedback during development (hot reload works in browser mode)
Use Playwright component testing when:
- You're already using Playwright for E2E tests and want component testing in the same framework
- You need visual regression testing at the component level
- You want Playwright's excellent debugging tools (
--debug, trace viewer) for component issues
Use WebdriverIO when:
- Real Safari compatibility is business-critical (financial, media, health apps with heavy iOS users)
- You need to test on real iOS/Android devices via Appium
- You're in an enterprise environment that already uses Selenium/WebDriver infrastructure
- Cross-browser compatibility requirements extend to edge cases that webkit doesn't fully cover
Migrating From JSDOM to Vitest Browser Mode
The migration path from JSDOM to Vitest browser mode is the smoothest among all three tools — most tests require zero changes.
Common Migration Fixes
1. Replace global.ResizeObserver mocks
// Before (JSDOM workaround — delete this)
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))
// After (browser mode — ResizeObserver works natively, no mock needed)
2. Replace IntersectionObserver mocks
// Before (JSDOM workaround — delete this)
global.IntersectionObserver = vi.fn().mockImplementation((callback) => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))
// After: IntersectionObserver works in real browser — remove the mock
3. Replace matchMedia mocks
// Before (JSDOM workaround)
Object.defineProperty(window, 'matchMedia', {
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
addListener: vi.fn(),
removeListener: vi.fn(),
})),
})
// After: real browser — remove the mock, test actual responsive behavior
4. Update imports for browser context
// Before (JSDOM)
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// After (browser mode) — use @vitest/browser/context for enhanced versions
import { render, screen, userEvent } from '@vitest/browser/context'
// These wrap @testing-library with browser-aware implementations
Hybrid Strategy: JSDOM + Browser Mode
Not everything needs the real browser. A workspace configuration keeps both:
// vitest.config.ts — run unit tests in JSDOM, component tests in browser
export default defineConfig({
test: {
workspace: [
{
test: {
name: 'unit',
environment: 'jsdom',
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['**/*.browser.{test,spec}.{ts,tsx}'],
},
},
{
test: {
name: 'browser',
browser: {
enabled: true,
name: 'chromium',
provider: 'playwright',
headless: true,
},
include: ['**/*.browser.{test,spec}.{ts,tsx}'],
},
},
],
},
})
Convention: name files *.test.ts for JSDOM tests and *.browser.test.ts for browser tests. Pure logic, hooks, and utilities stay in JSDOM. Components with real DOM requirements go to browser mode.
Methodology
- Download data from npmjs.com API, March 2026 weekly averages: Vitest ~7M/week, @playwright/test ~8M/week, WebdriverIO ~1.5M/week
- Versions: Vitest 2.x, Playwright 1.50+, WebdriverIO 9.x
- Sources: Vitest documentation (vitest.dev/guide/browser), Playwright documentation, WebdriverIO documentation
Compare Vitest, Playwright, and WebdriverIO on PkgPulse — health scores, download trends, and ecosystem analysis.
Related: Playwright vs Cypress vs Puppeteer E2E Testing 2026 · node:test vs Vitest vs Jest Native Test Runner 2026 · Playwright Component Testing vs Storybook 2026