Skip to content
Release notesTesting

Vitest 4: Browser Mode is stable, visual regression and Playwright traces

Vitest 4.0.0 was published on 22 October. If your suite runs in happy-dom, the upgrade is small. If you want to test components in a real browser, this is the release to start with.

vuejs.school team23 Oct 20258 min readVitest 4.0 · vitest-browser-vue 2.0

Vitest 4.0.0 reached npm on 22 October, and the announcement went up the same day. The headline is Browser Mode: after a long run with an experimental label, running component tests inside Chromium, Firefox or WebKit is now a supported way to test, and it gains screenshot comparisons and Playwright traces.

If your component tests fight happy-dom over layout, focus or scrolling, start with the setup below, which uses vitest-browser-vue 2.0. If you only want to upgrade an existing suite, go to the breaking changes: the release notes and the migration guide list them, and most are configuration.

What shipped

  • Browser Mode is stable. The providers moved into their own packages: @vitest/browser-playwright, @vitest/browser-webdriverio and @vitest/browser-preview. Each one brings @vitest/browser with it, so that package can leave your devDependencies. The browser context is imported from vitest/browser now, not @vitest/browser/context; the old path still works until the next major.
  • Visual regression testing. A new toMatchScreenshot assertion photographs an element and compares it with a reference image stored next to the test. A toBeInViewport matcher arrives with it.
  • Playwright traces. Set trace under test.browser, or pass --browser.trace=on, and Vitest records a trace you can open in the Playwright trace viewer.
  • page.frameLocator (Playwright only) finds elements inside an iframe, and every locator has a length property, so toHaveLength works on a locator.
  • expect.schemaMatching validates a value against any Standard Schema object, such as a Zod schema, and expect.assert exposes Chai's assert for narrowing types.
  • Reporters: the basic reporter is gone, default prints a tree only when a single file runs, a new tree reporter always does, and verbose prints each test as it finishes rather than only in CI.
  • beforeEach and afterEach can be called on the object returned by test.extend, so they see the extended context types.

Why test a component in a real browser

Most Vue component tests run in Node with happy-dom or jsdom, two libraries that imitate a browser's document model. They are fast and they are enough for "renders the list, emits on click". They do not do layout. An element has no width, scrollIntoView does nothing, focus and CSS behave approximately, and a library that measures the DOM (a virtual scroller, a chart, a date picker) often fails in ways the real thing never would.

Browser Mode starts your Vite dev server, opens a real browser through Playwright and runs the test file inside it. The component renders with its real CSS, measures real sizes and receives real clicks. The price is speed: a browser test takes longer to start than a Node test. The split that works is to keep composable and store tests in Node and move the component tests that depend on layout, focus, scrolling or third-party DOM code into the browser.

A Vue component test in Browser Mode

Install the runner, the Playwright provider, the Vue renderer and Playwright itself. @vitest/browser-playwright lists playwright as a peer dependency, which is why it is on the line, and the second command downloads Chromium. The config also uses @vitejs/plugin-vue, which a Vite Vue project already has.

npm install -D vitest@4 @vitest/browser-playwright@4 vitest-browser-vue@2 playwright
npx playwright install chromium

The configuration names a provider and at least one browser instance:

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

export default defineConfig({
  plugins: [vue()],
  test: {
    setupFiles: ['vitest-browser-vue'],
    browser: {
      enabled: true,
      provider: playwright(),
      headless: true,
      instances: [{ browser: 'chromium' }],
    },
  },
})

provider: playwright() is a function call, not a string; that is one of the breaking changes below. instances lists the browsers to run in, one entry each. headless: true keeps the browser window closed, which you want in CI; drop it locally to watch the tests run.

The setupFiles entry loads vitest-browser-vue before every test file. It adds page.render, and it unmounts the previous component before each test starts rather than after it ends, so the last render stays on screen for you to look at.

Here is a component and its test. The test file ends in .browser.test.ts, the pattern that split matches:

src/components/Counter.vue
<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps<{ initialCount?: number }>()
const count = ref(props.initialCount ?? 0)
</script>

<template>
  <p>Count is {{ count }}</p>
  <button type="button" @click="count++">Increment</button>
</template>
src/components/Counter.browser.test.ts
import { expect, test } from 'vitest'
import { render } from 'vitest-browser-vue'
import Counter from './Counter.vue'

test('increments when the button is clicked', async () => {
  const screen = render(Counter, { props: { initialCount: 1 } })

  await screen.getByRole('button', { name: 'Increment' }).click()

  await expect.element(screen.getByText('Count is 2')).toBeVisible()
})

render mounts the component and returns a screen with locators: getByRole, getByText, getByLabelText and the rest. It is synchronous in 2.0. The click is a real click, so it is awaited. expect.element retries the assertion until it passes or times out, which removes every await nextTick() you would write in a Node test. vitest-browser-vue 2.0.0 was published on the same day as Vitest 4 and requires it; underneath it uses Vue Test Utils, and its config.global export is where you set global plugins or mocks such as a $t function.

