Vitest Browser Mode: Running Tests in Real Browsers vs jsdom

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 chromium

Configure 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 --coverage

Coverage 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.info

The --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">false

Vitest's UI mode shows test results alongside a live preview of the browser:

npx vitest --ui --config vitest.browser.config.ts

Add 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.

Read more