Skip to main content
Back to Blog
Automation TutorialsTypeScript Quality Engineering · Part 2

Choose Vitest, Browser Mode, or Playwright by Risk

Each test layer can observe different things: Node sees pure logic and Blobs, jsdom sees DOM structure but no layout, Vitest browser mode sees real geometry, input and object URLs, and only Playwright sees what the browser does outside the page. This guide splits one file-download component across all four, gives a capability-to-layer decision table, shows the vacuous jsdom layout test and its fix, and prices each layer in feedback time, CI browser installs and diagnostic artefacts on Vitest 5 and Playwright 1.63.

Shashank Rawlani

Engineering Leader | Builder | Problem Solver | AI Engineer

Published Oct 5, 2026Updated Oct 14, 202617 min read
View AI-ready Markdown
Dark technical illustration over a faint dashed grid. On the left, a green rounded pane with three grey dots across its header, three green text pills and a green button holds the start of a single thick green horizontal line that runs to the right. The line passes through four nested dashed green rectangles, each larger than the one inside it, and inside each band between two rectangles it meets one ringed green dot. Beside the first dot are short stacked pills; beside the second, a small three-node tree; beside the third, pills of clearly unequal widths over a dotted measuring line; the fourth and largest dot, outside the third rectangle, drops a curve into a green page icon with a folded corner holding three pills. From the first dot a dashed orange arc climbs over all the rectangles to the top right and ends at a crossed-out orange ring. Grey rules and faint pills fill the top, bottom and outer edges.
Quick answer: Pick the layer by what the assertion has to observe, not by which runner you like. Pure transformations (a filename, a MIME type, CSV quoting) belong in plain Node. DOM wiring with no geometry can live in jsdom. Anything involving layout, real input or a live object URL needs Vitest browser mode, because jsdom does no layout and returns zeros. Anything the browser does on the page's behalf, such as saving a download, is invisible from inside the page and needs a Playwright test driving the browser from outside. Run the extra engines only where you promise that contract, and keep one end-to-end test for the journey that makes money.

Here is a jsdom test that passes. It mounts an Export button inside a toolbar styled to 48 pixels wide, forbids wrapping, and asserts that the label does not overflow. In Chromium the same code reports a content width of 82 pixels squeezed into a 44-pixel box. In jsdom both numbers are zero, and zero is less than or equal to zero.

The opposite mistake is just as common and harder to see, because it is merely slow rather than wrong. A Playwright suite launches Chromium, Firefox and WebKit to check that "Q3 Revenue / EMEA" turns into q3-revenue-emea-2026-10-05.csv. Three browser launches to test a regular expression.

Both come from treating the runner as a preference. The useful question is narrower: where does the test code execute, and can it see the thing you are asserting from there?

Versions checked on 2026-09-30: Vitest 5.0.2 with @vitest/browser-playwright 5.0.2, Playwright 1.63.0, jsdom 30.1.1. Browser mode lost its experimental tag in Vitest 4.0, which also moved each provider into its own package and moved the context import from @vitest/browser/context to vitest/browser. Vitest 5 requires Node.js 22.12 or later and Vite 6.4 or later, and hands the WebdriverIO provider to the vitest-community organisation. The examples below were run against Vitest 5.0.2, jsdom 30.1.1 and Playwright 1.62.1 in Chromium.

Four places a test can execute

A Node process with no DOM. The default Vitest environment. It has more than people assume: Node ships a global Blob, marked no longer experimental since Node 18.0.0, so you can build the export payload and assert its type and bytes without any browser at all.

A Node process with a simulated DOM. Vitest's jsdom and happy-dom environments. The jsdom README lists two features as outside the project's scope. Layout, "the ability to calculate where elements will be visually laid out as a result of CSS", which affects getBoundingClientRect() and offsetTop. And navigation, the ability to replace the global object "when clicking a link or assigning location.href". For layout it returns "zeros for many layout-related properties". An anchor click that should start a download has nowhere to go.

