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

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-10-05T13:30:00.000Z

- Updated: 2026-10-14T11:09:52.936Z

- Category: Automation Tutorials

- Canonical source: https://automationtester.in/blog/automation-tutorials/choose-vitest-browser-mode-playwright-by-risk

- Tags: vitest, playwright, typescript, browser-testing, component-testing, test-strategy

**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](/tools/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 observe | Lowest sufficient layer | What the layer below gets wrong |
| --- | --- | --- |
| Filename, MIME type, serialisation, Blob bytes | Vitest, `node` | Nothing below; a browser here only adds launch time |
| Rendered structure, accessible names, a click reaching its handler, disabled states | Vitest, `jsdom` or `happy-dom` | Node has no `document`, so you end up testing a hand-built fake |
| Size, overflow, visibility, scroll position, anything read from `getBoundingClientRect()` | Vitest browser mode | jsdom returns zeros, so inequality assertions pass vacuously |
| Pointer, focus and keyboard behaviour; a live object URL and its revocation | Vitest browser mode with `playwright` | jsdom and the `preview` provider simulate events rather than dispatching real input |
| A download, popup, dialog or navigation the browser performs for the page | Playwright Test | Browser mode runs inside the test iframe and cannot see the browser's side of the transaction |
| An in-page API that behaves differently across engines | Browser mode with several `instances` | A single Chromium instance says nothing about Gecko or WebKit |
| A browser-level behaviour that differs across engines, such as the suggested download name | Playwright projects for each promised engine | Browser mode instances cannot observe the event at all |
| Server, auth, storage and browser agreeing on one outcome | One Playwright end-to-end test against a deployed stack | Every 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](https://vitest.dev/guide/browser/). 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](https://vitest.dev/guide/browser/why). 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](https://vitest.dev/guide/browser/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](https://vitest.dev/api/browser/interactivity). That `userEvent` from `vitest/browser` uses CDP or WebDriver "instead of faking events".

- [Vitest — Context API](https://vitest.dev/api/browser/context). That `userEvent` with the `preview` provider "fallbacks to simulated events via `@testing-library/user-event`".

- [Vitest — Commands](https://vitest.dev/api/browser/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](https://vitest.dev/guide/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](https://vitest.dev/guide/browser/playwright-traces). Trace files being "only available when using the Playwright provider".

- [Vitest — Trace View](https://vitest.dev/guide/browser/trace-view). `browser.traceView` as a DOM-snapshot replay added in 5.0.0.

- [Vitest — Migrating to Vitest 5.0](https://vitest.dev/guide/migration). 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!](https://vitest.dev/blog/vitest-4). 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!](https://vitest.dev/blog/vitest-5). 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](https://github.com/jsdom/jsdom#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](https://playwright.dev/docs/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](https://playwright.dev/docs/api/class-download). `path()` returning a GUID name, `saveAs()`, `failure()`, and that `suggestedFilename()` is browser-computed and "different browsers can use different logic".

- [Playwright — Projects](https://playwright.dev/docs/test-projects). The Chromium, Firefox and WebKit project configuration with `devices`.

- [Playwright — Browsers](https://playwright.dev/docs/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](https://playwright.dev/docs/trace-viewer-intro). The default `trace: 'on-first-retry'` configuration.

- [MDN — The anchor element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/a). 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()](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static). Creating a blob URL that points at a Blob.

- [MDN — URL.revokeObjectURL()](https://developer.mozilla.org/en-US/docs/Web/API/URL/revokeObjectURL_static). Releasing an object URL once it is no longer needed, the cleanup the browser-mode test asserts.

- [MDN — Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob). `Blob.type` as the MIME type and `Blob.text()` for reading contents.

- [Node.js — Class: Blob](https://nodejs.org/api/buffer.html#class-blob). Blob available in Node and "no longer experimental" since v18.0.0, the basis for asserting Blob contents in a Node test.

## Continue reading on AutomationTester.in

- [Mock or Integrate? Playwright Network Testing Boundaries](https://automationtester.in/blog/automation-tutorials/playwright-network-mocking-integration-boundaries)
- [Build Deterministic Python API Test Data Factories](https://automationtester.in/blog/automation-tutorials/python-api-test-data-factories-deterministic)
- [Make JUnit Failures Useful in CI](https://automationtester.in/blog/automation-tutorials/junit-failure-reports-useful-ci)
- [Design Playwright Fixtures for Parallel Test Isolation](https://automationtester.in/blog/automation-tutorials/playwright-fixtures-parallel-test-isolation)
- [Debug JUnit Parallel-Execution Flakes](https://automationtester.in/blog/automation-tutorials/debug-junit-parallel-execution-flakes)

Source: [Choose Vitest, Browser Mode, or Playwright by Risk](https://automationtester.in/blog/automation-tutorials/choose-vitest-browser-mode-playwright-by-risk) by Shashank Rawlani.
