{"title":"Choose Vitest, Browser Mode, or Playwright by Risk","excerpt":"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.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/choose-vitest-browser-mode-playwright-by-risk","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["vitest","playwright","typescript","browser-testing","component-testing","test-strategy"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-10-05T13:30:00.000Z","dateModified":"2026-10-14T11:09:52.936Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/choose-vitest-browser-mode-playwright-by-risk.webp","alt":"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."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> 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.</div>\n\n<p>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.</p>\n\n<p>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 <code class=\"language-text\">\"Q3 Revenue / EMEA\"</code> turns into <code class=\"language-text\">q3-revenue-emea-2026-10-05.csv</code>. Three browser launches to test a regular expression.</p>\n\n<p>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?</p>\n\n<div class=\"callout callout-warning\"><strong>Versions checked on 2026-09-30:</strong> Vitest 5.0.2 with <code class=\"language-text\">@vitest/browser-playwright</code> 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 <code class=\"language-text\">@vitest/browser/context</code> to <code class=\"language-text\">vitest/browser</code>. 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.</div>\n\n<h2 id=\"four-places-a-test-can-execute\">Four places a test can execute</h2>\n\n<p><strong>A Node process with no DOM.</strong> The default Vitest environment. It has more than people assume: Node ships a global <code class=\"language-text\">Blob</code>, marked no longer experimental since Node 18.0.0, so you can build the export payload and assert its <code class=\"language-text\">type</code> and bytes without any browser at all.</p>\n\n<p><strong>A Node process with a simulated DOM.</strong> Vitest's <code class=\"language-text\">jsdom</code> and <code class=\"language-text\">happy-dom</code> 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 <code class=\"language-text\">getBoundingClientRect()</code> and <code class=\"language-text\">offsetTop</code>. And navigation, the ability to replace the global object \"when clicking a link or assigning <code class=\"language-text\">location.href</code>\". For layout it returns \"zeros for many layout-related properties\". An anchor click that should start a download has nowhere to go.</p>\n\n<p><strong>Inside a real browser page.</strong> 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 <code class=\"language-text\">URL.createObjectURL</code> returns a URL the browser will actually resolve.</p>\n\n<p><strong>Outside the browser, driving it.</strong> A Playwright test runs in Node and controls the browser through its protocol, so it receives browser-level events: downloads, popups, dialogs, navigations.</p>\n\n<p>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 <code class=\"language-text\">page</code> 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.</p>\n\n<h2 id=\"one-export-button-four-kinds-of-proof\">One export button, four kinds of proof</h2>\n\n<p>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:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// src/download-button.ts\nexport function triggerDownload(blob: Blob, filename: string): void {\n  const url = URL.createObjectURL(blob)\n  const link = document.createElement('a')\n  link.href = url\n  link.download = filename\n  document.body.append(link)\n  link.click()\n  link.remove()\n  setTimeout(() =&gt; URL.revokeObjectURL(url), 0)\n}</code></pre>\n\n<p><code class=\"language-text\">mountExportButton</code> wraps that in a button whose click calls <code class=\"language-text\">exportFilename(reportName, format, now())</code>, <code class=\"language-text\">buildExport(rows, format)</code> and then <code class=\"language-text\">triggerDownload</code>. The pure helpers live in their own module with no DOM imports. That split is the design decision that makes the rest cheap.</p>\n\n<h3 id=\"node-owns-the-naming-rules\">Node owns the naming rules</h3>\n\n<p>Everything that is a function of its inputs goes here, including the Blob, because Node has one:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// tests/export-file.unit.test.ts\nimport { describe, expect, test } from 'vitest'\nimport { buildExport, exportFilename, mimeFor, serialise } from '../src/export-file'\n\nconst at = new Date('2026-10-05T23:30:00Z')\n\ndescribe('exportFilename', () =&gt; {\n  test.each([\n    ['Q3 Revenue / EMEA', 'q3-revenue-emea-2026-10-05.csv'],\n    ['Café Ørders', 'cafe-rders-2026-10-05.csv'],\n    ['../../etc/passwd', 'etc-passwd-2026-10-05.csv'],\n    ['***', 'export-2026-10-05.csv'],\n  ])('%s -&gt; %s', (name, expected) =&gt; {\n    expect(exportFilename(name, 'csv', at)).toBe(expected)\n  })\n})\n\ntest('CSV quoting survives commas, quotes and newlines', () =&gt; {\n  expect(serialise([{ name: 'Smith, \"Jo\"', note: 'a\\nb' }], 'csv'))\n    .toBe('name,note\\n\"Smith, \"\"Jo\"\"\",\"a\\nb\"')\n})\n\ntest('the Blob carries the MIME type and the bytes', async () =&gt; {\n  const blob = buildExport([{ id: 1 }], 'json')\n  expect(blob.type).toBe(mimeFor('json'))\n  expect(JSON.parse(await blob.text())).toEqual([{ id: 1 }])\n})</code></pre>\n\n<p>The filename table is where the edge cases belong, and it can grow to fifty rows at no cost. Notice the second row: <code class=\"language-text\">Ø</code> does not decompose under NFKD, so it is dropped rather than transliterated. If the slug pattern gets more involved, the <a href=\"/tools/regex-tester\">Regex Tester</a> is a quick way to try it against the awkward names before they become table rows.</p>\n\n<p>Sanitising here is not optional polish. MDN's description of the <code class=\"language-text\">download</code> attribute says <code class=\"language-text\">/</code> and <code class=\"language-text\">\\</code> 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.</p>\n\n<h3 id=\"browser-mode-owns-the-click-and-the-blob-url\">Browser mode owns the click and the Blob URL</h3>\n\n<p>Run the two layers side by side with Vitest projects, the configuration shape the browser mode guide itself documents:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// vitest.config.ts\nimport { defineConfig } from 'vitest/config'\nimport { playwright } from '@vitest/browser-playwright'\n\nexport default defineConfig({\n  test: {\n    projects: [\n      {\n        test: {\n          name: 'unit',\n          environment: 'node',\n          include: ['tests/**/*.unit.test.ts'],\n        },\n      },\n      {\n        test: {\n          name: 'browser',\n          include: ['tests/**/*.browser.test.ts'],\n          browser: {\n            enabled: true,\n            provider: playwright(),\n            headless: true,\n            instances: [{ browser: 'chromium' }],\n          },\n        },\n      },\n    ],\n  },\n})</code></pre>\n\n<p>The browser test drives a real click, lets the real <code class=\"language-text\">URL.createObjectURL</code> run, and stops the download at the anchor so the test observes what the page handed over without the browser saving anything:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// tests/download-button.browser.test.ts\nimport { afterEach, expect, test, vi } from 'vitest'\nimport { page } from 'vitest/browser'\nimport { mountExportButton } from '../src/download-button'\n\nafterEach(() =&gt; {\n  document.body.replaceChildren()\n  vi.restoreAllMocks()\n})\n\ntest('a real click hands the browser a CSV blob under the computed name', async () =&gt; {\n  const createUrl = vi.spyOn(URL, 'createObjectURL')\n  const revokeUrl = vi.spyOn(URL, 'revokeObjectURL')\n\n  let captured: { href: string; download: string } | undefined\n  const stopDownload = (event: MouseEvent) =&gt; {\n    const link = (event.target as Element).closest('a[download]') as HTMLAnchorElement | null\n    if (!link) return\n    captured = { href: link.href, download: link.download }\n    event.preventDefault()\n  }\n  document.addEventListener('click', stopDownload, { capture: true })\n\n  mountExportButton(document.body, {\n    reportName: 'Q3 Revenue / EMEA',\n    format: 'csv',\n    getRows: () =&gt; [{ region: 'EMEA', total: 1200 }],\n    now: () =&gt; new Date('2026-10-05T09:00:00Z'),\n  })\n\n  await page.getByRole('button', { name: 'Export CSV' }).click()\n\n  expect(captured?.download).toBe('q3-revenue-emea-2026-10-05.csv')\n  const blob = createUrl.mock.calls[0][0] as Blob\n  const objectUrl = createUrl.mock.results[0].value as string\n  expect(captured?.href).toBe(objectUrl)\n  expect(blob.type).toBe('text/csv;charset=utf-8')\n  expect(await blob.text()).toBe('region,total\\nEMEA,1200')\n\n  await vi.waitFor(() =&gt; expect(revokeUrl).toHaveBeenCalledWith(objectUrl))\n  await expect(fetch(objectUrl)).rejects.toThrow()\n\n  document.removeEventListener('click', stopDownload, { capture: true })\n})</code></pre>\n\n<p>Do not read the bytes by fetching <code class=\"language-text\">captured.href</code> after the click. It fails with <code class=\"language-text\">TypeError: Failed to fetch</code>: 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.</p>\n\n<p>The choice of click matters too. The <code class=\"language-text\">userEvent</code> and locator methods from <code class=\"language-text\">vitest/browser</code> use CDP or WebDriver \"instead of faking events\". The guide recommends against <code class=\"language-text\">@testing-library/user-event</code> in browser mode for exactly this reason, and with the <code class=\"language-text\">preview</code> 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 <code class=\"language-text\">'Export CSV'</code> will not match <code class=\"language-text\">'Export CSV file'</code>.</p>\n\n<h3 id=\"playwright-owns-what-the-browser-saves\">Playwright owns what the browser saves</h3>\n\n<p>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:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// e2e/export.spec.ts\nimport { readFile } from 'node:fs/promises'\nimport { expect, test } from '@playwright/test'\n\ntest('the browser saves the export under the name the app chose', async ({ page }, testInfo) =&gt; {\n  await page.goto('/reports/q3-emea')\n\n  const downloadPromise = page.waitForEvent('download')\n  await page.getByRole('button', { name: 'Export CSV' }).click()\n  const download = await downloadPromise\n\n  expect(download.suggestedFilename()).toBe('q3-revenue-emea-2026-10-05.csv')\n  expect(await download.failure()).toBeNull()\n\n  const target = testInfo.outputPath(download.suggestedFilename())\n  await download.saveAs(target)\n  expect(await readFile(target, 'utf8')).toContain('region,total')\n})</code></pre>\n\n<p>Start waiting before the click; the Playwright docs mark the missing <code class=\"language-text\">await</code> on <code class=\"language-text\">waitForEvent</code> as deliberate. Save before the test ends, because \"downloaded files are deleted when the browser context that produced them is closed\", and note that <code class=\"language-text\">download.path()</code> returns a random GUID filename, so assert on <code class=\"language-text\">suggestedFilename()</code>.</p>\n\n<p>That method is the reason this layer exists. Its documentation says the name \"is typically computed by the browser from the <code class=\"language-text\">Content-Disposition</code> response header or the <code class=\"language-text\">download</code> 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 <code class=\"language-text\">Content-Disposition</code> header \"takes priority over a filename specified in the <code class=\"language-text\">download</code> attribute\".</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// playwright.config.ts\nimport { defineConfig, devices } from '@playwright/test'\n\nexport default defineConfig({\n  use: { baseURL: 'http://localhost:4173', trace: 'on-first-retry' },\n  projects: [\n    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },\n    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },\n    { name: 'webkit', use: { ...devices['Desktop Safari'] } },\n  ],\n})</code></pre>\n\n<h3 id=\"the-journey-that-gets-an-end-to-end-test\">The journey that gets an end-to-end test</h3>\n\n<p>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.</p>\n\n<h2 id=\"capability-to-layer-decision-table\">Capability-to-layer decision table</h2>\n\n<p>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.</p>\n\n<table>\n<thead>\n<tr><th>What the assertion must observe</th><th>Lowest sufficient layer</th><th>What the layer below gets wrong</th></tr>\n</thead>\n<tbody>\n<tr><td>Filename, MIME type, serialisation, Blob bytes</td><td>Vitest, <code class=\"language-text\">node</code></td><td>Nothing below; a browser here only adds launch time</td></tr>\n<tr><td>Rendered structure, accessible names, a click reaching its handler, disabled states</td><td>Vitest, <code class=\"language-text\">jsdom</code> or <code class=\"language-text\">happy-dom</code></td><td>Node has no <code class=\"language-text\">document</code>, so you end up testing a hand-built fake</td></tr>\n<tr><td>Size, overflow, visibility, scroll position, anything read from <code class=\"language-text\">getBoundingClientRect()</code></td><td>Vitest browser mode</td><td>jsdom returns zeros, so inequality assertions pass vacuously</td></tr>\n<tr><td>Pointer, focus and keyboard behaviour; a live object URL and its revocation</td><td>Vitest browser mode with <code class=\"language-text\">playwright</code></td><td>jsdom and the <code class=\"language-text\">preview</code> provider simulate events rather than dispatching real input</td></tr>\n<tr><td>A download, popup, dialog or navigation the browser performs for the page</td><td>Playwright Test</td><td>Browser mode runs inside the test iframe and cannot see the browser's side of the transaction</td></tr>\n<tr><td>An in-page API that behaves differently across engines</td><td>Browser mode with several <code class=\"language-text\">instances</code></td><td>A single Chromium instance says nothing about Gecko or WebKit</td></tr>\n<tr><td>A browser-level behaviour that differs across engines, such as the suggested download name</td><td>Playwright projects for each promised engine</td><td>Browser mode instances cannot observe the event at all</td></tr>\n<tr><td>Server, auth, storage and browser agreeing on one outcome</td><td>One Playwright end-to-end test against a deployed stack</td><td>Every layer above stubs at least one of those parties</td></tr>\n</tbody>\n</table>\n\n<p>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.</p>\n\n<h2 id=\"zero-width-passes-every-inequality\">Zero width passes every inequality</h2>\n\n<p>The opening test is the most common way this goes wrong, so here it is in full. In jsdom:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// @vitest-environment jsdom\ntest('export label fits a narrow toolbar', () =&gt; {\n  const toolbar = document.createElement('div')\n  toolbar.style.width = '48px'\n  document.body.append(toolbar)\n  const button = mountExportButton(toolbar, { reportName: 'Q3', format: 'csv', getRows: () =&gt; [] })\n  button.style.whiteSpace = 'nowrap'\n  button.style.overflow = 'hidden'\n  button.style.maxWidth = '100%'\n\n  // 0 &lt;= 0: green at any toolbar width\n  expect(button.scrollWidth).toBeLessThanOrEqual(button.clientWidth)\n})</code></pre>\n\n<p>Moved to the browser project, with one extra line:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// tests/toolbar-fit.browser.test.ts\ntest.each([48, 160])('export label fits a %ipx toolbar', (width) =&gt; {\n  // ...same setup...\n  expect(button.clientWidth).toBeGreaterThan(0)\n  expect(button.scrollWidth).toBeLessThanOrEqual(button.clientWidth)\n})\n// 48px fails: expected 82 to be less than or equal to 44\n// 160px passes</code></pre>\n\n<p>The <code class=\"language-text\">toBeGreaterThan(0)</code> 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 <code class=\"language-text\">Object.defineProperty()</code> as a workaround. That makes the test pass on numbers you typed, which is a test of your fixture.</p>\n\n<h2 id=\"a-regex-does-not-need-webkit\">A regex does not need WebKit</h2>\n\n<p>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.</p>\n\n<p>If a transformation is only reachable through a click, extract it. Once <code class=\"language-text\">exportFilename</code> imports without a DOM, the browser tests shrink to one happy path each.</p>\n\n<h2 id=\"chromium-green-is-a-chromium-result\">Chromium green is a Chromium result</h2>\n\n<p>A Chromium-only suite proves the Chromium contract. That is often the right scope; reporting it as \"works in all browsers\" is the mistake.</p>\n\n<p>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.</p>\n\n<p>If the supported-browser list says Safari by name and the behaviour is in-page, browser mode's WebdriverIO provider lists <code class=\"language-text\">safari</code> among its browsers, where Playwright offers <code class=\"language-text\">webkit</code>. Know what you are adopting: as of Vitest 5 that provider is community-maintained and \"addressed on a per-issue basis\".</p>\n\n<p>When you do need several engines for in-page behaviour, prefer <code class=\"language-text\">browser.instances</code> 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\".</p>\n\n<h2 id=\"feedback-install-and-artefact-costs\">Feedback, install and artefact costs</h2>\n\n<p><strong>Feedback time.</strong> 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 <code class=\"language-text\">vmThreads</code> 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.</p>\n\n<p><strong>CI install.</strong> The <code class=\"language-text\">preview</code> provider cannot run headless or in CI; the guide says CI needs <code class=\"language-text\">playwright</code> or <code class=\"language-text\">webdriverio</code>. 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: <code class=\"language-text\">npx playwright install --with-deps chromium</code> installs one engine and its system packages, and <code class=\"language-text\">--only-shell</code> skips the full Chromium build when everything runs headless.</p>\n\n<p><strong>Diagnostic artefacts.</strong> 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 <code class=\"language-text\">.vitest/attachments/failure-screenshots/</code>. It also adds the new <code class=\"language-text\">browser.traceView</code> 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 <code class=\"language-text\">trace: 'on-first-retry'</code> so the cost is paid only when something has already failed. Budget storage for the upper layers and nothing for the bottom one.</p>\n\n<h2 id=\"sort-one-component-by-capability-today\">Sort one component's tests by capability today</h2>\n\n<p>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.</p>\n\n<p>Then search your jsdom projects for <code class=\"language-text\">getBoundingClientRect</code>, <code class=\"language-text\">offsetWidth</code>, <code class=\"language-text\">clientWidth</code> and <code class=\"language-text\">scrollWidth</code>. 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.</p>\n\n<p>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.</p>\n\n<h2 id=\"questions-about-splitting-vitest-and-playwright\">Questions about splitting Vitest and Playwright</h2>\n\n<h3 id=\"can-browser-mode-replace-playwright-test\">Can Vitest browser mode replace Playwright Test?</h3>\n\n<p>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.</p>\n\n<h3 id=\"is-happy-dom-a-fix-for-the-layout-problem\">Does switching from jsdom to happy-dom fix layout assertions?</h3>\n\n<p>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.</p>\n\n<h3 id=\"why-not-mock-createobjecturl-in-jsdom\">Why not mock <code class=\"language-text\">URL.createObjectURL</code> in jsdom and stop there?</h3>\n\n<p>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.</p>\n\n\n<h2 id=\"vitest-and-playwright-primary-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://vitest.dev/guide/browser/\">Vitest — Browser Mode</a>. Provider packages (<code class=\"language-text\">@vitest/browser-playwright</code>, <code class=\"language-text\">@vitest/browser-preview</code>), that CI requires <code class=\"language-text\">playwright</code> or <code class=\"language-text\">webdriverio</code> because <code class=\"language-text\">preview</code> \"relies on simulating events\", that headless is not available with <code class=\"language-text\">preview</code>, the <code class=\"language-text\">projects</code> configuration running a Node project beside a browser project, the browser lists per provider (<code class=\"language-text\">safari</code> under WebdriverIO, <code class=\"language-text\">webkit</code> under Playwright), tests running inside an iframe, the warning against <code class=\"language-text\">@testing-library/user-event</code>, and the limitation on thread-blocking dialogs. Vitest 5.0.2.</li>\n<li><a href=\"https://vitest.dev/guide/browser/why\">Vitest — Why Browser Mode</a>. 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.</li>\n<li><a href=\"https://vitest.dev/guide/browser/multiple-setups\">Vitest — Multiple Setups</a>. <code class=\"language-text\">browser.instances</code> for Chromium, Firefox and WebKit, and that instances share one Vite server so transform and pre-bundling \"has to happen only once\".</li>\n<li><a href=\"https://vitest.dev/api/browser/interactivity\">Vitest — Interactivity API</a>. That <code class=\"language-text\">userEvent</code> from <code class=\"language-text\">vitest/browser</code> uses CDP or WebDriver \"instead of faking events\".</li>\n<li><a href=\"https://vitest.dev/api/browser/context\">Vitest — Context API</a>. That <code class=\"language-text\">userEvent</code> with the <code class=\"language-text\">preview</code> provider \"fallbacks to simulated events via <code class=\"language-text\">@testing-library/user-event</code>\".</li>\n<li><a href=\"https://vitest.dev/api/browser/commands\">Vitest — Commands</a>. That the command context's <code class=\"language-text\">page</code> \"references the full page that contains the test iframe\", the orchestrator, which \"you most likely shouldn't touch\".</li>\n<li><a href=\"https://vitest.dev/guide/environment\">Vitest — Test Environment</a>. The <code class=\"language-text\">node</code>, <code class=\"language-text\">jsdom</code> and <code class=\"language-text\">happy-dom</code> environments, happy-dom being \"considered to be faster than jsdom, but lacks some API\", the <code class=\"language-text\">@vitest-environment</code> control comment, and that browser is not an environment but a project.</li>\n<li><a href=\"https://vitest.dev/guide/browser/playwright-traces\">Vitest — Playwright Traces</a>. Trace files being \"only available when using the Playwright provider\".</li>\n<li><a href=\"https://vitest.dev/guide/browser/trace-view\">Vitest — Trace View</a>. <code class=\"language-text\">browser.traceView</code> as a DOM-snapshot replay added in 5.0.0.</li>\n<li><a href=\"https://vitest.dev/guide/migration\">Vitest — Migrating to Vitest 5.0</a>. The Node.js 22.12 and Vite 6.4 minimums, exact-by-default locators, failure screenshots moving to <code class=\"language-text\">.vitest/attachments/failure-screenshots/</code>, and the WebdriverIO provider moving to vitest-community, \"addressed on a per-issue basis\".</li>\n<li><a href=\"https://vitest.dev/blog/vitest-4\">Vitest 4.0 is out!</a>. Browser mode losing its experimental tag, providers moving to separate packages, and the context import moving from <code class=\"language-text\">@vitest/browser/context</code> to <code class=\"language-text\">vitest/browser</code>.</li>\n<li><a href=\"https://vitest.dev/blog/vitest-5\">Vitest 5.0 is out!</a>. The benchmark cell comparing the same 92-module React app under jsdom (1.07 s) and browser mode on Chromium (2.01 s).</li>\n<li><a href=\"https://github.com/jsdom/jsdom#unimplemented-parts-of-the-web-platform\">jsdom README — Unimplemented parts of the web platform</a>. Layout and navigation as out of scope, \"zeros for many layout-related properties\", and the <code class=\"language-text\">Object.defineProperty()</code> workaround. jsdom 30.1.1.</li>\n<li><a href=\"https://playwright.dev/docs/downloads\">Playwright — Downloads</a>. The <code class=\"language-text\">waitForEvent('download')</code> pattern started before the click, and that \"downloaded files are deleted when the browser context that produced them is closed\".</li>\n<li><a href=\"https://playwright.dev/docs/api/class-download\">Playwright — Download</a>. <code class=\"language-text\">path()</code> returning a GUID name, <code class=\"language-text\">saveAs()</code>, <code class=\"language-text\">failure()</code>, and that <code class=\"language-text\">suggestedFilename()</code> is browser-computed and \"different browsers can use different logic\".</li>\n<li><a href=\"https://playwright.dev/docs/test-projects\">Playwright — Projects</a>. The Chromium, Firefox and WebKit project configuration with <code class=\"language-text\">devices</code>.</li>\n<li><a href=\"https://playwright.dev/docs/browsers\">Playwright — Browsers</a>. WebKit derived from WebKit main and not the branded Safari, Firefox tracking Stable, platform-dependent codecs, install sizes, <code class=\"language-text\">--with-deps</code> and <code class=\"language-text\">--only-shell</code>. Playwright 1.63.0.</li>\n<li><a href=\"https://playwright.dev/docs/trace-viewer-intro\">Playwright — Trace viewer</a>. The default <code class=\"language-text\">trace: 'on-first-retry'</code> configuration.</li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/a\">MDN — The anchor element</a>. The <code class=\"language-text\">download</code> attribute converting <code class=\"language-text\">/</code> and <code class=\"language-text\">\\</code> to underscores, browsers adjusting names, working only for same-origin, <code class=\"language-text\">blob:</code> and <code class=\"language-text\">data:</code> URLs, and a <code class=\"language-text\">Content-Disposition</code> filename taking priority.</li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static\">MDN — URL.createObjectURL()</a>. Creating a blob URL that points at a Blob.</li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/URL/revokeObjectURL_static\">MDN — URL.revokeObjectURL()</a>. Releasing an object URL once it is no longer needed, the cleanup the browser-mode test asserts.</li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/Blob\">MDN — Blob</a>. <code class=\"language-text\">Blob.type</code> as the MIME type and <code class=\"language-text\">Blob.text()</code> for reading contents.</li>\n<li><a href=\"https://nodejs.org/api/buffer.html#class-blob\">Node.js — Class: Blob</a>. Blob available in Node and \"no longer experimental\" since v18.0.0, the basis for asserting Blob contents in a Node test.</li>\n</ul>\n"}