| name | vitest |
| description | Vitest test.projects, browser mode with playwright(), optimizeDeps, separate vitest.config from vite.config. Use when editing vitest config, debugging flaky browser tests, upgrading @vitest/*, or splitting node vs browser projects. |
| compatibility | Written for Vitest 4.1+ (factory browser provider, test.projects). |
| user-invocable | true |
Vitest
- Docs: if unsure about an API, fetch
https://vitest.dev/llms.txt. Do not guess.
- Config file: use
vitest.config.{ts,mts,js,mjs} for test / test.projects. Vitest-only-in-vite.config merges badly with multiple projects and Storybook.
- Gate: no
vitest.config.* but vite.config has test: (or no Vitest file), ask before adding projects or Storybook whether to introduce vitest.config.ts and move test out of Vite.
page.viewport(width, height): import from vitest/browser.
test.projects for mixed node/browser and Storybook. Either extends: true (inherit merged root: Storybook Vitest example with mergeConfig(viteConfig, …)) or extends: './vite.config.ts' per project; put test + plugins on each project (svelte-5:storybook-vitest).
- Browser concurrency:
vitest-browser-svelte/pure when you need no auto-cleanup.
vitest-browser-svelte v3: render is async-only (await render(...)); usage in svelte-5:testing-svelte. v3 gives /pure full export parity with the main entry and exports the RenderResult type.
- Assertions: value, not presence. Presence-only assertions (
toBeInTheDocument, not.toBe(''), toHaveBeenCalled) ship regressions. Every interaction test must compute an expected value from the action and assert equality. See references/assertions.md for the pattern, callback-payload rules, and how to tell a harness failure from a behavior failure.
- Locators: prefer accessible queries, avoid
data-testid. data-testid is an a11y smell (tkdodo, 2025), adding a testid means the element has no accessible name, role, or text for users of AT either. Fix the a11y hole, not the test. Use page.getByRole(...), getByLabelText, getByText, getByPlaceholderText, getByTitle, getByAltText first. They mirror how screen readers find elements and fail when a11y regresses. Reach for data-testid only as a last resort: charting canvases, truly decorative content, or legacy markup you cannot change, and when you do, leave a comment explaining why no accessible selector worked.
- Upgrade: bump all
@vitest/* together.
browser.provider: import { playwright } from '@vitest/browser-playwright' to provider: playwright() (factory, not a string).
- Use
import { page } from 'vitest/browser': not @vitest/browser/context.
- In
test.projects, plugins / resolve / optimizeDeps belong inside each project, not only at root: projects do not inherit root plugins by default (extends: true inherits the merged root).
optimizeDeps.exclude: Svelte 5 runes in .svelte.js (e.g. Melt UI) so vite-plugin-svelte handles them, not Vite's dependency pre-bundler (Rolldown since Vite 8, esbuild before).
optimizeDeps.include: deps that trigger mid-test optimization (e.g. minisearch) to reduce flakes.
- Storybook:
vitest.config.ts + storybookTest + browser playwright(): svelte-5:storybook-vitest, manual setup.
Browser test flake hygiene
Browser/MSW/storybook suites are inherently flaky on a single run. Before any "green" claim:
lsof -ti :<test-server-port>: kill any holder.
rm -rf node_modules/.vite node_modules/.cache/storybook
- Run the test ONCE: capture setup time + counts.
- Run AGAIN: counts must match within ±0 tests, setup time within ±30%.
- Run a THIRD time: same.
If any of (3)(4)(5) diverge: the suite is flaky. Investigate the flake itself (missing optimizeDeps.entries, race in async teardowns, MSW handler order or a missing worker.resetHandlers() in afterEach: svelte-5:msw) BEFORE claiming any fix works.
A single passing run on this kind of suite tells you nothing about whether your fix worked. It tells you only that ONE possible execution order happened to pass.
Test Tags (CI exclusion)
Vitest 4.1+ supports tags for filtering tests at runtime. Tags must be defined in config before use, using an undefined tag throws.
Config (root test block, inherited by all projects):
test: {
tags: [
{ name: 'ci-skip', description: 'needs recorded fixtures or live backend' }
],
}
Test file:
describe('my suite', { tags: ['ci-skip'] }, () => { ... })
it('needs backend', { tags: ['ci-skip'] }, () => { ... })
CLI filter:
vitest --tags-filter='!ci-skip'
vitest --tags-filter='unit || e2e'
vitest --tags-filter='(unit || e2e) && !slow'
Critical: tags only skip the test body, the file is still imported. If the import itself throws (e.g. MSW handlers throwing on missing fixtures: svelte-5:msw), the tag won't help. Module-level code must be import-safe (warn, don't throw).
Critical: pnpm test -- --tags-filter='!ci-skip' does NOT work. The -- makes vitest treat --tags-filter as a positional arg. Use a dedicated script: "test:ci": "vitest --run --tags-filter='!ci-skip'".
CSS Selector Locators (locators.extend)
.locator(selector) is intentionally protected on vitest's Locator type (vitest-dev/vitest#7969). The official escape hatch is locators.extend (since Vitest 3.2):
import { locators } from "vitest/browser";
locators.extend({
css(selector: string) {
return selector;
},
});
declare module "vitest/browser" {
interface LocatorSelectors {
css(selector: string): Locator;
}
}
Wire it via setupFiles in the browser project config. Then .css() is properly typed, no @ts-expect-error needed.
Critical: do NOT name the extend function locator, it shadows the internal protected locator() method and causes infinite recursion (RangeError: Maximum call stack size exceeded). Use css or another name.
Visual Regression Testing
Vitest 4.0+ includes toMatchScreenshot() natively in browser mode. No extra packages needed, it's built into @vitest/browser with the Playwright provider.
Usage
import { expect, test } from "vitest";
import { page } from "vitest/browser";
test("component looks correct", async () => {
await expect(page.getByRole("button", { name: "Submit" })).toMatchScreenshot(
"submit-button",
);
});
- First run: saves reference to
__screenshots__/ next to the test file
- Subsequent runs: compares with pixelmatch, generates
*-actual.png + *-diff.png on failure
- Update baselines:
vitest --update
- Filenames include browser + platform (e.g.
my-component-chromium-darwin.png)
- Animations auto-disabled when using Playwright provider
Config (optional: per-project or global)
test: {
browser: {
expect: {
toMatchScreenshot: {
comparatorName: 'pixelmatch',
comparatorOptions: {
threshold: 0.2,
allowedMismatchedPixelRatio: 0.01,
},
},
},
},
}
Per-test options
await expect(element).toMatchScreenshot("name", {
screenshotOptions: {
mask: [page.getByRole("time")],
},
comparatorOptions: {
allowedMismatchedPixelRatio: 0.01,
},
});
When to use
Use toMatchScreenshot instead of manual Playwright MCP screenshots + eyeballing for CSS/layout verification. It provides programmatic pixel-level diffing with actual diff images, not subjective "looks the same" claims. Works in both browser and storybook test projects.