Inside a real browser page. Vitest browser mode serves your test through Vite and runs it inside an iframe in a real browser, with input sent over the Chrome DevTools Protocol or WebDriver depending on the provider. Layout is real, focus is real, and URL.createObjectURL returns a URL the browser will actually resolve.

Outside the browser, driving it. A Playwright test runs in Node and controls the browser through its protocol, so it receives browser-level events: downloads, popups, dialogs, navigations.

The asymmetry that decides most cases sits between the last two. Browser mode sees everything inside the page and nothing the browser does on the page's behalf. The Vitest commands documentation is explicit about where the outer page is: the page object on the server-side command context "references the full page that contains the test iframe", which is the orchestrator, and "you most likely shouldn't touch it to not break things". The download manager lives on the far side of that line.

One export button, four kinds of proof

The worked example is a report export. A button computes a filename, serialises rows, wraps them in a Blob and hands the browser an object URL through a temporary anchor:

// src/download-button.ts
export function triggerDownload(blob: Blob, filename: string): void {
  const url = URL.createObjectURL(blob)
  const link = document.createElement('a')
  link.href = url
  link.download = filename
  document.body.append(link)
  link.click()
  link.remove()
  setTimeout(() => URL.revokeObjectURL(url), 0)
}

mountExportButton wraps that in a button whose click calls exportFilename(reportName, format, now()), buildExport(rows, format) and then triggerDownload. The pure helpers live in their own module with no DOM imports. That split is the design decision that makes the rest cheap.

Node owns the naming rules

Everything that is a function of its inputs goes here, including the Blob, because Node has one:

// tests/export-file.unit.test.ts
import { describe, expect, test } from 'vitest'
import { buildExport, exportFilename, mimeFor, serialise } from '../src/export-file'

const at = new Date('2026-10-05T23:30:00Z')

describe('exportFilename', () => {
  test.each([
    ['Q3 Revenue / EMEA', 'q3-revenue-emea-2026-10-05.csv'],
    ['Café Ørders', 'cafe-rders-2026-10-05.csv'],
    ['../../etc/passwd', 'etc-passwd-2026-10-05.csv'],
    ['***', 'export-2026-10-05.csv'],
  ])('%s -> %s', (name, expected) => {
    expect(exportFilename(name, 'csv', at)).toBe(expected)
  })
})

test('CSV quoting survives commas, quotes and newlines', () => {
  expect(serialise([{ name: 'Smith, "Jo"', note: 'a\nb' }], 'csv'))
    .toBe('name,note\n"Smith, ""Jo""","a\nb"')
})

test('the Blob carries the MIME type and the bytes', async () => {
  const blob = buildExport([{ id: 1 }], 'json')
  expect(blob.type).toBe(mimeFor('json'))
  expect(JSON.parse(await blob.text())).toEqual([{ id: 1 }])
})

The filename table is where the edge cases belong, and it can grow to fifty rows at no cost. Notice the second row: Ø does not decompose under NFKD, so it is dropped rather than transliterated. If the slug pattern gets more involved, the Regex Tester is a quick way to try it against the awkward names before they become table rows.

Sanitising here is not optional polish. MDN's description of the download attribute says / and \ are converted to underscores and that browsers "will adjust the suggested name if necessary" for characters a filesystem forbids. If you leave that to the browser, the name the user gets depends on which browser they use.

Browser mode owns the click and the Blob URL

Run the two layers side by side with Vitest projects, the configuration shape the browser mode guide itself documents:

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'

export default defineConfig({
  test: {
    projects: [
      {
        test: {
          name: 'unit',
          environment: 'node',
          include: ['tests/**/*.unit.test.ts'],
        },
      },
      {
        test: {
          name: 'browser',
          include: ['tests/**/*.browser.test.ts'],
          browser: {
            enabled: true,
            provider: playwright(),
            headless: true,
            instances: [{ browser: 'chromium' }],
          },
        },
      },
    ],
  },
})

The browser test drives a real click, lets the real URL.createObjectURL run, and stops the download at the anchor so the test observes what the page handed over without the browser saving anything:

