Skip to content
Release notesTesting

Vitest 3.2: projects, test annotations and scoped fixtures

Vitest 3.2.0 was published on 2 June. It is a minor release with one deprecation and a long list of additions. Here are the ones a Vue project will use this week.

vuejs.school team3 Jun 20256 min readVitest 3.2

Vitest 3.2.0 reached npm on 2 June, with the announcement the same day. The team describes it as a release focused on Browser Mode and TypeScript support. In a Vue project on Vitest 3, the changes you will use first are elsewhere: workspace becomes projects, a test can attach notes and files to its result, and a fixture can be set up once per file instead of once per test.

The one deprecation, the workspace option, keeps working with a warning. Upgrading from 3.1 is a version bump, plus a rename if you use a workspace.

What shipped

The release notes list the full set. Five additions change how you configure or write Vue tests, and each has a section below: the projects option, annotate(), scoped fixtures, the test signal and watchTriggerPatterns. The rest matter to fewer projects:

  • Project names can carry a colour, and sequence.groupOrder controls which projects run first.
  • In Browser Mode, locators.extend() defines reusable custom locators, the Playwright provider gains a connect option for a running Playwright server, and screenshot() takes save: false to return the image as base64 without writing a file.
  • A Matchers interface: extend it once and your custom matchers are typed on expect(), on expect itself and in expect.extend().
  • Explicit resource management: using spy = vi.spyOn(...) restores the spy when the block ends, in environments that support the using keyword (TypeScript accepts it from 5.2).
  • vi.mockObject() automocks any object, and the V8 coverage provider gets an AST-aware remapping mode behind coverage.experimentalAstAwareRemapping, planned to become the default in the next major.
  • The Vite dependency range now accepts the Vite 7 beta alongside 5 and 6.

Rename workspace to projects

A workspace was how a repository ran several test configurations under one command: unit tests in a browser-like environment and server tests in Node, or one project per package. It lived in a vitest.workspace.ts file or, since 3.0, in a workspace array. In 3.2 either form becomes a projects array in the root config, so global options such as reporters have one obvious home and the name no longer clashes with pnpm's workspaces.

For a Vue app with components and shared utilities, the root config now looks like this:

vitest.config.ts
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'

export default mergeConfig(viteConfig, defineConfig({
  test: {
    projects: [
      {
        extends: true,
        test: {
          name: 'components',
          include: ['src/**/*.spec.ts'],
          environment: 'happy-dom',
        },
      },
      {
        extends: true,
        test: {
          name: { label: 'utils', color: 'green' },
          include: ['shared/**/*.test.ts'],
          environment: 'node',
        },
      },
    ],
  },
}))

extends: true makes a project inherit the root options, including the Vue plugin merged in from vite.config.ts; without it a project starts from nothing. Project names must be unique, and --project components runs one project. Coverage and reporters stay at the root, because they describe the whole run.

Until you migrate, Vitest prints a deprecation warning, and the announcement says workspace will be removed in a future major. Migration is mechanical:

  • A workspace array in vitest.config.ts: rename the key to projects.
  • A vitest.workspace.ts file: move its array into test.projects in vitest.config.ts, then delete the file.

Add context to a failing test with annotations

A failing component test often needs context that the assertion message cannot carry: which fixture file was used, which environment variable the component read, what the rendered HTML looked like. annotate() comes from the test context, the object a test receives as its first argument:

src/components/__tests__/Checkout.spec.ts
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import Checkout from '../Checkout.vue'

it('formats the total in the shop currency', async ({ annotate }) => {
  await annotate('Currency comes from VITE_CURRENCY at build time', 'notice')

  const wrapper = mount(Checkout, { props: { items: [{ name: 'Tea', price: 4 }] } })
  await annotate('Rendered markup', { body: wrapper.html(), contentType: 'text/html' })

  expect(wrapper.get('.total').text()).toBe('£4.00')
})

The signature is annotate(message, type?, attachment?). The type is a free string and defaults to notice; leave it out and the second argument can be the attachment, as in the second call. annotate returns a promise, and Vitest awaits any you forget before the test finishes.

