Vitest Browser Mode | Real Browser Tests
이 글의 핵심
Vitest Browser Mode loads your test bundle in a real browser process—Playwright or WebdriverIO drives it—so you can assert layout, native events, and screenshots. This English guide translates the Korean Browser Mode article and adds detail on the runner–browser bridge, CDP-based automation, coverage caveats, and splitting visual jobs in CI.
Introduction: Why Vitest Browser Mode exists
Most frontend unit tests run in Node behind jsdom or happy-dom. That stack is excellent for speed, CI predictability, and asserting React/Vue/Svelte state without launching a browser. It breaks down when your assertions depend on real browser behavior: precise layout, ResizeObserver, IntersectionObserver, canvas quirks, font metrics, focus rings, IME composition, or event pipelines that only match production when the browser executes your bundle.
Vitest Browser Mode bridges that gap. Vitest still orchestrates discovery, reporters, and watch mode on the Node side, but the test code and application code run inside a real browser instance controlled by a browser provider (typically Playwright, optionally WebdriverIO, or a lightweight preview workflow for local iteration). Your tests import browser-aware helpers from vitest/browser—for example page, userEvent, and locator APIs—so interactions follow Chrome DevTools Protocol (CDP) or WebDriver/BiDi semantics instead of purely synthetic DOM events.
Browser Mode vs jsdom
Node with jsdom gives you a VM-shaped DOM: it is very fast, predictable in CI, and good for logic, hooks, stores, and most component tests, but the APIs are a subset, networking is mocked at the fetch and XHR layer, and you will hit gaps (polyfills, wrong semantics) when you lean on the real platform.
Vitest Browser Mode runs the same bundle in Chromium, Firefox, or WebKit—slower because you pay for process and browser startup, but you get native browser APIs, MSW in-browser when you wire it, and a place to assert layout, observers, and native input the way users actually get them.
Use jsdom as the default for volume. Reach for Browser Mode when failures are environmental (“works in dev, fails in the browser”) rather than purely logical. Do not treat “real browser” as automatically better: if the test only checks React state, you are paying for a browser to learn nothing new.
Browser Mode vs Playwright Component Testing (CT)
Playwright CT mounts components using Playwright’s test runner, dev server integration, and component-specific APIs. Vitest Browser Mode keeps Vitest as the runner you already use for unit tests: same describe/it ergonomics, vi mocks (with browser caveats), Vite transforms, and config merging.
The tradeoff is not “one is always better.” Playwright CT makes sense when the org standardizes on @playwright/test for almost everything: one mental model, one report, one CI path. Vitest Browser Mode is the better fit when you already live in vitest and want one config with projects that split Node and browser, shared Vite module graphs, and vi next to the rest of your unit suite—accepting that you are not using Playwright’s test runner, so CT-specific conveniences and docs do not port one-to-one.
What Browser Mode is not
Browser Mode is not a replacement for full E2E suites. Multi-page navigation, real authentication flows, third-party payment iframes, and cross-service orchestration still belong in Playwright or Cypress tests that drive your deployed or preview environment. Browser Mode targets component- and page-scoped correctness inside Vitest’s fast feedback loop.
Browser mode is not always the answer. If a test only needs stable DOM and fake events, Node + jsdom (or happy-dom) is almost always the right call. If the question is “does the full user journey work against staging,” that is E2E, not Vitest. Browser Mode sits in the middle: you pay for a real engine when the value is platform fidelity—layout, observers, real input, screenshots—not when you are replaying the same component logic in a more expensive process.
Architecture: runner, provider, and browser
Understanding the split between Node and browser processes prevents “impossible” errors when porting Node tests.
- Vitest (Node) coordinates test discovery, watch mode, coverage collection hooks, and reporting—the same as non-browser Vitest.
- A browser provider (
@vitest/browser-playwright,@vitest/browser-webdriverio, or@vitest/browser-preview) launches browser processes (or connects remotely) and exposes a control channel. - Playwright uses CDP for Chromium and equivalent protocols for Firefox/WebKit. WebdriverIO plugs into WebDriver/BiDi—helpful if you already maintain Selenium grids or enterprise browser farms.
- Tests import from
vitest/browser(page,userEvent, locators) so the bridge serializes locator queries and actions across the process boundary.
Mocking still uses Vitest’s vi API, but the module graph executes in the browser. Namespace imports can be sealed; partial mocks may need factories or vi.mock(..., { spy: true }) patterns. Keep mocks serialization-friendly: heavy Node-only singletons do not cross into the browser test VM the same way they do in Node.
Coverage in Browser Mode typically relies on V8 or Istanbul instrumentation of bundled code. Line mappings may differ from Node-only runs. Many teams treat Node + jsdom as the primary coverage source and Browser Mode as behavioral and visual insurance; merge lcov outputs in CI if you need unified numbers.
Setup guide: installation and configuration
Installation
Install Vitest, a browser provider, and the backing automation stack. For Playwright:
npm install -D vitest @vitest/browser-playwright playwright
npx playwright install
For WebdriverIO:
npm install -D vitest @vitest/browser-webdriverio webdriverio
Bootstrap can also use the official initializer:
npx vitest init browser
The initializer pins compatible package versions and scaffolds starter config—prefer it in greenfield repos.
Minimal vitest.config.ts
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
headless: true,
},
},
});
Key test.browser options (overview)
The exact surface evolves by Vitest major version; always cross-check Browser configuration for your installed version. In practice, enabled turns the browser pipeline on for that run; provider is the factory (playwright(), webdriverio(), or preview()); instances lists the engines (for example chromium, firefox, webkit). headless controls whether a window is shown (CI almost always wants true). viewport is worth setting in config rather than ad-hoc resizing: it is the first lever for stable layout and visual tests. For screenshots, screenshotDirectory and screenshotFailures control where baselines and failure captures land. testerHtmlPath is for advanced harness customization. Per-instance options can include setupFile for hooks that run in the browser context (MSW, storage seeds, and so on).
Type references for provider options
For stronger typing on provider-specific knobs, add reference types:
/// <reference types="@vitest/browser/providers/playwright" />
/// <reference types="@vitest/browser/providers/webdriverio" />
Splitting Node and browser projects
Use Vitest projects to keep fast Node tests default and opt into browsers explicitly:
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
export default defineConfig({
test: {
projects: [
{
test: {
name: 'unit',
environment: 'node',
include: ['src/**/*.test.ts'],
},
},
{
test: {
name: 'browser',
include: ['src/**/*.browser.test.ts'],
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
headless: true,
},
},
},
],
},
});
Run browser tests with project filters: vitest --project browser.
Browser providers: Playwright, WebdriverIO, preview
Playwright (most common)
@vitest/browser-playwright is the default choice for local dev and CI: excellent cross-browser support, robust locator model, and tight CDP integration.
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
export default defineConfig({
test: {
browser: {
enabled: true,
headless: true,
provider: playwright({
launchOptions: {
// Example: slow down actions while debugging locally
// slowMo: 50,
},
actionTimeout: 5_000,
}),
instances: [{ browser: 'chromium' }],
},
},
});
Important: Vitest controls headless mode via test.browser.headless. Provider launchOptions.headless is ignored—set headlessness in Vitest config or CLI (--browser.headless=false for headed runs).
Remote / Docker / CI farm: use connectOptions.wsEndpoint to attach to a running Playwright server rather than launching locally—useful for containerized CI or remote grids.
WebdriverIO
Choose WebdriverIO when your organization already standardizes on WebDriver endpoints, Appium-style grids, or internal Selenium infrastructure. Configuration mirrors the same test.browser structure with webdriverio() factory options.
Preview provider
The preview provider is lightweight and useful when you primarily want a browser-backed harness without full Playwright install weight—trade-offs include fewer automation features compared to Playwright. Treat it as a stepping stone, not a full CI substitute for complex projects.
Writing tests: vitest/browser APIs
Locators and page
page exposes Playwright-like locators that work across providers:
import { expect } from 'vitest';
import { page } from 'vitest/browser';
test('finds button by role', async () => {
const button = page.getByRole('button', { name: /submit/i });
await expect.element(button).toBeInTheDocument();
});
Prefer role-based queries aligned with Testing Library philosophy: resilient to DOM refactors and accessible by construction.
Assertions: expect.element
Browser Mode extends expectations for DOM nodes. Typical patterns assert visibility, text content, or ARIA state. API names follow Vitest’s @vitest/browser matchers—consult the versioned docs for the exact matcher list available to you.
Imports: vitest vs vitest/browser
- Import
describe,it,beforeEach,expectfromvitest. - Import
page,userEvent, and other interactivity helpers fromvitest/browser. - Avoid mixing Node-only utilities in test files that execute in the browser project without guards.
Mocking caveats
Dynamic import() and ESM circularity behave like the real browser. If a mock worked in Node via CommonJS interop, it may need restructuring for browser ESM. When in doubt, extract pure functions to test in Node and keep browser tests focused on DOM and integration.
DOM testing: querying, assertions, Testing Library
You can still use @testing-library/dom (and framework bindings) for queries if you keep execution inside the browser project. The key is consistency: align queries with how users perceive the UI (getByRole, getByLabelText) and reserve testId for last-resort scenarios.
Example pattern with component render (framework-specific setup omitted):
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; // see interaction section for preferred approach
import { expect, test } from 'vitest';
test('dialog opens', async () => {
render(<App />);
await userEvent.click(screen.getByRole('button', { name: /open/i }));
expect(screen.getByRole('dialog')).toBeVisible();
});
For assertions, combine Testing Library matchers (if installed) with expect.element where appropriate. Keep expectations at the behavioral level: not implementation details like internal state variable names.
User interactions: clicks, typing, keyboard, mouse
Prefer vitest/browser userEvent
Vitest implements a subset of @testing-library/user-event APIs using real browser automation rather than simulated DOM events. Import userEvent from vitest/browser for provider-aligned behavior.
import { expect, test } from 'vitest';
import { page, userEvent } from 'vitest/browser';
test('submits form', async () => {
await userEvent.fill(page.getByLabelText(/email/i), '[email protected]');
await userEvent.click(page.getByRole('button', { name: /submit/i }));
await expect.element(page.getByText(/success/i)).toBeInTheDocument();
});
Keyboard state and differences vs RTL userEvent
The default userEvent instance is shared across calls (unlike a fresh RTL userEvent.setup() per test). This matches real keyboard state with CDP/WebDriver. For modifier chords, follow Vitest’s documented patterns; special cases exist for Firefox/WebDriverIO (click with {} options) per upstream browser bugs.
Pointer and hover persistence
With Playwright/WebdriverIO providers, pointer position and hover state can persist across tests in the same file. Vitest resets unreleased keyboard state before each test case, but not pointer hover. If a test depends on a neutral hover, reset explicitly:
import { beforeEach } from 'vitest';
import { userEvent } from 'vitest/browser';
beforeEach(async () => {
await userEvent.unhover(document.body);
});
Locator shortcuts
Locators returned by page often expose click, hover, and other interaction methods directly—equivalent to combining userEvent with the located element.
Visual regression: screenshots in Browser Mode
Visual tests generate screenshots of rendered UI and compare against baselines. In CI, visual noise comes from font rasterization, subpixel antialiasing, animation frames, and OS-level differences.
Stabilization checklist
- Fixed viewport via
test.browser.viewport(and consistentdeviceScaleFactorwhere applicable). - Font readiness await
document.fonts.readybefore capturing when custom fonts matter. - Disable animations for deterministic frames (CSS or test hooks).
- Single OS baseline per branch of development; avoid mixing macOS-generated baselines with Linux CI without tolerance tuning.
Example flow (illustrative)
import { expect, test } from 'vitest';
test('dashboard matches snapshot', async () => {
// await document.fonts.ready (in browser context)
await expect(document.body).toMatchScreenshot();
});
Exact matcher names and import paths follow your Vitest version—see Visual regression testing.
Split visual jobs
Keep test:visual separate from test:unit so PRs stay fast. Run visual baselines on nightly schedules or opt-in labels, and shard large suites across workers when safe.
Network mocking: MSW and API interception
MSW (Mock Service Worker) intercepts network calls at the service worker or request layer depending on setup. In Browser Mode, you are already in a browser—ensure MSW’s browser integration is initialized in a setupFile that runs before tests which need handlers.
Sketch:
// browser setup file (conceptual) — use msw/browser, not msw/node
import { beforeAll, afterAll, afterEach } from 'vitest';
// import { worker } from './mocks/browser';
// See MSW browser setup (start/stop, resetHandlers) for your MSW version
beforeAll(async () => {
// await worker.start({ onUnhandledRequest: 'error' });
});
afterEach(() => {
// worker.resetHandlers();
});
afterAll(async () => {
// await worker.stop();
});
Practical guidance:
- Colocate handlers with fixtures; avoid global mutable state leaking between tests.
- Keep happy path and error path handlers explicit—flaky tests often come from accidental 404 fallthrough.
- For cross-cutting auth, centralize token injection in
localStorage/sessionStoragesetup (see next section).
Because stack details shift between MSW major versions, treat the above as a roadmap: wire the official MSW browser recipe to Vitest’s browser setupFile.
Browser context: viewport, storage, and cookies
Viewport
Configure default viewport in Vitest browser settings rather than scattered window.resizeTo calls. Consistent dimensions reduce visual test flakiness and make responsive assertions reproducible.
localStorage and sessionStorage
Seed storage in beforeEach when features depend on persisted flags or tokens:
import { beforeEach } from 'vitest';
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
localStorage.setItem('featureFlags', JSON.stringify({ newNav: true }));
});
Cookies
If your code path reads document.cookie, set or clear as needed for each test. Where cross-origin constraints appear, simplify test doubles to avoid fighting real domain rules in unit-level browser tests.
Cross-browser testing: Chromium, Firefox, WebKit
Define multiple instances to run the same specs across engines:
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [
{ browser: 'chromium' },
{ browser: 'firefox' },
{ browser: 'webkit' },
],
},
},
});
Cost: tripling instances multiplies runtime. Filter projects in PR CI (Chromium-only) and schedule broad matrices on main or nightly pipelines.
Engine-specific issues: WebKit and Firefox occasionally differ in focus management, scrollbar metrics, and font rendering. Prefer tolerance in visual snapshots or engine-specific baselines when unavoidable.
Performance: speed and optimization
Browser tests are inherently slower than Node tests because of process startup, bundling for the browser, and IPC. I treat project split as non-negotiable: default vitest stays fast for unit work, and browser is opt-in. Narrow include globs so you are not building a giant browser bundle for no reason. On PRs, Chromium-only is usually enough signal without tripling time; save Firefox/WebKit for main or nightly. Sharding and parallelization help when the suite is large and your CI can safely fan out. Where patterns allow, reuse stable page or harness setup to avoid repeating expensive initialization in every file.
None of that turns Browser Mode into “free.” Measure medians for unit vs browser and decide whether the tax is worth the class of bugs you are buying.
Debugging: UI Mode and browser DevTools
Vitest UI Mode
Run Vitest with UI enabled (see Vitest UI) to inspect suites, time filters, and failure traces interactively. Browser Mode benefits from UI when triaging flaky screenshots or interaction steps.
Headed runs
Temporarily disable headless to watch the browser:
npx vitest --browser.headless=false
During local debugging, increase actionTimeout or add slowMo in Playwright launchOptions to observe race conditions.
Browser DevTools
Because code runs in a real browser, you can attach DevTools for breakpoints in test and source files. When a failure is environmental, inspect Network, Performance, and Rendering panels exactly as in production debugging.
CI/CD: GitHub Actions with browsers
Playwright system dependencies on Linux
Install OS packages required by Chromium on Ubuntu runners:
name: browser-tests
on: [push, pull_request]
jobs:
vitest-browser:
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 playwright install --with-deps
- run: npx vitest run --browser.headless
--with-deps brings in system libraries Playwright expects on Linux.
Caching
Cache ~/.cache/ms-playwright between runs to avoid re-downloading browsers when versions are unchanged (match cache key to Playwright version in package-lock.json).
Sharding
For large suites, split test files across jobs with Vitest shard flags or separate include globs per job. Combine JUnit reports if your org requires a single artifact.
Failure artifacts
Enable screenshot-on-failure options in browser config and upload diff folders as CI artifacts for quick review without reproducing locally.
Migration guide
Migration story: we switched from Playwright CT
We had been on Playwright Component Testing for a slice of the design system: mount helpers, a separate runner, and Playwright’s own expectations and screenshot paths. Moving those specs to Vitest Browser Mode was not a rename—it was a port.
What broke first was the assumption that “component test” means the same tool everywhere. Our CT files imported @playwright/test patterns and custom fixtures; in Vitest we had to re-home setup into a browser setupFile and align imports with vitest and vitest/browser. Mount flows did not map one-to-one: we rewrote several tests to use our framework’s render inside the Vitest browser project, then drove interactions through userEvent from vitest/browser instead of the CT-flavored APIs we were used to.
What broke next was mocking. vi.mock behavior with ESM in the real browser is not a byte-for-byte copy of Node. A few “clever” mocks that relied on CommonJS interop or sealed namespaces had to be refactored into factory-style mocks or slimmer test doubles. Visual baselines were the third pain: we did not assume screenshot paths or digest algorithms matched Playwright’s CT output. We treated baselines as new for the Vitest pipeline and recaptured intentionally, with a short freeze on unrelated UI churn.
What we kept: the intent of the old tests. What we threw away: the idea that we could sed our way to green. The win was a single Vitest config and one less runner in daily life—not zero migration work.
If you are weighing the same move, plan for parallel runs in CI for a week: old CT (or a small subset) and new Vitest browser until confidence matches, then delete the old job.
From jsdom to Browser Mode
- Create a dedicated project (
browser) withincludeglobs for migrated files. - Move DOM-sensitive tests first: observers, focus traps, canvas,
matchMedia. - Replace synthetic event patterns with
vitest/browseruserEvent. - Keep logic-only tests in Node for speed.
From Playwright Component Testing to Vitest Browser Mode
- Map Playwright CT mount helpers to your framework’s render utilities inside Vitest browser tests.
- Replace Playwright-specific expect extensions with Vitest + Testing Library +
expect.elementpatterns. - Colocate visual baselines per runner; do not assume file paths are portable without adjustment.
Incremental adoption
Start with one critical component tree or design-system primitives. Encode lessons in team guidelines before mass migration.
Lessons learned (personal, not a checklist)
I keep most tests on Node + jsdom. Coverage maps cleanly, feedback is instant, and CI stays boring in a good way. I reach for Browser Mode when I can name the browser-only signal—observers, real focus paths, font/layout, screenshots—not because the component “feels important.”
For queries, I default to role-based and label-style selectors: they stay stable through refactors and they push the UI toward accessibility. For visuals, I do not trust a screenshot until viewport, fonts, and motion are pinned; otherwise I am just diffing noise. I seed storage in hooks when flags or auth matter, and I clear when tests leak into each other.
Pointer hover burned us once: I now userEvent.unhover(document.body) in beforeEach when anything depends on a neutral state—keyboard reset is automatic; hover is not, and that distinction matters.
In CI, I want a fast PR lane and a heavier lane (nightly or main) for multi-engine and visual baselines. I still run full E2E for journeys. Browser Mode is a sharp tool; it is not the only tool.
When to use what (opinionated, not a matrix)
Node + jsdom is my default for pure logic, hooks that do not need real layout, and component tests where synthetic events are honest. If the assertion is “state updated,” the browser is usually overkill.
Browser Mode is where I go when the bug report sounds like a platform: ResizeObserver and IntersectionObserver behaving differently than in jsdom, canvas and devicePixelRatio, font metrics, or anything where “pixels matter.” Single-component visual regression also fits here, as long as I accept flakiness work.
E2E (Playwright, Cypress, etc.) is for routes, real auth, email flows, and third-party embeds on real origins. If I need to prove a coupon flow across two apps, I am not simulating that in Vitest’s browser project—I am using E2E or a dedicated integration environment.
I intentionally leave overlap between layers sometimes: a small visual in Vitest and a spot-check in E2E. The mistake is using Browser Mode to avoid paying for E2E when the risk is end-to-end.
Issues we hit (not a lookup table)
These are the failures I actually saw, not an exhaustive “if X then Y” matrix.
Mocks that passed in Node and failed in the browser were almost always ESM shape or sealed imports. The fix was never “try harder”; it was smaller modules, vi.mock factories, and occasionally spy: true so we were not fighting the bundler. Flaky screenshots tracked back to fonts and animation not being settled, or to OS-specific subpixel work—we standardized viewport, waited on document.fonts.ready where it mattered, and turned motion off in tests. Mysterious hover failures were pointer carrying over between cases; unhovering the body in beforeEach made them boring again.
CI got slow when we naïvely turned on every engine on every push. We cut to Chromium on PRs and cached Playwright browsers so cold starts were not a daily tax. On Firefox, some modifier and click edge cases needed provider-specific workarounds—worth reading the current Vitest/Playwright notes rather than assuming parity with Chromium.
Leaky localStorage / sessionStorage was classic “this test order-dependent?”—clearing in beforeEach fixed most of it. If your symptom matches one of these, the mitigation is the same; if it does not, use this section as a set of field notes, not a complete diagnostic manual.
Real project examples (patterns)
Design system package
A component library keeps 80% of tests in Node for props/variants, and uses Browser Mode for input masks, media queries, and screenshot baselines of primitives. CI runs Chromium on PRs; full matrix runs nightly.
Data dashboard
Charts using canvas and devicePixelRatio move to Browser Mode; numerical utilities stay in Node. MSW provides API stubs shared between Storybook and tests.
Authentication shell
A team seeds localStorage with tokens in beforeEach to test route guards in isolation, while E2E covers real login flows. Browser Mode never replaces OAuth redirects—it validates client-side gating with controlled storage.
E2E automation overlap (layered testing)
Think in layers, not a single “best” test type. Unit and RTL in Node (Vitest + jsdom) is where I keep the bulk: fast, logic-heavy, cheap in CI. Browser Mode (Vitest + a provider like Playwright) is the slice where the browser is the subject: real DOM, native events, layout, and screenshots. E2E is where the product is the subject: multiple pages, real backends or realistic previews, and journeys I cannot honestly collapse into a Vite test bundle.
I use Browser Mode when I want Vite’s graph, Vitest’s reporters, and vi next to the rest of the suite. I use standalone Playwright (or similar) when the spec is a full user story with minimal reason to share Vitest’s module wiring—resisting the urge to shove every check into the same harness just because I can.
Production testing posture
Browser Mode improves pre-release quality. In production, complement it with synthetic checks (scheduled Playwright against canary URLs), RUM, and error monitoring. These layers detect environmental failures Browser Mode was never designed to catch.
References (official)
- Vitest Browser Mode
- Browser configuration
- Interactivity API (
userEvent) - Visual regression testing
- Configuring Playwright provider
Frequently asked questions (expanded)
Should Browser Mode be the default for new tests? No. Default to Node + jsdom; opt into the browser when the test’s value explicitly depends on real browser APIs or visuals.
Can I use both Testing Library and vitest/browser? Yes—queries can come from Testing Library while interactions for realism should go through vitest/browser userEvent and locators on page.
How do I keep CI fast? Project split, Chromium-only on PRs, cache Playwright browsers, and schedule multi-engine + visual baselines in nightly pipelines.
Related reading (internal)
- [Jest testing guide (unit tests and mocks)](/en/blog/jest-testing-guide/
- [Playwright component testing (React, Vue, Svelte, MSW, and CI)](/en/blog/playwright-component-testing-guide/
- [Playwright E2E testing (automation and locators)](/en/blog/playwright-e2e-testing-guide/
This guide follows Vitest’s evolving Browser Mode surface—verify version-specific options in the official documentation before upgrading major versions.