// tests/download-button.browser.test.ts
import { afterEach, expect, test, vi } from 'vitest'
import { page } from 'vitest/browser'
import { mountExportButton } from '../src/download-button'

afterEach(() => {
  document.body.replaceChildren()
  vi.restoreAllMocks()
})

test('a real click hands the browser a CSV blob under the computed name', async () => {
  const createUrl = vi.spyOn(URL, 'createObjectURL')
  const revokeUrl = vi.spyOn(URL, 'revokeObjectURL')

  let captured: { href: string; download: string } | undefined
  const stopDownload = (event: MouseEvent) => {
    const link = (event.target as Element).closest('a[download]') as HTMLAnchorElement | null
    if (!link) return
    captured = { href: link.href, download: link.download }
    event.preventDefault()
  }
  document.addEventListener('click', stopDownload, { capture: true })

  mountExportButton(document.body, {
    reportName: 'Q3 Revenue / EMEA',
    format: 'csv',
    getRows: () => [{ region: 'EMEA', total: 1200 }],
    now: () => new Date('2026-10-05T09:00:00Z'),
  })

  await page.getByRole('button', { name: 'Export CSV' }).click()

  expect(captured?.download).toBe('q3-revenue-emea-2026-10-05.csv')
  const blob = createUrl.mock.calls[0][0] as Blob
  const objectUrl = createUrl.mock.results[0].value as string
  expect(captured?.href).toBe(objectUrl)
  expect(blob.type).toBe('text/csv;charset=utf-8')
  expect(await blob.text()).toBe('region,total\nEMEA,1200')

  await vi.waitFor(() => expect(revokeUrl).toHaveBeenCalledWith(objectUrl))
  await expect(fetch(objectUrl)).rejects.toThrow()

  document.removeEventListener('click', stopDownload, { capture: true })
})

Do not read the bytes by fetching captured.href after the click. It fails with TypeError: Failed to fetch: the click goes out to the provider and back before the promise resolves, and by then the zero-delay revoke timer has already fired. Read the Blob from the spy's arguments instead. Then turn the dead URL into an assertion: a fetch of a revoked object URL rejecting is proof that cleanup happened, and only a browser's own Blob URL store can give you that.

The choice of click matters too. The userEvent and locator methods from vitest/browser use CDP or WebDriver "instead of faking events". The guide recommends against @testing-library/user-event in browser mode for exactly this reason, and with the preview provider the same API "fallbacks to simulated events", which removes the point of being in a browser. Note also that in Vitest 5 locators match text exactly and case-sensitively by default, so 'Export CSV' will not match 'Export CSV file'.

Playwright owns what the browser saves

Whether the browser actually treats that anchor as a download, and under what name, is not visible from inside the page. From outside it is one event:

// e2e/export.spec.ts
import { readFile } from 'node:fs/promises'
import { expect, test } from '@playwright/test'

test('the browser saves the export under the name the app chose', async ({ page }, testInfo) => {
  await page.goto('/reports/q3-emea')

  const downloadPromise = page.waitForEvent('download')
  await page.getByRole('button', { name: 'Export CSV' }).click()
  const download = await downloadPromise

  expect(download.suggestedFilename()).toBe('q3-revenue-emea-2026-10-05.csv')
  expect(await download.failure()).toBeNull()

  const target = testInfo.outputPath(download.suggestedFilename())
  await download.saveAs(target)
  expect(await readFile(target, 'utf8')).toContain('region,total')
})

Start waiting before the click; the Playwright docs mark the missing await on waitForEvent as deliberate. Save before the test ends, because "downloaded files are deleted when the browser context that produced them is closed", and note that download.path() returns a random GUID filename, so assert on suggestedFilename().

That method is the reason this layer exists. Its documentation says the name "is typically computed by the browser from the Content-Disposition response header or the download attribute" and that "different browsers can use different logic for computing it". Here is the first assertion in this article whose answer can differ by engine, so it is the first place a browser matrix earns its cost. It is also the test that notices if the export ever moves server-side, because MDN states that a filename in a Content-Disposition header "takes priority over a filename specified in the download attribute".

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  use: { baseURL: 'http://localhost:4173', trace: 'on-first-retry' },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
})

