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-webdriverioand@vitest/browser-preview. Each one brings@vitest/browserwith it, so that package can leave yourdevDependencies. The browser context is imported fromvitest/browsernow, not@vitest/browser/context; the old path still works until the next major. - Visual regression testing. A new
toMatchScreenshotassertion photographs an element and compares it with a reference image stored next to the test. AtoBeInViewportmatcher arrives with it. - Playwright traces. Set
traceundertest.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 alengthproperty, sotoHaveLengthworks on a locator.expect.schemaMatchingvalidates a value against any Standard Schema object, such as a Zod schema, andexpect.assertexposes Chai'sassertfor narrowing types.- Reporters: the
basicreporter is gone,defaultprints a tree only when a single file runs, a newtreereporter always does, andverboseprints each test as it finishes rather than only in CI. beforeEachandafterEachcan be called on the object returned bytest.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:
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:
<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>
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>:
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:
export default defineConfig({
test: {
browser: {
enabled: true,
provider: 'playwright',
instances: [{ browser: 'chromium' }],
},
},
})
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
workspaceoption is removed in favour ofprojects, and aprojectsentry can no longer point to a file that exports an array. Atest.workspaceoption stops the run with an error, but avitest.workspace.tsthat Vitest 3 found on its own is now skipped without a warning, and every test runs under the root config. Move its contents intotest.projectsinvitest.config.ts. - The default
excludeonly skipsnode_modulesand.git. If you kept tests out ofdistorcypressby relying on the old defaults, addtest.diror your ownexclude. - V8 coverage uses AST-based remapping, and the guide says to expect the numbers to move.
coverage.allandcoverage.extensionsare removed: the report includes only files loaded during the run unless you setcoverage.include, so set it to['src/**']. maxThreadsandmaxForksbecomemaxWorkers,poolOptionsis removed and its options move to the top level, andsingleThread: truebecomesmaxWorkers: 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 therestoreMocks: trueconfig option, now only put back the originals of spies created withvi.spyOn. They no longer clear any mock's call history or resetvi.fn()implementations. If you relied on that, addclearMocks: true(history) ormockReset: true(history and implementations) to the config.- A mock used as a constructor must be implemented with
functionorclass. With an arrow function,newthrows aTypeErrorthat ends inis not a constructor, after a warning that the mock did not usefunctionorclass. vi.fn().getMockName()returnsvi.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:
- Add
@vitest/ui@4if you use the UI. - For Browser Mode, uninstall
@vitest/browser, install@vitest/browser-playwright@4, and change the provider to the factory. - If you have a
vitest.workspace.ts, move it intotest.projectsnow. - Run the suite and fix configuration errors before test failures. The provider string, a
test.workspaceoption and third-argument options all stop with a clear message. - 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.