HomeJournalThis post

Playwright vs Vitest Browser Mode

A matched accessible component contract compares Playwright and Vitest Browser Mode across fidelity, mocks, isolation, debugging, CI cost, and failure artifacts.

JP
JP Casabianca
UI/UX designer and full-stack engineer · Bogotá

Playwright vs Vitest Browser Mode is best answered by running the same UI contract in both current toolchains. A real browser label alone does not explain the Playwright story gallery, Vitest's Vite-native execution, application fidelity, or the evidence each layer can truthfully produce.

This comparison pins current packages and executes one accessible overlay through Playwright's built-in fixtures.mount() and Vitest Browser Mode's Playwright provider. The receipt proves semantic parity without inventing timing or durability conclusions.

Benchmark Playwright vs Vitest Browser Mode

Playwright vs Vitest Browser Mode should begin with one accessible component contract rather than two marketing feature lists. The reproducible fixture pins @playwright/test 1.62.1, Vitest 4.1.10, @vitest/browser 4.1.10, and @vitest/browser-playwright 4.1.10. Both current runners open the same modal overlay, resolve its accessible name, verify initial focus, move focus with Tab, close with Escape, and prove focus returns to the trigger.

The shared contract is an accessible modal overlay with open, initial focus, Tab behavior, Escape close, and focus return. Each current runner drives the same implementation in Chrome and must pass before one normalized receipt is printed. Framework-specific conveniences remain separate so parity does not erase meaningful differences.

Current Playwright story-gallery and Vitest Browser Mode parityPlaywright mounts a framework-specific, application-owned story through the runner-level fixtures.mount contract while Vitest Browser Mode imports the shared component module; both drive the same modal interaction. @playwright/test 1.62fixtures.mount()Vitest Browser 4.1Playwright providerapp-owned galleryframework-specificVite browser graphshared modulesame modalfocus + TabEscape + return2 current passes
Figure 1: Playwright's application-owned gallery is framework-specific behind a stable runner contract; both real Chrome runs prove the same behavior.

Use Playwright's stable story-gallery model.

Current Playwright component testing uses plain @playwright/test with the built-in fixtures.mount() API. The application supplies a framework-specific, application-owned story gallery behind Playwright's runner-level fixtures.mount contract, exposing window.mount() and window.unmount(); Playwright navigates to that baseURL and mounts a named story for each isolated test. There is no dedicated component-test runtime or Playwright-owned bundler in this architecture, so gallery fidelity remains an explicit application responsibility.

Playwright 1.62.1 uses its stable story-gallery architecture: plain @playwright/test and a framework-specific, application-owned gallery behind the runner-level fixtures.mount contract. Vitest Browser Mode 4.1.10 runs the same implementation with @vitest/browser locators and its Playwright provider. The executable imports both pinned toolchains instead of modeling their output with arrays.

Understand Vitest Browser Mode boundaries

Vitest Browser Mode brings tests into browser providers while retaining the Vitest and Vite-centered development model. Its browser interactivity API provides user-facing actions and locators. The comparison notes provider, browser, module transform, mock behavior, and whether component CSS and application context match the shipped route.

Fidelity includes more than using Chromium. I record how the component is bundled, how CSS and assets load, whether application providers match production, how navigation is represented, and which browser engines run in CI. A real browser around an unrealistic mount can still miss integration failures that an end-to-end route would catch.

Runnable artifact: The pinned harness executes one real modal contract through Playwright's built-in story mount and Vitest Browser Mode's Playwright provider.

Save this proof as browser-runner-parity.test.mjs and run node browser-runner-parity.test.mjs. Expected final line: PASS: current browser runners share one contract.

