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
Flakiness Management and Deterministic Test Execution
Browser-based tests are inherently more susceptible to flakiness than JSDOM tests because they depend on real rendering, real timers, and real asynchronous browser behaviors that JSDOM abstracts away with synchronous implementations. Managing flakiness in production browser test suites requires explicit strategies. Vitest browser mode exposes expect.poll() — a utility that retries an assertion until it passes or times out, similar to Playwright's auto-retrying assertions. For components that animate, transition, or load data asynchronously, replacing synchronous expect(el).toBeVisible() with await expect.poll(() => screen.getByRole('button')).toBeVisible() eliminates an entire class of timing-related failures.
Playwright component testing's auto-retrying assertions (await expect(component).toBeVisible()) are implemented at the framework level and are designed specifically for browser timing. Every Playwright assertion retries with a configurable timeout, and the default timeout is set to 5 seconds — long enough for most animation and data-loading scenarios. The actionability checks before user interactions (verifying a button is visible and enabled before clicking it) also prevent a common flakiness source where tests click elements that are present in the DOM but not yet interactive.
CI Integration and Pipeline Performance
Browser-based component testing in CI requires careful resource management. Running headless Chromium in GitHub Actions requires the ubuntu-latest runner (not macOS, which has significantly higher per-minute costs) and no additional Docker setup — Chromium runs natively. Playwright and Vitest browser mode both install their browser binaries via npx playwright install chromium, which should be cached in CI using actions/cache with the Playwright version as the cache key to avoid re-downloading 150MB on every workflow run.
Vitest browser mode parallelizes tests across workers with the same strategy as its JSDOM mode: each test file runs in a separate browser context, and multiple contexts run concurrently up to the configured maxWorkers limit. In practice, browser-based tests run 2-4x slower than equivalent JSDOM tests per test case because real browser rendering involves layout, paint, and JavaScript engine overhead that JSDOM skips. For CI budgets, the recommendation is to run JSDOM tests on every push and browser mode tests only on pull request or merge to main — the JSDOM tests catch logic errors quickly while the browser tests verify the visual and DOM-interaction correctness that only a real browser can confirm.
Playwright component testing's CI story benefits from Playwright's established CI optimization patterns. The --shard flag splits tests across multiple parallel CI jobs — npx playwright test --shard=1/4 runs the first quarter of tests — enabling horizontal scaling by adding more runner instances rather than increasing single-runner resource limits. Playwright also produces detailed HTML reports with video recordings and traces for failed tests, which are invaluable for debugging intermittent failures in CI where you cannot interact with the test interactively. WebdriverIO's CI integration is the most complex of the three, but WebdriverIO supports Sauce Labs and BrowserStack as remote execution providers, enabling real-device testing in CI without managing device farms in-house.
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