Where an annotation appears depends on the reporter:

  • The default terminal reporter prints annotations only under a failed test, so a green suite stays quiet.
  • The Vitest UI and the HTML report show each one on the line that called it. An image attachment is displayed; any other, like this markup, is a download link.
  • The GitHub Actions reporter, on by default in GitHub Actions, turns notice, warning and error into annotations of that level on the workflow run (any other type becomes a notice). It shows the message, not the attachment.
  • The JUnit and TAP reporters keep the type and message and drop the attachment.

Set up data once per file with scoped fixtures

A fixture is a value a test asks for by destructuring it from the context, and test.extend() defines it. Until 3.2, Vitest built each fixture again for every test that used it. The new scope option changes that. This fixture reads a product catalogue once per file, and every test in the file gets the same parsed array:

tests/fixtures.ts
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { test as base } from 'vitest'

interface Product {
  id: number
  name: string
  price: number
}

export const test = base.extend<{ products: Product[] }>({
  products: [
    async ({}, use) => {
      const raw = await readFile(join(import.meta.dirname, 'products.json'), 'utf8')
      await use(JSON.parse(raw) as Product[])
    },
    { scope: 'file' },
  ],
})

A test imports test from this file instead of from vitest and asks for products:

src/components/__tests__/ProductList.spec.ts
import { mount } from '@vue/test-utils'
import { expect } from 'vitest'
import { test } from '../../../tests/fixtures'
import ProductList from '../ProductList.vue'

test('renders one row per product', ({ products }) => {
  const wrapper = mount(ProductList, { props: { products } })
  expect(wrapper.findAll('li')).toHaveLength(products.length)
})

The tuple form, function first and options second, is where scope goes. 'file' runs the setup on first use in each test file. 'worker' runs it once per worker process, which only differs from 'file' when you have turned isolation off, because by default every file gets a fresh worker. Add auto: true to the options when the fixture must run even in files that never destructure it.

Inside the function, the first parameter must be a destructuring pattern, here an empty {}, because Vitest reads it to find the fixture's dependencies. use() hands the value to the tests, and code after await use(...) runs as cleanup once the file's tests are done.

The fixture builds its path with import.meta.dirname rather than the common new URL('./products.json', import.meta.url). Component tests run in the happy-dom project, where Vite rewrites that new URL() pattern into a browser asset URL, so readFile would look for the wrong file.

Cancel a timed-out test's request with the signal

The signal is an AbortSignal for tests that start something that outlives them. It fires when the test times out, when bail stops the run after a failure, or when you press Ctrl+C. Pass it to fetch, and a test that times out also cancels its request instead of leaving it running while the next test starts:

it('loads products from the mock server', async ({ signal }) => {
  const response = await fetch('http://localhost:3001/products', { signal })
  expect(response.ok).toBe(true)
}, 2000)

Rerun tests when a file outside the import graph changes

Vitest reruns tests when a file in their import graph changes. A test that reads locales/en.json from disk, to check that every key a component uses exists, has no import to follow. watchTriggerPatterns maps the change to the test:

vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    watchTriggerPatterns: [
      {
        pattern: /\/locales\/(.*)\.json$/,
        testsToRun: () => 'src/i18n/__tests__/messages.spec.ts',
      },
    ],
  },
})

testsToRun receives the changed file's path and the regex match, and returns one test file, an array of them, or nothing; a relative test path is resolved from the root directory. The changed path is absolute, so match its end: a pattern that starts with ^locales never fires. It is a root-level option; projects cannot set it.

What stays the same, and how to upgrade

The release notes list no breaking changes. Vue Test Utils 2.4 needs no update, mount, emitted and trigger behave as before, and a config without a workspace needs no edit. Nothing in the notes touches snapshots or fake timers.

Upgrade the runner and every companion package together:

npm install -D vitest@3.2 @vitest/coverage-v8@3.2

Add @vitest/ui@3.2 or @vitest/browser@3.2 to the line if you use them. Run the suite once; the only new output should be the workspace deprecation warning if you have one, and that is the moment to rename it. If you want the more accurate V8 coverage now rather than in the next major, set experimentalAstAwareRemapping: true under test.coverage and compare the report with the previous run.

Upgrade now. The release costs an afternoon at most, and annotations pay for it the first time a test fails in CI: their messages print under the failure in the job log, so the context is there before you try to reproduce it locally.

Check it stuck3 questions from the Testing async code and mocks quiz.
Practice now
Was this helpful?

Keep reading