import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process";
const root=process.cwd(); const pkg=JSON.parse(await readFile(path.join(root,"package.json"),"utf8"));
const versions={"@playwright/test":"1.62.1","vitest":"4.1.10","@vitest/browser":"4.1.10","@vitest/browser-playwright":"4.1.10"};
for(const [name,version] of Object.entries(versions))assert.equal(pkg.devDependencies[name],version,name+" must stay pinned");
const temp=await mkdtemp(path.join(os.tmpdir(),"browser-parity-"));
const shared=`export function mountOverlay(root){root.innerHTML='<button type="button">Open refund</button><dialog aria-labelledby="refund-title"><h1 id="refund-title">Approve refund</h1><button type="button">Cancel</button><button type="button">Confirm</button></dialog>';const [open,cancel]=root.querySelectorAll('button');const dialog=root.querySelector('dialog');open.addEventListener('click',()=>{dialog.showModal();cancel.focus()});dialog.addEventListener('close',()=>open.focus());return root}`;
const gallery=`<!doctype html><meta charset="utf-8"><div id="root"></div><script type="module">import {mountOverlay} from './overlay.mjs';window.mount=async({story})=>{if(story!=='overlay/Default')throw Error('unknown story');mountOverlay(document.querySelector('#root'))};window.unmount=async()=>{document.querySelector('#root').replaceChildren()}</script>`;
const server=`import http from 'node:http';import {readFile} from 'node:fs/promises';http.createServer(async(req,res)=>{const file=req.url==='/'?'gallery.html':req.url.slice(1);try{const body=await readFile(new URL(file,import.meta.url));res.setHeader('content-type',file.endsWith('.html')?'text/html':'text/javascript');res.end(body)}catch{res.statusCode=404;res.end('missing')}}).listen(41873,'127.0.0.1')`;
const playwrightConfig=`import {defineConfig} from '@playwright/test';export default defineConfig({testDir:'.',testMatch:'playwright.spec.mjs',workers:1,reporter:'line',use:{baseURL:'http://127.0.0.1:41873',channel:'chrome',headless:true},webServer:{command:'node serve.mjs',url:'http://127.0.0.1:41873',reuseExistingServer:false}})`;
const playwrightSpec=`import {test,expect} from '@playwright/test';test('shared overlay contract',async({mount,page})=>{const c=await mount('overlay/Default');const open=c.getByRole('button',{name:'Open refund'}),dialog=c.getByRole('dialog',{name:'Approve refund'}),cancel=c.getByRole('button',{name:'Cancel'}),confirm=c.getByRole('button',{name:'Confirm'});await open.click();await expect(dialog).toBeVisible();await expect(cancel).toBeFocused();await page.keyboard.press('Tab');await expect(confirm).toBeFocused();await page.keyboard.press('Escape');await expect(open).toBeFocused()})`;
const vitestConfig=`import {defineConfig} from 'vitest/config';import {playwright} from '@vitest/browser-playwright';export default defineConfig({test:{include:['vitest.spec.mjs'],browser:{enabled:true,provider:playwright({launchOptions:{channel:'chrome',headless:true}}),instances:[{browser:'chromium'}]}}})`;
const vitestSpec=`import {beforeEach,expect,test} from 'vitest';import {page,userEvent} from '@vitest/browser/context';import {mountOverlay} from './overlay.mjs';beforeEach(()=>{document.body.innerHTML='<div id="root"></div>';mountOverlay(document.querySelector('#root'))});test('shared overlay contract',async()=>{const open=page.getByRole('button',{name:'Open refund'}),dialog=page.getByRole('dialog',{name:'Approve refund'}),cancel=page.getByRole('button',{name:'Cancel'}),confirm=page.getByRole('button',{name:'Confirm'});await open.click();await expect.element(dialog).toBeVisible();await expect.element(cancel).toHaveFocus();await userEvent.keyboard('{Tab}');await expect.element(confirm).toHaveFocus();await userEvent.keyboard('{Escape}');await expect.element(open).toHaveFocus()})`;
try{
  await symlink(path.join(root,"node_modules"),path.join(temp,"node_modules"),"dir");
  await Promise.all([["overlay.mjs",shared],["gallery.html",gallery],["serve.mjs",server],["playwright.config.mjs",playwrightConfig],["playwright.spec.mjs",playwrightSpec],["vitest.config.mjs",vitestConfig],["vitest.spec.mjs",vitestSpec]].map(([name,body])=>writeFile(path.join(temp,name),body)));
  const run=(bin,args)=>spawnSync(path.join(root,"node_modules",".bin",bin),args,{cwd:temp,encoding:"utf8",env:{...process.env,FORCE_COLOR:"0"}});
  const playwrightRun=run("playwright",["test","--config","playwright.config.mjs"]); if(playwrightRun.status!==0)console.error(playwrightRun.stdout,playwrightRun.stderr);
  const vitestRun=run("vitest",["run","--config","vitest.config.mjs"]); if(vitestRun.status!==0)console.error(vitestRun.stdout,vitestRun.stderr);
  assert.equal(playwrightRun.status,0,"Playwright story-gallery contract failed"); assert.equal(vitestRun.status,0,"Vitest Browser Mode contract failed");
  const receipt={versions,contract:["open named trigger","find named modal dialog","initial focus Cancel","Tab to Confirm","Escape closes","focus returns"],playwright:{runtime:"@playwright/test",mount:"runner fixtures.mount + framework-specific application-owned vanilla DOM gallery",passed:true},vitest:{runtime:"Vitest Browser Mode",provider:"@vitest/browser-playwright",passed:true}};
  console.log(JSON.stringify(receipt,null,2)); console.log("PASS: current browser runners share one contract");
}finally{await rm(temp,{recursive:true,force:true})}

