Vitest 3.0.0 was published to npm on 16 January, and the announcement followed on 17 January. Vitest is the test runner create-vue offers for unit tests. It runs on Vite and reads your Vite config, so .vue files work in tests without extra setup.
Most Vue component tests pass on Vitest 3 unchanged. The breaking changes that can fail one involve mocks, fake timers and error matching, and the fixes are small.
What shipped
The announcement names five headline changes:
- The terminal reporter was rewritten. Output flickers less and stays stable while tests run. The reporter API behind the
reportersoption was redesigned with a simpler lifecycle: the oldonTaskUpdatehook still works but is deprecated in favour of hooks such asonTestCaseResult. - Workspaces can be declared inline. A monorepo lists its projects straight in the config file with
workspace: ['packages/*'], with no separate workspace file. - Browser mode gets
browser.instances, an array that runs the same tests in several browsers from one config and one Vite server. - You can run a single test by its line number:
vitest src/Counter.spec.ts:12. - The
vitest/nodeAPI was redesigned and documented, and the team plans to drop its experimental label in the next minor.
The changelog adds smaller features: the new matchers toBeOneOf and toHaveBeenCalledExactlyOnceWith, expect.toSatisfy() as an asymmetric matcher, a describe.for that mirrors test.for, and a summary option for the default reporter.
Breaking changes that can fail a component test
The announcement calls the breaking changes small, and for a typical Vue project that is true. The migration guide linked from the announcement lists them in full. These four can reach a component test.
mockReset() now restores the original implementation. Before, mockReset() turned a mock into an empty function that returns undefined. Now a mock created with vi.fn(() => 'hello') goes back to returning 'hello' after a reset. If your afterEach relied on a reset to silence a mock, the mock now keeps its behaviour.
vi.spyOn() reuses an existing spy. Spying on a method that is already spied on returns the same spy instead of wrapping it again, so mockRestore() restores the real method rather than the previous spy. This fixes a class of leaky tests, but a test that counted on two separate spies will see one.
Fake timers fake more by default. vi.useFakeTimers() used to replace a fixed list: setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, clearImmediate and Date. Now it replaces every timer-related API it finds except process.nextTick. That includes performance.now() and, in a DOM environment such as happy-dom or jsdom, requestAnimationFrame.
A component that measures elapsed time with performance.now() will see time stand still until you advance it. To get the old list back for every test, set it in your config:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
fakeTimers: {
toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'setImmediate', 'clearImmediate', 'Date'],
},
},
})
toFake names exactly what gets faked. To change one test only, pass the same option to that call instead: vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }).
Error equality is stricter. toEqual and toThrowError now compare an error's name, message and cause, and check the prototype. expect(fn).toThrowError(new Error('Bad input')) fails when the thrown error is a TypeError, where before it passed. Match the class you actually throw.
Two smaller changes do not fail a test:
- Passing options as the third argument,
it('saves', async () => {}, { timeout: 10_000 }), is deprecated. Put the object second:it('saves', { timeout: 10_000 }, async () => {}). A plain number as the third argument still works. The next major version will turn the warning into an error, so move the options now. - Test files are always excluded from the coverage report, even if you overrode
coverage.exclude. If your coverage number moves a little, that is why.
Fix a test that relied on mockReset
ShoppingList takes a load function as a prop, calls it once, and lists what it returns. When load returns nothing, it shows an empty state.
<script setup lang="ts">
const props = defineProps<{
load: () => string[] | undefined
}>()
const items = props.load() ?? []
</script>
<template>
<ul v-if="items.length">
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
<p v-else>Nothing to buy</p>
</template>
Its test file resets the mock after each test. It passed on Vitest 2 and fails on Vitest 3:
import { mount } from '@vue/test-utils'
import { afterEach, expect, it, vi } from 'vitest'
import ShoppingList from '../ShoppingList.vue'
const load = vi.fn(() => ['Milk', 'Eggs'])
afterEach(() => {
load.mockReset()
})
it('renders the items', () => {
const wrapper = mount(ShoppingList, { props: { load } })
expect(wrapper.text()).toContain('Milk')
})
it('shows the empty state', () => {
const wrapper = mount(ShoppingList, { props: { load } })
expect(wrapper.text()).toContain('Nothing to buy')
})
The second test never said what load should return. It leaned on mockReset() having turned load into a function that returns undefined, which the component turns into an empty list. On Vitest 3 the reset restores the ['Milk', 'Eggs'] implementation, the list renders, and the assertion fails with expected 'MilkEggs' to contain 'Nothing to buy'.
The fix is to say what you mean. Replace the second test with one that sets its own return value:
it('shows the empty state', () => {
load.mockReturnValue([])
const wrapper = mount(ShoppingList, { props: { load } })
expect(wrapper.text()).toContain('Nothing to buy')
})
That version passes on both majors, and it reads better: the test states its own setup instead of inheriting it from a hook. Search your suites for mockReset and for vi.fn( with an implementation, and check each pair for the same hidden dependency.
What stays the same
Most of what a Vue component test touches is unchanged:
- Vue Test Utils. Version 2.4 is the current release, and
mount,shallowMount,wrapper.find,triggerandemittedbehave as before. The library does not depend on the test runner at all. - The
environment: 'happy-dom'or'jsdom'setting. - The
vitest.config.tsthat create-vue generates, withmergeConfig(viteConfig, ...). It needs no edit. - Snapshots.
toMatchSnapshotworks exactly as it did. The change is in the API of the@vitest/snapshotpackage, which now supports several snapshot states in one run and matters only to code that calls it directly.
How to upgrade
First, the one check: confirm your Vite version with npx vite --version. If it prints 5.x or 6.x you are ready. If it prints 4.x, upgrade Vite before touching Vitest, because Vitest 3 depends on Vite 5 or 6.
Then make sure your suite is green on Vitest 2, so every failure after the upgrade is the upgrade's doing:
npx vitest --run
Now install Vitest and its companion packages in one command. The coverage, UI and browser packages each expect the exact same version as vitest, so upgrade them together:
npm install -D vitest@3 @vitest/coverage-v8@3
Add @vitest/ui@3 or @vitest/browser@3 to that line if you use them. Run the suite again, and read the warnings before the failures: the third-argument deprecation prints one warning for each test or describe that still passes its options last.
Upgrade now or wait
Upgrade now if your project is on Vite 5 or 6 and your suite is green. The breaking changes are findable by search, the new reporter is easier to read, and running one test by line number is a small thing you will use often. An app already on Vite 6 gains the most, because Vitest 3 can use the app's own Vite instead of a separate Vite 5.
Wait if you are on Vite 4, and upgrade Vite first. Fake timers are not a reason to wait. If you are unsure what your timer tests depend on, add the toFake list above when you upgrade, get the suite green, and remove the list later, one timer test at a time.