The journey that gets an end-to-end test

One test, against a deployed stack with real authentication and real data: a signed-in user opens a report that the server generated, exports it, and the file contains that report's rows. It catches what none of the layers above can, such as an authorisation rule that blanks the rows, or a reports API whose field names no longer match the columns the serialiser expects. It does not need the filename table, the quoting cases or three engines. Those already have homes.

Capability-to-layer decision table

Read each row as: if the assertion must observe the left column, the middle column is the cheapest layer that can, and the right column is what goes wrong one layer down.

What the assertion must observeLowest sufficient layerWhat the layer below gets wrong
Filename, MIME type, serialisation, Blob bytesVitest, nodeNothing below; a browser here only adds launch time
Rendered structure, accessible names, a click reaching its handler, disabled statesVitest, jsdom or happy-domNode has no document, so you end up testing a hand-built fake
Size, overflow, visibility, scroll position, anything read from getBoundingClientRect()Vitest browser modejsdom returns zeros, so inequality assertions pass vacuously
Pointer, focus and keyboard behaviour; a live object URL and its revocationVitest browser mode with playwrightjsdom and the preview provider simulate events rather than dispatching real input
A download, popup, dialog or navigation the browser performs for the pagePlaywright TestBrowser mode runs inside the test iframe and cannot see the browser's side of the transaction
An in-page API that behaves differently across enginesBrowser mode with several instancesA single Chromium instance says nothing about Gecko or WebKit
A browser-level behaviour that differs across engines, such as the suggested download namePlaywright projects for each promised engineBrowser mode instances cannot observe the event at all
Server, auth, storage and browser agreeing on one outcomeOne Playwright end-to-end test against a deployed stackEvery layer above stubs at least one of those parties

Where the table leaves a genuine tie, usually a component that could run in jsdom or browser mode, default to browser mode if the component reads geometry, manages focus or creates object URLs, and to jsdom otherwise. Listing which browser APIs a component touches is mechanical work that a grep or an AI assistant does well. Deciding which of those behaviours you promise to users in which browsers is a product decision, and it has to come from whoever owns the supported-browser list.

Zero width passes every inequality

The opening test is the most common way this goes wrong, so here it is in full. In jsdom:

// @vitest-environment jsdom
test('export label fits a narrow toolbar', () => {
  const toolbar = document.createElement('div')
  toolbar.style.width = '48px'
  document.body.append(toolbar)
  const button = mountExportButton(toolbar, { reportName: 'Q3', format: 'csv', getRows: () => [] })
  button.style.whiteSpace = 'nowrap'
  button.style.overflow = 'hidden'
  button.style.maxWidth = '100%'

  // 0 <= 0: green at any toolbar width
  expect(button.scrollWidth).toBeLessThanOrEqual(button.clientWidth)
})

Moved to the browser project, with one extra line:

// tests/toolbar-fit.browser.test.ts
test.each([48, 160])('export label fits a %ipx toolbar', (width) => {
  // ...same setup...
  expect(button.clientWidth).toBeGreaterThan(0)
  expect(button.scrollWidth).toBeLessThanOrEqual(button.clientWidth)
})
// 48px fails: expected 82 to be less than or equal to 44
// 160px passes

The toBeGreaterThan(0) guard is the part worth copying into every geometry test. If someone later moves the file into a jsdom project, it fails loudly instead of passing quietly. jsdom's README suggests overriding layout getters with Object.defineProperty() as a workaround. That makes the test pass on numbers you typed, which is a test of your fixture.

A regex does not need WebKit

The other direction costs time, not correctness. A filename table in Playwright makes each row pay for a page, a navigation and a click to exercise code that never touches the browser, and reports failure as a mismatched download instead of a one-line diff. The Vitest documentation names its own overhead under drawbacks, as "Longer Initialization", because the provider and browser must start first. A Node test has none of that.