Compare fidelity, mocks, and isolation.

The parity table separates browser engine coverage, bundling, network boundaries, module mocks, clock control, storage cleanup, worker isolation, and route integration. Frontend quality bars help decide which facts belong at component or end-to-end layers. A fast test that mocks away the production seam is not automatically higher quality.

Failure evidence remains a separate evaluation dimension. A team can plant an accessible-name mismatch and retain source location, DOM excerpt, screenshot, Playwright trace, Vitest reporter output, and rerun command. This article does not claim a diagnosis-time winner because the executable receipt measures green contract parity, not human debugging performance.

Read the executable parity receipt

The artifact creates one shared DOM implementation, a minimal Playwright gallery, a @playwright/test suite using fixtures.mount(), and a Vitest Browser Mode suite using its Playwright provider. Both suites run in real Chrome and must pass the same six semantic actions before the receipt is emitted. QA for AI-generated UI reinforces the method: compare observable user contracts first, then evaluate each runner's native failure evidence separately.

Mocking is evaluated by boundary: network response, clock, random source, browser API, and imported module. Deep module mocks can make a component test fast while bypassing the production seam, so the suite explains which substitutions remain representative. I prefer network and dependency injection boundaries that both runners can express without private bundler knowledge.

Work the same overlay contract through both runners. The generated Playwright suite asks built-in fixtures.mount() to render overlay/Default from the application-owned gallery. The Vitest suite imports the identical DOM fixture into Browser Mode and drives it through @vitest/browser locators and userEvent. Both click the named trigger, assert a named modal dialog, verify Cancel owns initial focus, press Tab to Confirm, close with Escape, and confirm focus returns. The executable fails unless both real Chrome runs pass.

The normalized receipt records exact package versions, mount boundary, provider, and six shared actions. It does not infer comparative diagnosis speed or CI cost from green runs. Teams can add intentional failures, screenshots, Playwright traces, Vitest reporters, repeated timings, and hardware metadata in a separate operational evaluation without changing the semantic contract proved here.

DimensionPlaywright 1.62Vitest Browser 4.1Evidence
MountApp-owned gallery + fixtureVite module importActual suites
Browser@playwright/test ChromePlaywright provider ChromeBoth pass
ContractLocator + keyboardLocator + userEventSix actions
OperationsProjects + trace availableVite + reporters availableMeasure in team CI
Figure 2: The matrix reflects pinned current APIs and separates measured parity from unmeasured operations.