Compare screenshots and record traces

A screenshot assertion takes a locator and a name. This test assumes a Card component whose root element is an <article>:

src/components/Card.browser.test.ts
import { expect, test } from 'vitest'
import { render } from 'vitest-browser-vue'
import Card from './Card.vue'

test('matches the reference image', async () => {
  const screen = render(Card, { props: { title: 'Hello' } })

  await expect(screen.getByRole('article')).toMatchScreenshot('card-default')
})

Reference images live in a __screenshots__ folder beside the test file, one subfolder per test file, with the browser and platform in the file name. Commit them. The first run has nothing to compare with: Vitest saves the reference and fails the test, so check the image and run again. When a later comparison fails, the actual image and a diff are written to .vitest-attachments, and vitest --update replaces the references once you have checked the change is intended.

Vitest sets no default for how many pixels may differ. The visual regression guide sets 1% in its example under test.browser.expect.toMatchScreenshot: comparatorName: 'pixelmatch' with comparatorOptions: { allowedMismatchedPixelRatio: 0.01 }. It also strongly recommends generating and comparing screenshots in the same environment, such as a Docker container in CI, because fonts and GPUs differ between machines.

Traces are a Playwright-only feature. Add trace: 'retain-on-failure' under test.browser (the other values are on, off, on-first-retry and on-all-retries), and a failed test leaves a .trace.zip in a __traces__ folder next to the test. Open it with npx playwright show-trace <file> to step through every action with a DOM snapshot, network log and console output at each step. For a flaky browser test this replaces guessing.

Breaking changes for an existing suite

The provider option changed shape. A string no longer works:

vitest.config.tsBreaks
export default defineConfig({
  test: {
    browser: {
      enabled: true,
      provider: 'playwright',
      instances: [{ browser: 'chromium' }],
    },
  },
})
vitest.config.tsWorks
import { playwright } from '@vitest/browser-playwright'

export default defineConfig({
  test: {
    browser: {
      enabled: true,
      provider: playwright(),
      instances: [{ browser: 'chromium' }],
    },
  },
})

The factory takes options, for example playwright({ launchOptions: { slowMo: 100 } }), and the preview provider is no longer the default, so a config without a provider must now name one.

Outside Browser Mode, these are the changes most likely to touch a Vue project. In the config file:

  • The workspace option is removed in favour of projects, and a projects entry can no longer point to a file that exports an array. A test.workspace option stops the run with an error, but a vitest.workspace.ts that Vitest 3 found on its own is now skipped without a warning, and every test runs under the root config. Move its contents into test.projects in vitest.config.ts.
  • The default exclude only skips node_modules and .git. If you kept tests out of dist or cypress by relying on the old defaults, add test.dir or your own exclude.
  • V8 coverage uses AST-based remapping, and the guide says to expect the numbers to move. coverage.all and coverage.extensions are removed: the report includes only files loaded during the run unless you set coverage.include, so set it to ['src/**'].
  • maxThreads and maxForks become maxWorkers, poolOptions is removed and its options move to the top level, and singleThread: true becomes maxWorkers: 1, isolate: false.

In test code:

  • Passing options as the third argument, it('saves', fn, { timeout: 10_000 }), was a warning in Vitest 3 and is an error now. Put the object second. A plain number still works as the last argument.
  • vi.restoreAllMocks(), and the restoreMocks: true config option, now only put back the originals of spies created with vi.spyOn. They no longer clear any mock's call history or reset vi.fn() implementations. If you relied on that, add clearMocks: true (history) or mockReset: true (history and implementations) to the config.
  • A mock used as a constructor must be implemented with function or class. With an arrow function, new throws a TypeError that ends in is not a constructor, after a warning that the mock did not use function or class.
  • vi.fn().getMockName() returns vi.fn(), so a snapshot that contained [MockFunction spy] now prints [MockFunction].

How to upgrade

Get the suite green on Vitest 3 first, so that every failure afterwards belongs to the upgrade, then install Vitest 4 and its coverage package:

npx vitest --run
npm install -D vitest@4 @vitest/coverage-v8@4

Then:

  1. Add @vitest/ui@4 if you use the UI.
  2. For Browser Mode, uninstall @vitest/browser, install @vitest/browser-playwright@4, and change the provider to the factory.
  3. If you have a vitest.workspace.ts, move it into test.projects now.
  4. Run the suite and fix configuration errors before test failures. The provider string, a test.workspace option and third-argument options all stop with a clear message.
  5. Compare the coverage report with the one from Vitest 3 and adjust thresholds rather than chasing the old figures.

Upgrade now if you are on Vite 6 or 7 and Node 20 or newer: the breaking changes are listed above, and expect.element in a real browser is a better way to test anything that touches layout. If you are still on Vite 5, wait and upgrade Vite first.

Check it stuck3 questions from the Testing Library and browser mode quiz.
Practice now
Was this helpful?

Keep reading