If a transformation is only reachable through a click, extract it. Once exportFilename imports without a DOM, the browser tests shrink to one happy path each.

Chromium green is a Chromium result

A Chromium-only suite proves the Chromium contract. That is often the right scope; reporting it as "works in all browsers" is the mistake.

Three documented facts limit what a WebKit or Firefox run can claim. Playwright's WebKit "is derived from the latest WebKit main branch sources, often before these updates are incorporated into Apple Safari", and Playwright "doesn't work with the branded version of Safari since it relies on patches". Its Firefox likewise tracks Firefox Stable but not the branded build. And platform matters: the same page notes that media codecs vary between Linux, macOS and Windows, and recommends running WebKit on macOS for the closest-to-Safari behaviour.

If the supported-browser list says Safari by name and the behaviour is in-page, browser mode's WebdriverIO provider lists safari among its browsers, where Playwright offers webkit. Know what you are adopting: as of Vitest 5 that provider is community-maintained and "addressed on a per-issue basis".

When you do need several engines for in-page behaviour, prefer browser.instances over separate projects. The multiple-setups guide gives the reason: instances share one Vite server, so "the file transform and dependency pre-bundling has to happen only once".

Feedback, install and artefact costs

Feedback time. Vitest's own benchmarks for the 5.0 release include one app measured both ways: the same 92-module React app took 1.07 seconds under jsdom (on the vmThreads pool) and 2.01 seconds in browser mode on Chromium. Roughly double, on a small app, before any cross-engine instances. That is cheap enough to use wherever the table says so, and too expensive to use where it does not.

CI install. The preview provider cannot run headless or in CI; the guide says CI needs playwright or webdriverio. Playwright's browsers page gives the disk bill as roughly 281 MB for Chromium, 187 MB for Firefox and 180 MB for WebKit, and each Playwright upgrade can need a fresh install. Two flags cut it down: npx playwright install --with-deps chromium installs one engine and its system packages, and --only-shell skips the full Chromium build when everything runs headless.

Diagnostic artefacts. A Node failure gives you a diff and a stack, which is usually all you need. Browser mode adds failure screenshots, written in Vitest 5 to .vitest/attachments/failure-screenshots/. It also adds the new browser.traceView DOM-snapshot replay, and Playwright trace files, which are "only available when using the Playwright provider". Playwright Test records a full trace per test, and the configuration it generates sets trace: 'on-first-retry' so the cost is paid only when something has already failed. Budget storage for the upper layers and nothing for the bottom one.

Sort one component's tests by capability today

Pick the component with the slowest or flakiest tests. For each test, write down the one thing its assertion must observe, then look it up in the table. Move every test that sits above its row down to the lower layer, extracting a pure function where the logic is trapped behind a click.

Then search your jsdom projects for getBoundingClientRect, offsetWidth, clientWidth and scrollWidth. Each hit is either a mocked value or a vacuous pass. Move those tests to a browser project and add the greater-than-zero guard.

Capture two numbers before and after: wall-clock time for the component's full test run, and how many of its tests launch a browser. The first should fall. The second should match the number of rows in the table that the component actually needs.

Questions about splitting Vitest and Playwright

Can Vitest browser mode replace Playwright Test?

No, and the Vitest documentation says so under the heading "Not a Drop-In Replacement": it recommends pairing browser mode with a standalone runner such as Playwright. Browser mode is a component-testing layer in a real browser. Downloads, multiple pages, authentication state across navigations and deployed-stack journeys belong to a test that drives the browser from outside.

Does switching from jsdom to happy-dom fix layout assertions?

Do not count on it. Vitest describes happy-dom as faster than jsdom but lacking some APIs, and its Why Browser Mode page files jsdom and happy-dom together under the same simulation caveat. Choose between them on API coverage and speed for DOM-structure tests. For geometry, move to browser mode.

Why not mock URL.createObjectURL in jsdom and stop there?

You can, and the test will prove your code calls a function you wrote. It cannot prove the URL resolves to the right bytes or that revocation frees it, because a mock has no Blob URL store behind it.