Separate contract parity from CI economics

The measured receipt proves current API compatibility and behavior parity; it does not publish cold-start, watch-mode, sharding, screenshot, or diagnosis-time claims. Those depend on hardware, caches, reporters, project count, and artifact policy. Deploy readiness for product UI supplies the broader release context. A team should measure those operational properties in its own CI after the semantic contract is real.

Isolation tests deliberately leak local storage, timers, service workers, and DOM listeners from one case, then verify the next case starts clean. Parallel workers repeat the fixture with randomized ordering. This exposes whether cleanup is a convention, a harness guarantee, or an expensive global reset that will shape CI throughput.

Design a layered test portfolio after selection. Playwright vs Vitest Browser Mode need not end in total standardization. A team can keep Vite-native component contracts close to source while reserving Playwright for production routes, cross-browser paths, downloads, permissions, and multi-page behavior. The boundary document names which risks each layer owns, preventing the same overlay test from being copied three times while navigation, CSS loading, or browser-engine differences remain uncovered.

Flake triage also follows ownership: deterministic component failures stay local, infrastructure and route failures retain network and trace evidence, and suspected browser differences reproduce across the declared matrix. Playwright vs Vitest Browser Mode becomes an architecture for evidence rather than a tooling contest. The chosen portfolio should make failures easier to locate and important user contracts harder to accidentally leave between layers.

Assign each test to the lowest truthful layer

My default is Vitest Browser Mode for source-adjacent, Vite-native component feedback and Playwright for gallery stories that benefit from its runner, projects, tracing, or broader route tests. Either can prove this isolated overlay contract. Tests should challenge generated intent supports choosing by uncovered risk instead of duplicating every assertion at every layer.

An operational cost study should include install bytes, browser cache, cold startup, warm rerun, retries, sharding, artifact retention, and watch mode with hardware and commit metadata. The parity receipt intentionally omits those numbers. A single local pass cannot support a durable CI-cost conclusion, so the decision preserves an explicit measurement backlog rather than false precision.

Retest runner claims after application changes. A new router, CSS pipeline, server component boundary, service worker, browser provider, or CI container can invalidate the original mount and timing evidence. Playwright vs Vitest Browser Mode records those dependencies beside the recommendation and reruns the same fixture after material changes. The team keeps previous reports so a regression has a baseline and a tool switch is not justified by memory. This small recurring benchmark prevents test architecture from drifting until developers no longer know which environment a passing component test actually represents.

  1. 1Share

    One overlay source

  2. 2Mount

    Framework-specific gallery + Vite

  3. 3Drive

    Same keyboard contract

  4. 4Receipt

    Require two real passes

Figure 3: The executable builds both current harnesses around one shared component implementation.

Publish the runner decision as revisable evidence

The decision file records pinned versions, browser/provider choice, shared fixture source, current mount architecture, passed contract, CI assumptions, and a trigger to retest. Playwright vs Vitest Browser Mode remains a measured local choice. When the gallery, Vite configuration, application providers, browser matrix, or either runner changes, rerun the same real interaction instead of defending an obsolete architecture.

Real browser tests are only as representative as their mount, providers, assets, and navigation boundary. A UI test runner earns its place by producing evidence that matches the risk, not merely by launching Chromium around an artificial fixture.

My selection rule uses Vitest Browser Mode when source-adjacent Vite transforms and component feedback dominate, and Playwright when its gallery, project matrix, tracing, or complete route workflows own the risk. Many teams can use both with a narrow boundary. Duplicate tests are avoided by assigning each contract to the lowest layer that can prove it truthfully.

Playwright vs Vitest Browser Mode should be chosen from representative mounting, failure diagnosis, browser coverage, and CI operation. Re-run the Playwright vs Vitest Browser Mode contract when the application or either runner changes materially.