Primary references

  • Vitest — Browser Mode. Provider packages (@vitest/browser-playwright, @vitest/browser-preview), that CI requires playwright or webdriverio because preview "relies on simulating events", that headless is not available with preview, the projects configuration running a Node project beside a browser project, the browser lists per provider (safari under WebdriverIO, webkit under Playwright), tests running inside an iframe, the warning against @testing-library/user-event, and the limitation on thread-blocking dialogs. Vitest 5.0.2.
  • Vitest — Why Browser Mode. The simulation caveat covering jsdom and happy-dom, and the stated drawbacks: "Not a Drop-In Replacement" for standalone end-to-end runners, and longer initialisation because the provider and browser must start.
  • Vitest — Multiple Setups. browser.instances for Chromium, Firefox and WebKit, and that instances share one Vite server so transform and pre-bundling "has to happen only once".
  • Vitest — Interactivity API. That userEvent from vitest/browser uses CDP or WebDriver "instead of faking events".
  • Vitest — Context API. That userEvent with the preview provider "fallbacks to simulated events via @testing-library/user-event".
  • Vitest — Commands. That the command context's page "references the full page that contains the test iframe", the orchestrator, which "you most likely shouldn't touch".
  • Vitest — Test Environment. The node, jsdom and happy-dom environments, happy-dom being "considered to be faster than jsdom, but lacks some API", the @vitest-environment control comment, and that browser is not an environment but a project.
  • Vitest — Playwright Traces. Trace files being "only available when using the Playwright provider".
  • Vitest — Trace View. browser.traceView as a DOM-snapshot replay added in 5.0.0.
  • Vitest — Migrating to Vitest 5.0. The Node.js 22.12 and Vite 6.4 minimums, exact-by-default locators, failure screenshots moving to .vitest/attachments/failure-screenshots/, and the WebdriverIO provider moving to vitest-community, "addressed on a per-issue basis".
  • Vitest 4.0 is out!. Browser mode losing its experimental tag, providers moving to separate packages, and the context import moving from @vitest/browser/context to vitest/browser.
  • Vitest 5.0 is out!. The benchmark cell comparing the same 92-module React app under jsdom (1.07 s) and browser mode on Chromium (2.01 s).
  • jsdom README — Unimplemented parts of the web platform. Layout and navigation as out of scope, "zeros for many layout-related properties", and the Object.defineProperty() workaround. jsdom 30.1.1.
  • Playwright — Downloads. The waitForEvent('download') pattern started before the click, and that "downloaded files are deleted when the browser context that produced them is closed".
  • Playwright — Download. path() returning a GUID name, saveAs(), failure(), and that suggestedFilename() is browser-computed and "different browsers can use different logic".
  • Playwright — Projects. The Chromium, Firefox and WebKit project configuration with devices.
  • Playwright — Browsers. WebKit derived from WebKit main and not the branded Safari, Firefox tracking Stable, platform-dependent codecs, install sizes, --with-deps and --only-shell. Playwright 1.63.0.
  • Playwright — Trace viewer. The default trace: 'on-first-retry' configuration.
  • MDN — The anchor element. The download attribute converting / and \ to underscores, browsers adjusting names, working only for same-origin, blob: and data: URLs, and a Content-Disposition filename taking priority.
  • MDN — URL.createObjectURL(). Creating a blob URL that points at a Blob.
  • MDN — URL.revokeObjectURL(). Releasing an object URL once it is no longer needed, the cleanup the browser-mode test asserts.
  • MDN — Blob. Blob.type as the MIME type and Blob.text() for reading contents.
  • Node.js — Class: Blob. Blob available in Node and "no longer experimental" since v18.0.0, the basis for asserting Blob contents in a Node test.

TypeScript Quality Engineering

Get part 3: Debug Vitest Module Mocking and Cache Leaks

It is live now: read part 3. The form below subscribes you to The Automation Brief, the site newsletter.

No spam, no sending cadence promised. Unsubscribe any time.