diff --git a/.github/workflows/cr.yml b/.github/workflows/cr.yml
index 5c4653374c42..42f4ac7e3d90 100644
--- a/.github/workflows/cr.yml
+++ b/.github/workflows/cr.yml
@@ -27,10 +27,10 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- - name: Set node version to 20
+ - name: Set node version to 24
uses: actions/setup-node@v6
with:
- node-version: 20
+ node-version: 24
registry-url: https://registry.npmjs.org/
cache: pnpm
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 00affa8f3c6b..243c7a9856c3 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -29,10 +29,10 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- - name: Set node version to 20
+ - name: Set node version to 24
uses: actions/setup-node@v6
with:
- node-version: 20
+ node-version: 24
registry-url: https://registry.npmjs.org/
# disable cache to avoid cache poisoning
package-manager-cache: false
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 363f27c4a7f0..3e6405af0c24 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -818,30 +818,19 @@ export default ({ mode }: { mode: string }) => {
},
],
},
+ // Authoring — how to express a test in code: constructing it,
+ // asserting, mocking dependencies, attaching metadata. The page is
+ // about *test content*, not the runner. Discriminator: "How do I
+ // write X in a test?" If yes, it belongs here. Mocking sub-pages
+ // live nested because they're a multi-page subtopic.
{
- text: 'Guides',
+ text: 'Authoring',
collapsed: false,
items: [
- {
- text: 'CLI',
- link: '/guide/cli',
- },
- {
- text: 'Test Filtering',
- link: '/guide/filtering',
- },
- {
- text: 'Test Tags',
- link: '/guide/test-tags',
- },
{
text: 'Test Context',
link: '/guide/test-context',
},
- {
- text: 'Test Environment',
- link: '/guide/environment',
- },
{
text: 'Test Run Lifecycle',
link: '/guide/lifecycle',
@@ -890,45 +879,85 @@ export default ({ mode }: { mode: string }) => {
],
},
{
- text: 'Parallelism',
- link: '/guide/parallelism',
- },
- {
- text: 'Test Projects',
- link: '/guide/projects',
+ text: 'Test Tags',
+ link: '/guide/test-tags',
},
{
- text: 'Reporters',
- link: '/guide/reporters',
+ text: 'Test Annotations',
+ link: '/guide/test-annotations',
},
{
- text: 'Coverage',
- link: '/guide/coverage',
+ text: 'Extending Matchers',
+ link: '/guide/extending-matchers',
},
{
text: 'Testing Types',
link: '/guide/testing-types',
},
- {
- text: 'Vitest UI',
- link: '/guide/ui',
- },
{
text: 'In-Source Testing',
link: '/guide/in-source',
},
+ ],
+ },
+ // Workflow — how to invoke, select, and orchestrate test runs
+ // across files/projects/processes. The page is about the *runner
+ // and tooling around it*, not what's inside a test. Discriminator:
+ // "How do I run / filter / parallelize / integrate Vitest?" If a
+ // page is about the runtime environment of the tests themselves
+ // (jsdom, node), it still belongs here — that's a workflow choice.
+ {
+ text: 'Workflow',
+ collapsed: false,
+ items: [
{
- text: 'Test Annotations',
- link: '/guide/test-annotations',
+ text: 'CLI',
+ link: '/guide/cli',
},
{
- text: 'Extending Matchers',
- link: '/guide/extending-matchers',
+ text: 'Test Filtering',
+ link: '/guide/filtering',
+ },
+ {
+ text: 'Test Projects',
+ link: '/guide/projects',
+ },
+ {
+ text: 'Test Environment',
+ link: '/guide/environment',
+ },
+ {
+ text: 'Parallelism',
+ link: '/guide/parallelism',
+ },
+ {
+ text: 'Reporters',
+ link: '/guide/reporters',
+ },
+ {
+ text: 'Vitest UI',
+ link: '/guide/ui',
},
{
text: 'IDE Integration',
link: '/guide/ide',
},
+ ],
+ },
+ // Quality & Debugging — how to verify the test run is healthy and
+ // diagnose it when it isn't. Coverage, perf, leak detection, error
+ // triage, observability. Discriminator: "Is my suite good?" or
+ // "Why did this fail / leak / slow down?" If a page primarily
+ // measures or fixes the suite (rather than authoring or running
+ // it), put it here.
+ {
+ text: 'Quality & Debugging',
+ collapsed: false,
+ items: [
+ {
+ text: 'Coverage',
+ link: '/guide/coverage',
+ },
{
text: 'Debugging',
link: '/guide/debugging',
@@ -937,25 +966,6 @@ export default ({ mode }: { mode: string }) => {
text: 'Common Errors',
link: '/guide/common-errors',
},
- {
- text: 'Migration Guide',
- link: '/guide/migration',
- collapsed: false,
- items: [
- {
- text: 'Migrating to Vitest 4.0',
- link: '/guide/migration#vitest-4',
- },
- {
- text: 'Migrating from Jest',
- link: '/guide/migration#jest',
- },
- {
- text: 'Migrating from Mocha + Chai + Sinon',
- link: '/guide/migration#mocha-chai-sinon',
- },
- ],
- },
{
text: 'Performance',
collapsed: false,
@@ -976,6 +986,62 @@ export default ({ mode }: { mode: string }) => {
},
],
},
+ // Recipes — end-to-end patterns that solve a concrete problem by
+ // combining multiple features. Each entry is titled by the problem
+ // ("Database Transaction per Test"), not the feature. Add a recipe
+ // when a single feature page would over-explain, when the value
+ // comes from composition, or when users would search by intent
+ // rather than by API name.
+ {
+ text: 'Recipes',
+ collapsed: false,
+ items: [
+ {
+ text: 'Database Transaction per Test',
+ link: '/guide/recipes/db-transaction',
+ },
+ {
+ text: 'Cancelling Long-Running Operations Gracefully',
+ link: '/guide/recipes/cancellable',
+ },
+ {
+ text: 'Waiting for Async Conditions',
+ link: '/guide/recipes/wait-for',
+ },
+ {
+ text: 'Type Narrowing in Tests',
+ link: '/guide/recipes/type-narrowing',
+ },
+ {
+ text: 'Custom Assertion Helpers',
+ link: '/guide/recipes/custom-assertions',
+ },
+ {
+ text: 'Watching Non-Imported Files',
+ link: '/guide/recipes/watch-templates',
+ },
+ {
+ text: 'Extending Browser Locators',
+ link: '/guide/recipes/browser-locators',
+ },
+ {
+ text: 'Schema-Driven Assertions',
+ link: '/guide/recipes/schema-matching',
+ },
+ {
+ text: 'Auto-Cleanup with `using`',
+ link: '/guide/recipes/explicit-resources',
+ },
+ {
+ text: 'Per-File Isolation Settings',
+ link: '/guide/recipes/disable-isolation',
+ },
+ {
+ text: 'Parallel and Sequential Test Files',
+ link: '/guide/recipes/parallel-sequential',
+ },
+ ],
+ },
{
text: 'Advanced',
collapsed: false,
@@ -998,12 +1064,31 @@ export default ({ mode }: { mode: string }) => {
},
],
},
+ // Migration — one-time transitional content: cross-version
+ // upgrades and porting from other test runners (Jest, Mocha).
+ // Sits near the bottom because it's not daily-use and would push
+ // active-use guides further from the user's first scroll.
{
+ text: 'Migration',
+ link: '/guide/migration',
+ collapsed: false,
items: [
{
- text: 'Recipes',
- link: '/guide/recipes',
+ text: 'Migrating to Vitest 4.0',
+ link: '/guide/migration#vitest-4',
},
+ {
+ text: 'Migrating from Jest',
+ link: '/guide/migration#jest',
+ },
+ {
+ text: 'Migrating from Mocha + Chai + Sinon',
+ link: '/guide/migration#mocha-chai-sinon',
+ },
+ ],
+ },
+ {
+ items: [
{
text: 'Comparisons',
link: '/guide/comparisons',
diff --git a/docs/api/vi.md b/docs/api/vi.md
index 1f040959df01..656a199a1b2b 100644
--- a/docs/api/vi.md
+++ b/docs/api/vi.md
@@ -1346,7 +1346,7 @@ function resetConfig(): void
If [`vi.setConfig`](#vi-setconfig) was called before, this will reset config to the original state.
-### vi.defineHelper 4.1.0 {#vi-defineHelper}
+### vi.defineHelper 4.1.0 {#vi-definehelper}
```ts
function defineHelper any>(fn: F): F
diff --git a/docs/blog/vitest-3-2.md b/docs/blog/vitest-3-2.md
index 690b8f00612e..38aecaaa7164 100644
--- a/docs/blog/vitest-3-2.md
+++ b/docs/blog/vitest-3-2.md
@@ -219,7 +219,7 @@ export default defineConfig({
test: {
watchTriggerPatterns: [
{
- pattern: /^src\/templates\/(.*)\.(ts|html|txt)$/,
+ pattern: /src\/templates\/(.*)\.(ts|html|txt)$/,
testsToRun: (file, match) => {
return `api/tests/mailers/${match[2]}.test.ts`
},
diff --git a/docs/config/watchtriggerpatterns.md b/docs/config/watchtriggerpatterns.md
index 2041def75738..d341e54633fb 100644
--- a/docs/config/watchtriggerpatterns.md
+++ b/docs/config/watchtriggerpatterns.md
@@ -18,7 +18,7 @@ export default defineConfig({
test: {
watchTriggerPatterns: [
{
- pattern: /^src\/(mailers|templates)\/(.*)\.(ts|html|txt)$/,
+ pattern: /src\/(mailers|templates)\/(.*)\.(ts|html|txt)$/,
testsToRun: (id, match) => {
// relative to the root value
return `./api/tests/mailers/${match[2]}.test.ts`
diff --git a/docs/guide/debugging.md b/docs/guide/debugging.md
index f43b7a5eba33..39b077ff785f 100644
--- a/docs/guide/debugging.md
+++ b/docs/guide/debugging.md
@@ -14,6 +14,8 @@ When debugging tests you might want to use following options:
## VS Code
+The [official VS Code](https://vitest.dev/vscode) extension supports debugging tests via "Debug Tests" button. However Vitest also exposes tools to define a custom configuration.
+
Quick way to debug tests in VS Code is via `JavaScript Debug Terminal`. Open a new `JavaScript Debug Terminal` and run `npm run test` or `vitest` directly. *this works with any code run in Node, so will work with most JS testing frameworks*

@@ -44,7 +46,9 @@ Then in the debug tab, ensure 'Debug Current Test File' is selected. You can the
### Browser mode
-To debug [Vitest Browser Mode](/guide/browser/index.md), pass `--inspect` or `--inspect-brk` in CLI or define it in your Vitest configuration:
+The simplest way to debug browser tests is to use the [official VS Code](https://vitest.dev/vscode) extension.
+
+However you can also pass `--inspect` or `--inspect-brk` in CLI or define it in your Vitest configuration:
::: code-group
```bash [CLI]
diff --git a/docs/guide/improving-performance.md b/docs/guide/improving-performance.md
index af0db788222d..f2af48b7038f 100644
--- a/docs/guide/improving-performance.md
+++ b/docs/guide/improving-performance.md
@@ -160,7 +160,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
- node-version: 20
+ node-version: 24
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
@@ -191,7 +191,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
- node-version: 20
+ node-version: 24
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
diff --git a/docs/guide/migration.md b/docs/guide/migration.md
index 93c10a080a48..5d4e15ea2f29 100644
--- a/docs/guide/migration.md
+++ b/docs/guide/migration.md
@@ -479,7 +479,7 @@ export default defineConfig({
```
:::
-See [Recipes](/guide/recipes) for more examples.
+See [Per-File Isolation Settings](/guide/recipes/disable-isolation) and [Parallel and Sequential Test Files](/guide/recipes/parallel-sequential) for more examples.
### Reporter Updates
diff --git a/docs/guide/recipes.md b/docs/guide/recipes.md
deleted file mode 100644
index 7ab89617db84..000000000000
--- a/docs/guide/recipes.md
+++ /dev/null
@@ -1,63 +0,0 @@
----
-title: Recipes | Guide
----
-
-# Recipes
-
-## Disabling Isolation for Specific Test Files Only
-
-You can speed up your test run by disabling isolation for specific set of files by specifying `isolate` per `projects` entries:
-
-```ts [vitest.config.ts]
-import { defineConfig } from 'vitest/config'
-
-export default defineConfig({
- test: {
- projects: [
- {
- test: {
- // Non-isolated unit tests
- name: 'Unit tests',
- isolate: false,
- exclude: ['**.integration.test.ts'],
- },
- },
- {
- test: {
- // Isolated integration tests
- name: 'Integration tests',
- include: ['**.integration.test.ts'],
- },
- },
- ],
- },
-})
-```
-
-## Parallel and Sequential Test Files
-
-You can split test files into parallel and sequential groups by using `projects` option:
-
-```ts [vitest.config.ts]
-import { defineConfig } from 'vitest/config'
-
-export default defineConfig({
- test: {
- projects: [
- {
- test: {
- name: 'Parallel',
- exclude: ['**.sequential.test.ts'],
- },
- },
- {
- test: {
- name: 'Sequential',
- include: ['**.sequential.test.ts'],
- fileParallelism: false,
- },
- },
- ],
- },
-})
-```
diff --git a/docs/guide/recipes/browser-locators.md b/docs/guide/recipes/browser-locators.md
new file mode 100644
index 000000000000..91e8445c9918
--- /dev/null
+++ b/docs/guide/recipes/browser-locators.md
@@ -0,0 +1,116 @@
+---
+title: Extending Browser Locators | Recipes
+---
+
+# Domain Locators
+
+Built-in [locators](/api/browser/locators) like `getByRole` and `getByText` cover queries that map onto accessibility attributes. They run out when an app has shapes that don't fit ARIA, like a "comment with N replies" or a row in a custom table component.
+
+The fallback is to use `querySelector`. That works, but the result is a plain query rather than a locator, so you lose auto-retry and strict-mode protection.
+
+[`locators.extend`](/api/browser/locators#custom-locators) 3.2.0 adds a domain-specific locator without giving up the locator API. The value the method returns is still a locator, so auto-retry, strict-mode protection, and chaining all carry through to your custom methods. The names you give those methods become part of the team's test vocabulary: `page.getByCard({ id: 'product-1' })` reads like the product instead of the DOM, and the same name shows up consistently across the suite.
+
+## Returning a Playwright string
+
+The simplest form returns a [Playwright locator string](https://playwright.dev/docs/other-locators). Vitest treats the returned string as a child query of whatever locator the method was called on: when called on `page`, the string runs against the entire page; when called on a parent locator, it runs scoped to that parent's subtree.
+
+Reach for this form when the new query has no good expression in built-in locators, like a CSS-with-text selector for a widget that doesn't map onto a built-in role, or an XPath for a legacy component you don't control.
+
+```ts
+import { locators } from 'vitest/browser'
+
+locators.extend({
+ getByCommentsCount(count: number) {
+ return `.comments :text("${count} comments")`
+ },
+})
+```
+
+```ts
+import { expect, test } from 'vitest'
+import { page } from 'vitest/browser'
+
+test('article shows comment count', async () => {
+ await expect.element(page.getByCommentsCount(1)).toBeVisible()
+ await expect.element(
+ page.getByRole('article', { name: 'Hello World' })
+ .getByCommentsCount(1)
+ ).toBeVisible()
+})
+```
+
+## Composing existing locators
+
+When you return a locator instead of a string, Vitest uses that locator directly. Inside the extension, `this` is bound to the locator the method was called on (or to `page` for top-level calls), so you can chain existing locators or apply `filter` to express relationships between elements that no single built-in option captures.
+
+The example below uses `filter({ has })` to narrow a row locator to those that contain a button with a given name, encoding a common per-row-actions pattern as a single named lookup:
+
+```ts
+import { locators } from 'vitest/browser'
+import type { Locator } from 'vitest/browser'
+
+locators.extend({
+ getRowWithAction(this: Locator, action: string) {
+ return this.getByRole('row').filter({
+ has: this.getByRole('button', { name: action }),
+ })
+ },
+})
+```
+
+```ts
+await page.getRowWithAction('Delete').first().click()
+```
+
+Prefer this over the raw-string form when both options can express the query. Built-in locators encode accessibility-aware lookups, and chaining or filtering them preserves those guarantees. Reach for the raw-string form only when no chain of built-ins covers the query, since the string runs whatever selector you wrote and bypasses the locator mechanism you're trying to keep.
+
+## Custom interactions
+
+Methods that perform an interaction instead of returning a locator also work. This is the same mechanism used for shaping your own DSL of user actions, defined alongside your queries so the test vocabulary stays consistent.
+
+`locators.extend` types `this` as `BrowserPage | Locator`, since custom methods are reachable from both. For query helpers that's fine, since `getByRole` and other query methods exist on both. For interaction helpers it isn't: `page` has no `click` or `fill`, so calling `page.clickAndFill('x')` would fail at runtime. Guard against that by comparing `this` against the `page` singleton, which lets TypeScript narrow `this` to `Locator` after the throw:
+
+```ts
+import { locators, page } from 'vitest/browser'
+import type { BrowserPage, Locator } from 'vitest/browser'
+
+locators.extend({
+ async clickAndFill(this: BrowserPage | Locator, text: string) {
+ if (this === page) {
+ throw new TypeError(
+ 'clickAndFill must be called on a locator, like page.getByRole(\'textbox\').clickAndFill(...)',
+ )
+ }
+ await this.click()
+ await this.fill(text)
+ },
+})
+
+await page.getByRole('textbox').clickAndFill('Hello World')
+```
+
+Interaction methods don't compose into selectors. `page.getByRole('textbox').clickAndFill('Hello')` works because `getByRole` returns a locator; `page.clickAndFill('Hello')` would hit the guard. Reach for this form for action helpers, not for query helpers.
+
+## Augmenting locator types
+
+`locators.extend` is a runtime registration. TypeScript doesn't know about the new methods until you augment the [`LocatorSelectors`](/api/browser/locators) interface, usually in a shared `.d.ts` file:
+
+```ts
+import 'vitest/browser'
+
+declare module 'vitest/browser' {
+ interface LocatorSelectors {
+ getByCommentsCount: (count: number) => Locator
+ getRowWithAction: (action: string) => Locator
+ clickAndFill: (text: string) => Promise
+ }
+}
+```
+
+`LocatorSelectors` is the interface that both `Locator` and `BrowserPage` extend, so any method declared on it shows up on both. That matches what `locators.extend` does at runtime, and it's why interaction helpers like `clickAndFill` need the guard above: TypeScript will let `page.clickAndFill('x')` type-check, but the guard catches the misuse before it hits a missing method.
+
+## See also
+
+- [Custom Locators API](/api/browser/locators#custom-locators)
+- [Built-in Locators](/api/browser/locators)
+- [Playwright "other locators"](https://playwright.dev/docs/other-locators)
diff --git a/docs/guide/recipes/cancellable.md b/docs/guide/recipes/cancellable.md
new file mode 100644
index 000000000000..677ad28d296e
--- /dev/null
+++ b/docs/guide/recipes/cancellable.md
@@ -0,0 +1,56 @@
+---
+title: Cancellable Test Resources | Recipes
+---
+
+# Cancellable Test Resources
+
+A test can hold onto resources that don't stop when the test stops. A `fetch`, a child process, a file stream, a polling loop: none of those notice when Vitest has cancelled the test, and the worker has to sit there waiting for them to finish on their own. Vitest cancels a test when it exceeds its `timeout`, when another test fails under `--bail`, or when someone presses Ctrl+C in the terminal.
+
+The test context provides a [`signal`](/guide/test-context#signal) 3.2.0 that fires in all of those cases. Pass it to anything that accepts an `AbortSignal` and the resource is released when Vitest cancels.
+
+## Pattern
+
+```ts
+import { test } from 'vitest'
+
+test('stop request when test times out', async ({ signal }) => {
+ await fetch('/heavy-resource', { signal })
+}, 2000)
+```
+
+If the request hasn't completed within 2 seconds, `fetch` rejects with `AbortError` instead of the test hanging until the operation finishes.
+
+## Other Web APIs that accept an `AbortSignal`
+
+- [`fetch`](https://developer.mozilla.org/docs/Web/API/fetch)
+- [`addEventListener`](https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener), where passing `{ signal }` removes the listener on abort
+- [`ReadableStream.pipeTo`](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeTo)
+- Node.js APIs like [`fs.readFile`](https://nodejs.org/api/fs.html#fspromisesreadfilepath-options), [`child_process.spawn`](https://nodejs.org/api/child_process.html#child_processspawncommand-args-options), and [`setTimeout` or `setInterval`](https://nodejs.org/api/timers.html), all of which accept `{ signal }`
+- Any custom code that calls `signal.throwIfAborted()` or listens for `'abort'`
+
+## Forwarding the signal
+
+Wire the test's signal into your own helpers so cancellation propagates all the way down:
+
+```ts
+async function pollUntilReady(url: string, signal: AbortSignal) {
+ while (!signal.aborted) {
+ const res = await fetch(url, { signal })
+ if (res.ok) {
+ return
+ }
+ await new Promise(r => setTimeout(r, 200))
+ }
+ signal.throwIfAborted()
+}
+
+test('worker becomes ready', async ({ signal }) => {
+ await pollUntilReady('http://localhost:4000/health', signal)
+}, 5000)
+```
+
+## See also
+
+- [`signal` in Test Context](/guide/test-context#signal)
+- [`bail`](/config/bail)
+- [`testTimeout`](/config/testtimeout)
diff --git a/docs/guide/recipes/custom-assertions.md b/docs/guide/recipes/custom-assertions.md
new file mode 100644
index 000000000000..8c04aeba4bf2
--- /dev/null
+++ b/docs/guide/recipes/custom-assertions.md
@@ -0,0 +1,55 @@
+---
+title: Custom Assertion Helpers | Recipes
+---
+
+# Custom Assertion Helpers
+
+Reusable assertion helpers make tests easier to read, at the cost of stack traces. When an assertion fails inside a helper, the trace points at the line inside the helper rather than the test that called it. With the same helper used across many tests, the stack trace alone doesn't identify which call site failed.
+
+[`vi.defineHelper`](/api/vi#vi-defineHelper) 4.1.0 wraps a function so Vitest strips its internals from the stack and points the error back at the call site instead.
+
+## Pattern
+
+```ts
+import { expect, test, vi } from 'vitest'
+
+const assertPair = vi.defineHelper((a: unknown, b: unknown) => {
+ expect(a).toEqual(b) // ❌ failure does NOT point here
+})
+
+test('example', () => {
+ assertPair('left', 'right') // ✅ failure points here
+})
+```
+
+When `assertPair` fails, the diff and stack frame surface the test line that called it. That's the same behaviour built-in matchers give you.
+
+## Composing multiple expectations
+
+The same wrapper works for helpers that bundle several assertions:
+
+```ts
+import { expect, test, vi } from 'vitest'
+
+const expectValidUser = vi.defineHelper((user: unknown) => {
+ expect(user).toHaveProperty('id')
+ expect(user).toHaveProperty('email')
+ expect(user.email).toMatch(/@/)
+})
+
+test('returns a valid user', async () => {
+ const user = await fetchUser('alice')
+ expectValidUser(user)
+})
+```
+
+A failure in any of the inner `expect` calls is reported against the `expectValidUser(user)` line in the test.
+
+Reach for `defineHelper` whenever a reusable check calls `expect` more than once, whether that's a domain-specific helper like `expectValidJWT` or any block of `expect` calls you'd otherwise inline into every test.
+
+For asymmetric matchers and custom matchers attached to `expect.extend`, see [Extending Matchers](/guide/extending-matchers).
+
+## See also
+
+- [`vi.defineHelper`](/api/vi#vi-defineHelper)
+- [Extending Matchers](/guide/extending-matchers)
diff --git a/docs/guide/recipes/db-transaction.md b/docs/guide/recipes/db-transaction.md
new file mode 100644
index 000000000000..c0ae9f20b03d
--- /dev/null
+++ b/docs/guide/recipes/db-transaction.md
@@ -0,0 +1,80 @@
+---
+title: Database Transaction per Test | Recipes
+---
+
+# Database Transaction per Test
+
+Integration tests that touch a real database need to start from a clean state. Truncating tables between every test is slow, so the conventional workaround is to wrap each test in a transaction that's rolled back when it finishes. Nothing ever commits, and there's no per-test cleanup to write.
+
+Vitest exposes this through [`aroundEach`](/api/hooks#aroundeach) 4.1.0 and a [scoped fixture](/guide/test-context#fixture-scopes) 3.2.0.
+
+## Pattern
+
+```ts
+import { test as baseTest } from 'vitest'
+import { createTestDatabase } from './db.ts'
+
+export const test = baseTest
+ .extend('db', { scope: 'file' }, async ({}, { onCleanup }) => {
+ const db = await createTestDatabase()
+ onCleanup(() => db.close())
+ return db
+ })
+
+test.aroundEach(async (runTest, { db }) => {
+ await db.transaction(runTest)
+})
+
+test('insert user', async ({ db }) => {
+ await db.insert({ name: 'Alice' })
+ // rolled back automatically when the test ends
+})
+```
+
+## How it works
+
+The `db` fixture is created once per file via `scope: 'file'`, so connection setup happens once instead of on every test, and `onCleanup` closes the connection when the file is done. `aroundEach` wraps every test in `db.transaction(runTest)`, and anything the test writes gets rolled back when `runTest` resolves. The test receives the same `db` instance through its context, with no awareness that it's running inside a transaction.
+
+This works as long as your database driver supports nested transactions or savepoints, which covers most modern databases. The same `aroundEach` hook can also wrap an [`AsyncLocalStorage`](https://nodejs.org/api/async_context.html#class-asynclocalstorage) context if you want to propagate things like tenant or trace IDs through the test alongside the transaction.
+
+## One connection per worker
+
+If the suite has many files, paying for a fresh database connection on every file adds up. Switching the fixture to `scope: 'worker'` and turning off isolation lets multiple files share a single connection per worker process:
+
+```ts [vitest.config.ts]
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ isolate: false,
+ },
+})
+```
+
+```ts
+import { test as baseTest } from 'vitest'
+import { createTestDatabase } from './db.ts'
+
+export const test = baseTest
+ .extend('db', { scope: 'worker' }, async ({}, { onCleanup }) => {
+ const db = await createTestDatabase()
+ onCleanup(() => db.close())
+ return db
+ })
+
+test.aroundEach(async (runTest, { db }) => {
+ await db.transaction(runTest)
+})
+```
+
+By default, every test file runs in its own worker, so `scope: 'file'` and `scope: 'worker'` behave identically. With `isolate: false`, Vitest reuses workers across files (capped by [`maxWorkers`](/config/maxworkers)), so a worker-scoped fixture is created once per worker instead of once per file. For a suite of 200 files running on 8 workers, that's 8 connections instead of 200.
+
+Reusing workers isn't a free optimization. With isolation off, files share module instances inside the worker, and tests that mutate top-level state (counters, caches, monkey-patched globals) can leak that state to whichever file runs next in the same worker. The per-test rollback handles data isolation in the database. It can't protect module state in the worker. Read the trade-offs in the [Per-File Isolation Settings](/guide/recipes/disable-isolation) recipe before turning isolation off suite-wide.
+
+[`vmThreads` and `vmForks`](/config/pool) always run isolated regardless of the `isolate` flag, so worker-scoped fixtures fall back to per-file behavior in those pools.
+
+## See also
+
+- [`aroundEach` and `aroundAll`](/api/hooks#aroundeach)
+- [Fixture scopes](/guide/test-context#fixture-scopes)
+- [Builder pattern](/guide/test-context#builder-pattern)
diff --git a/docs/guide/recipes/disable-isolation.md b/docs/guide/recipes/disable-isolation.md
new file mode 100644
index 000000000000..03ad3997c4fd
--- /dev/null
+++ b/docs/guide/recipes/disable-isolation.md
@@ -0,0 +1,71 @@
+---
+title: Per-File Isolation Settings | Recipes
+---
+
+# Per-File Isolation Settings
+
+By default, every test file runs in its own isolated module graph, which protects against one file leaking state into another. That isolation costs setup time on every file, which is fine for integration tests that genuinely need it and wasted on pure unit tests that don't share mutable state.
+
+Use [`projects`](/guide/projects) to apply [`isolate: false`](/config/isolate) to the unit suite while keeping the integration suite isolated.
+
+## Pattern
+
+```ts [vitest.config.ts]
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ projects: [
+ {
+ test: {
+ // Non-isolated unit tests
+ name: 'Unit tests',
+ isolate: false,
+ exclude: ['**.integration.test.ts'],
+ },
+ },
+ {
+ test: {
+ // Isolated integration tests
+ name: 'Integration tests',
+ include: ['**.integration.test.ts'],
+ },
+ },
+ ],
+ },
+})
+```
+
+## When isolation matters
+
+A test file is safe to deisolate when it does not:
+
+- mutate module-level state (counters, caches, top-level `let` bindings)
+- call [`vi.stubGlobal`](/api/vi#vi-stubglobal) or [`vi.stubEnv`](/api/vi#vi-stubenv)
+- monkey-patch prototypes (`Date.prototype`, `Array.prototype`, …)
+- register listeners on `process` or other long-lived emitters
+- depend on a fresh module instance for `vi.mock` factories
+
+If any of those apply, isolation is doing real work and should stay on.
+
+## Verifying it's safe
+
+Run the suite twice with shuffling to surface inter-file pollution:
+
+```sh
+vitest --shuffle --run --project='Unit tests'
+vitest --shuffle --run --project='Unit tests'
+```
+
+If the second run produces different results, you have order-dependent tests. Either fix the offender or leave isolation enabled for that file.
+
+## Per-pool isolation
+
+`isolate` only governs the [`threads`](/config/pool) and [`forks`](/config/pool) pools. The `vmThreads` and `vmForks` pools always run isolated regardless of the flag, since they trade startup cost for stronger guarantees.
+
+## See also
+
+- [`isolate`](/config/isolate)
+- [Test Projects](/guide/projects)
+- [Improving Performance](/guide/improving-performance)
+- [Parallel and Sequential Test Files](/guide/recipes/parallel-sequential)
diff --git a/docs/guide/recipes/explicit-resources.md b/docs/guide/recipes/explicit-resources.md
new file mode 100644
index 000000000000..67dc35c12c83
--- /dev/null
+++ b/docs/guide/recipes/explicit-resources.md
@@ -0,0 +1,105 @@
+---
+title: Auto-Cleanup with `using` | Recipes
+---
+
+# Auto-Cleanup with `using`
+
+Spies and mocks need to be restored after the test that installed them, otherwise state leaks between tests. The usual approaches are an `afterEach(() => vi.restoreAllMocks())` at the suite level or a per-test [`onTestFinished(() => spy.mockRestore())`](/api/hooks#ontestfinished) inline.
+
+If your runtime supports [Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management) (Node.js 24+, or via TypeScript 5.2+ in modern bundlers), there's a tighter option: declare the spy with `using` instead of `const`, and restoration happens automatically when the block exits.
+
+This works for [`vi.spyOn`](/api/vi#vi-spyon), [`vi.fn`](/api/vi#vi-fn), and [`vi.doMock`](/api/vi#vi-domock). 3.2.0
+
+## Pattern
+
+```ts
+import { expect, it, vi } from 'vitest'
+
+function debug(message: string) {
+ console.log(message)
+}
+
+it('calls console.log', () => {
+ using spy = vi.spyOn(console, 'log').mockImplementation(() => {})
+ debug('message')
+ expect(spy).toHaveBeenCalled()
+})
+
+// console.log is restored here without an afterEach
+```
+
+The same pattern works with `vi.doMock`, which returns a disposable that queues an unmock when the scope exits:
+
+```ts
+import { expect, it, vi } from 'vitest'
+
+it('uses the mocked module, then the real one', async () => {
+ {
+ using _mock = vi.doMock('./users', () => ({
+ loadUser: () => ({ id: '1', name: 'Alice' }),
+ }))
+ const { loadUser } = await import('./users')
+ expect(loadUser('alice').name).toBe('Alice')
+ }
+
+ // ./users is unmocked from here on
+})
+```
+
+## Scoped to any block
+
+`using` is block-scoped, so you can install a spy for just part of a test. This is the case neither `afterEach` nor `onTestFinished` covers, since both run after the test ends:
+
+```ts
+import { expect, it, vi } from 'vitest'
+
+it('only mocks fetch for the auth call', async () => {
+ // real fetch here
+ await preloadConfig()
+
+ {
+ using fetchSpy = vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValue(new Response('{"ok":true}'))
+
+ await login('alice', 'secret')
+ expect(fetchSpy).toHaveBeenCalledOnce()
+ }
+
+ // real fetch is back
+ await reportSuccess()
+})
+```
+
+This is also a way to avoid turning on the global [`restoreMocks: true`](/config/restoremocks) config when only a handful of calls actually need restoration.
+
+## Compatibility
+
+`using` requires support for the TC39 Explicit Resource Management proposal:
+
+- TypeScript ≥ 5.2 (with `target: 'es2022'` or higher and the `disposable` lib included by default).
+- Node.js ≥ 24 (or Node 22+ with `--harmony`-style flags) for native runtime support.
+
+If your environment doesn't support it yet, the closest equivalent for whole-test cleanup is [`onTestFinished`](/api/hooks#ontestfinished), which registers the cleanup inline and runs after the test completes regardless of pass or failure:
+
+```ts
+import { expect, it, onTestFinished, vi } from 'vitest'
+
+it('calls console.log', () => {
+ const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
+ onTestFinished(() => spy.mockRestore())
+
+ debug('message')
+ expect(spy).toHaveBeenCalled()
+})
+```
+
+`onTestFinished` can't tear down a spy mid-test the way `using` can, so the block-scoped pattern above remains specific to ERM.
+
+## See also
+
+- [`vi.spyOn`](/api/vi#vi-spyon)
+- [`vi.fn`](/api/vi#vi-fn)
+- [`vi.doMock`](/api/vi#vi-domock)
+- [`onTestFinished`](/api/hooks#ontestfinished)
+- [`restoreMocks`](/config/restoremocks)
+- [TC39 Explicit Resource Management proposal](https://github.com/tc39/proposal-explicit-resource-management)
diff --git a/docs/guide/recipes/parallel-sequential.md b/docs/guide/recipes/parallel-sequential.md
new file mode 100644
index 000000000000..f8dd5a470b3e
--- /dev/null
+++ b/docs/guide/recipes/parallel-sequential.md
@@ -0,0 +1,89 @@
+---
+title: Parallel and Sequential Test Files | Recipes
+---
+
+# Parallel and Sequential Test Files
+
+Most test files are independent and run faster in parallel. The exception is the handful that share an exclusive resource, like a fixed port, a writable temp directory, or a database without per-test isolation. Those files flake when other tests run concurrently with them.
+
+Disabling parallelism globally would slow down every test in the suite. Splitting the suite into two [`projects`](/guide/projects), one parallel and one sequential, lets only the affected files pay the cost.
+
+## Pattern
+
+```ts [vitest.config.ts]
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ projects: [
+ {
+ test: {
+ name: 'Parallel',
+ exclude: ['**.sequential.test.ts'],
+ },
+ },
+ {
+ test: {
+ name: 'Sequential',
+ include: ['**.sequential.test.ts'],
+ fileParallelism: false,
+ },
+ },
+ ],
+ },
+})
+```
+
+[`fileParallelism: false`](/config/fileparallelism) at the project level keeps the rest of your suite running concurrently while the matched files run one at a time. It's a shorthand for [`maxWorkers: 1`](/config/maxworkers); the two settings are equivalent.
+
+## Run sequential after parallel
+
+By default, projects run in parallel with each other, so the sequential project's first file may overlap with parallel files that still hold the same resource. Use [`sequence.groupOrder`](/config/sequence#sequence-grouporder) 3.2.0 to force the parallel batch to finish first:
+
+```ts [vitest.config.ts]
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ projects: [
+ {
+ test: {
+ name: 'Parallel',
+ exclude: ['**.sequential.test.ts'],
+ sequence: { groupOrder: 0 },
+ },
+ },
+ {
+ test: {
+ name: 'Sequential',
+ include: ['**.sequential.test.ts'],
+ fileParallelism: false,
+ sequence: { groupOrder: 1 },
+ },
+ },
+ ],
+ },
+})
+```
+
+The parallel batch finishes, *then* the sequential batch starts. Total wall clock stays close to the parallel time plus sum of the sequential test run time.
+
+## File scope vs. test scope
+
+There are two different "parallel" knobs in Vitest. Don't confuse them:
+
+| Scope | Knob | Controls |
+| --- | --- | --- |
+| Across files | [`fileParallelism`](/config/fileparallelism) | Whether two test *files* run in parallel workers |
+| Within a file | `describe.concurrent` / `test.concurrent` | Whether tests *inside one file* run concurrently |
+
+`fileParallelism: false` doesn't make tests inside a file concurrent; tests inside a file are sequential by default. And `concurrent` on a `describe` or `test` doesn't affect how files are scheduled.
+
+## See also
+
+- [`fileParallelism`](/config/fileparallelism)
+- [`maxWorkers`](/config/maxworkers)
+- [`sequence.groupOrder`](/config/sequence#sequence-grouporder)
+- [Parallelism](/guide/parallelism)
+- [Test Projects](/guide/projects)
+- [Per-File Isolation Settings](/guide/recipes/disable-isolation)
diff --git a/docs/guide/recipes/schema-matching.md b/docs/guide/recipes/schema-matching.md
new file mode 100644
index 000000000000..b22ace771f70
--- /dev/null
+++ b/docs/guide/recipes/schema-matching.md
@@ -0,0 +1,90 @@
+---
+title: Schema-Driven Assertions | Recipes
+---
+
+# Schema-Driven Assertions
+
+If your project already validates data with [Zod](https://zod.dev), [Valibot](https://valibot.dev), or [ArkType](https://arktype.io), those schemas already describe what a valid value looks like. Reusing them in tests is more direct than duplicating shape checks across `toEqual` and `toMatchObject`.
+
+[`expect.schemaMatching`](/api/expect#expect-schemamatching) 4.0.0 is an asymmetric matcher that takes any [Standard Schema v1](https://standardschema.dev) object and passes if the value conforms to it.
+
+## Pattern
+
+```ts
+import { expect, test } from 'vitest'
+import { z } from 'zod'
+
+test('email validation', () => {
+ const user = { email: 'john@example.com' }
+
+ expect(user).toEqual({
+ email: expect.schemaMatching(z.string().email()),
+ })
+})
+```
+
+`expect.schemaMatching` is an asymmetric matcher, so it composes inside any equality check the same way `expect.any` or `expect.stringMatching` do:
+
+- `toEqual` / `toStrictEqual`
+- `toMatchObject`
+- `toContainEqual`
+- `toThrow`
+- `toHaveBeenCalledWith`
+- `toHaveReturnedWith`
+- `toHaveBeenResolvedWith`
+
+## Works with any Standard Schema library
+
+```ts
+import { expect, test } from 'vitest'
+import { z } from 'zod'
+import * as v from 'valibot'
+import { type } from 'arktype'
+
+const user = { email: 'john@example.com' }
+
+// Zod
+expect(user).toEqual({
+ email: expect.schemaMatching(z.string().email()),
+})
+
+// Valibot
+expect(user).toEqual({
+ email: expect.schemaMatching(v.pipe(v.string(), v.email())),
+})
+
+// ArkType
+expect(user).toEqual({
+ email: expect.schemaMatching(type('string.email')),
+})
+```
+
+## Verifying call arguments
+
+A common use is asserting that a mock was called with data that conforms to a schema, without spelling out every field:
+
+```ts
+import { expect, test, vi } from 'vitest'
+import { z } from 'zod'
+
+const UserSchema = z.object({
+ id: z.string().uuid(),
+ email: z.string().email(),
+ createdAt: z.date(),
+})
+
+test('persists a valid user', () => {
+ const repo = { save: vi.fn() }
+ registerUser(repo, { email: 'a@b.com' })
+
+ expect(repo.save).toHaveBeenCalledWith(expect.schemaMatching(UserSchema))
+})
+```
+
+Reach for `schemaMatching` when you already have a schema for the value and would otherwise spell out every property by hand. It's especially useful for assertions over generated fields like UUIDs or timestamps, where you can validate the format without predicting the exact value.
+
+## See also
+
+- [`expect.schemaMatching`](/api/expect#expect-schemamatching)
+- [Standard Schema](https://standardschema.dev)
+- [Asymmetric Matchers](/api/expect)
diff --git a/docs/guide/recipes/type-narrowing.md b/docs/guide/recipes/type-narrowing.md
new file mode 100644
index 000000000000..9fea33ef53c7
--- /dev/null
+++ b/docs/guide/recipes/type-narrowing.md
@@ -0,0 +1,64 @@
+---
+title: Type Narrowing in Tests | Recipes
+---
+
+# Type Narrowing in Tests
+
+Tests deal with possibly-null values everywhere. `document.querySelector` returns `Element | null`, `Map.get(key)` returns `T | undefined`, and similar optional shapes show up throughout. The usual workarounds in test code are an unsafe cast with `as`, a non-null assertion with `!` on every access, or a runtime check like `expect(x).toBeTruthy()` that throws when the value is missing. All three add noise, and the runtime check is actively misleading because it doesn't narrow the type the way it looks like it should.
+
+[`expect.assert`](/api/expect#assert) 4.0.0 throws at runtime and narrows the TypeScript type. The same call replaces all three.
+
+## Pattern
+
+```ts
+import { expect, test } from 'vitest'
+
+test('reads stored user', () => {
+ const cache = new Map()
+ cache.set('alice', { id: '1', name: 'Alice' })
+
+ const user = cache.get('alice') // typed as `{ id, name } | undefined`
+ expect.assert(user) // throws if undefined, narrows below
+ expect(user.name).toBe('Alice') // no `!`, no `as`, type is `{ id, name }`
+})
+```
+
+The same shape collapses any "look up a value, check it exists, then use it" sequence:
+
+```ts
+const job = queue.find(j => j.id === 'build-42') // Job | undefined
+expect.assert(job)
+job.cancel() // narrowed to Job
+```
+
+## Why `toBeTruthy` doesn't narrow
+
+`expect(x).toBeTruthy()` and `expect(x).toBeDefined()` throw at runtime when the value is missing, so the test fails the way you want. They don't narrow the type, though, because their TypeScript signature returns `void` rather than the special `asserts` form.
+
+`expect.assert` is typed as an assertion function, so the same call serves both jobs.
+
+## Narrowing beyond null
+
+`expect.assert` accepts any boolean expression and applies the same narrowing TypeScript would do for an `if` branch. That covers `typeof` and `instanceof` checks:
+
+```ts
+expect.assert(typeof input === 'string')
+input.toUpperCase() // input is `string`
+
+expect.assert(error instanceof MyError)
+expect(error.code).toBe('E_FOO') // error is `MyError`
+```
+
+For common shapes there are pre-built helpers from chai's [`assert` API](/api/assert), reachable via the same `expect.assert` namespace:
+
+```ts
+expect.assert.isDefined(maybeUser) // narrows away `undefined`
+expect.assert.isString(input) // narrows to string
+expect.assert.instanceOf(error, MyError) // narrows to MyError
+```
+
+## See also
+
+- [`expect.assert`](/api/expect#assert)
+- [Chai `assert` API](/api/assert)
+- [Waiting for Async Conditions](/guide/recipes/wait-for)
diff --git a/docs/guide/recipes/wait-for.md b/docs/guide/recipes/wait-for.md
new file mode 100644
index 000000000000..4c5e5291bec8
--- /dev/null
+++ b/docs/guide/recipes/wait-for.md
@@ -0,0 +1,99 @@
+---
+title: Waiting for Async Conditions | Recipes
+---
+
+# Waiting for Async Conditions
+
+Plenty of things in tests don't happen synchronously. A server takes a moment to boot, or a DOM element renders after a microtask. Waiting with `setTimeout` tends to land on either a flaky undershoot or a wasteful long sleep, and a manual polling loop is more code than you want to write per test.
+
+Vitest provides helpers that poll on your behalf, retrying on a fixed interval until the condition holds or a timeout elapses.
+
+## `expect.poll`: retry an assertion
+
+Use [`expect.poll`](/api/expect#poll) when the wait condition is an assertion. The callback returns the value to assert on, the matcher does the comparison, and Vitest retries the whole expression at each interval until the matcher passes.
+
+```ts
+import { expect, test } from 'vitest'
+import { createServer } from './server.ts'
+
+test('server starts', async () => {
+ const server = createServer()
+
+ await expect.poll(() => server.isReady, {
+ timeout: 500,
+ interval: 20
+ }).toBe(true)
+})
+```
+
+The failure message is the standard `expect` diff, with no manual `throw new Error('Server not started')` to maintain. This is the right tool for most "wait for X to become Y" cases.
+
+`expect.poll` makes every assertion asynchronous, so the call must be awaited. Some matchers don't pair with it: snapshot matchers (which would always succeed under polling), `.resolves` and `.rejects` (the condition is already awaited), and `toThrow` (the value is resolved before the matcher sees it). For any of those, reach for `vi.waitFor` instead.
+
+## `vi.waitFor`: wait and capture the value
+
+[`vi.waitFor`](/api/vi#vi-waitfor) is the right tool when the wait condition is the work itself succeeding rather than an assertion you write. It runs the callback at each interval; a thrown error queues another attempt, and the first call that doesn't throw resolves the wait with whatever the callback returned.
+
+```ts
+import { expect, test, vi } from 'vitest'
+import { connect, DB_URL } from './db.ts'
+
+test('database is reachable', async () => {
+ // `connect` throws ECONNREFUSED until the database accepts connections
+ const client = await vi.waitFor(() => connect(DB_URL), {
+ timeout: 5000,
+ interval: 100,
+ })
+
+ const rows = await client.query('SELECT 1 AS ok')
+ expect(rows[0].ok).toBe(1)
+})
+```
+
+The throw that drives the retry comes from `connect` itself, not from an `expect` you wrote inside the callback. `expect.poll` doesn't fit this shape because it's built around assertions, and "retry until this call stops throwing and hand me the result" isn't an assertion. Wrapping the call in a `try`/`catch` to fake one would either duplicate the work after the wait or require building the retry loop by hand.
+
+## `vi.waitUntil`: poll until truthy, fail fast on errors
+
+Use [`vi.waitUntil`](/api/vi#vi-waituntil) for a value lookup where any thrown error should fail the test on the spot rather than be retried away. Each interval calls the callback again. A truthy return resolves the wait; a falsy return waits for the next interval. A thrown error fails the test immediately.
+
+```ts
+import { expect, test, vi } from 'vitest'
+import { jobResults, startJob } from './worker.ts'
+
+test('worker completes the job', async () => {
+ startJob('build-42')
+
+ const result = await vi.waitUntil(
+ () => jobResults.get('build-42'),
+ { timeout: 5000, interval: 100 },
+ )
+
+ expect(result.status).toBe('ok')
+ expect(result.steps).toHaveLength(4)
+})
+```
+
+`jobResults.get('build-42')` returns `JobResult | undefined`. `waitUntil` polls until it returns a truthy value, narrows the resolved type to `JobResult`, and hands it back for further assertions. If the lookup itself throws because of a programming error like a typo in the import, `waitUntil` surfaces the error on the first attempt rather than retrying past it.
+
+In browser mode, prefer [`page.locator`](/api/browser/locators) and [`expect.element`](/api/browser/assertions) over `waitUntil` for DOM queries: locators retry on their own and produce richer failure messages.
+
+## Picking between them
+
+| | `expect.poll` | `vi.waitFor` | `vi.waitUntil` |
+| --- | --- | --- | --- |
+| Reach for it when | the wait is an assertion | the work might fail until it's ready | a lookup might be falsy and that's fine |
+| Retries on thrown error | yes | yes | no, fails fast |
+| Resolves with | the assertion | callback's return value | callback's return value |
+
+Each of these accepts `{ timeout, interval }` options, defaulting to a 1000 ms timeout and 50 ms intervals. `vi.waitFor` and `vi.waitUntil` also accept a number in place of the options object as shorthand for the timeout.
+
+## Fake timers
+
+If [`vi.useFakeTimers`](/api/vi#vi-usefaketimers) is active, `vi.waitFor` automatically calls `vi.advanceTimersByTime(interval)` between attempts. That keeps `setTimeout`-based code under test reachable without leaking real time into the test.
+
+## See also
+
+- [`expect.poll`](/api/expect#poll)
+- [`vi.waitFor`](/api/vi#vi-waitfor)
+- [`vi.waitUntil`](/api/vi#vi-waituntil)
+- [`vi.useFakeTimers`](/api/vi#vi-usefaketimers)
diff --git a/docs/guide/recipes/watch-templates.md b/docs/guide/recipes/watch-templates.md
new file mode 100644
index 000000000000..49c1abd02e53
--- /dev/null
+++ b/docs/guide/recipes/watch-templates.md
@@ -0,0 +1,64 @@
+---
+title: Watching Non-Imported Files | Recipes
+---
+
+# Watching Non-Imported Files
+
+In watch mode, Vitest tracks the import graph: when you change a file, every test whose imports reach that file reruns. That covers most cases. It misses tests that depend on files they don't `import`, like email templates loaded with `fs.readFile`, JSON fixtures parsed at runtime, HTML or CSS pulled in by a build step, or generated artifacts the tests assert against. Editing one of those files leaves the related tests stale, and the watch loop has no way to know.
+
+[`watchTriggerPatterns`](/config/watchtriggerpatterns) 3.2.0 makes these dependencies explicit. You declare a regex over file paths and a callback that returns which tests to rerun when a matching file changes.
+
+## Pattern
+
+```ts [vitest.config.ts]
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ watchTriggerPatterns: [
+ {
+ pattern: /src\/templates\/(.*)\.(ts|html|txt)$/,
+ testsToRun: (file, match) => {
+ // edit `src/templates/welcome.html` ⇒ rerun `api/tests/mailers/welcome.test.ts`
+ return `api/tests/mailers/${match[1]}.test.ts`
+ },
+ },
+ ],
+ },
+})
+```
+
+`testsToRun` returns one or more test file paths to rerun (as a string or string array), or `undefined` if no tests should rerun. Paths are resolved against the workspace root and are not interpreted as globs. `match` is the result of `RegExp.exec` against the changed file.
+
+## Variations
+
+Multiple patterns can coexist. The first below derives the test path from the directory of the changed file; the second maps a single shared fixture to a fixed list of test files:
+
+```ts [vitest.config.ts]
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ watchTriggerPatterns: [
+ {
+ pattern: /src\/(.*)\/schema\.json$/,
+ testsToRun: (_file, match) => `src/${match[1]}/__tests__/index.test.ts`,
+ },
+ {
+ pattern: /test\/shared-fixture\.json$/,
+ testsToRun: () => [
+ 'test/integration/users.test.ts',
+ 'test/integration/billing.test.ts',
+ ],
+ },
+ ],
+ },
+})
+```
+
+[`forceRerunTriggers`](/config/forcereruntriggers) covers the same general gap, except it reruns every test on every match. `watchTriggerPatterns` reruns only the tests you map for a given pattern, which keeps the watch loop fast.
+
+## See also
+
+- [`watchTriggerPatterns`](/config/watchtriggerpatterns)
+- [`forceRerunTriggers`](/config/forcereruntriggers)
diff --git a/docs/guide/test-tags.md b/docs/guide/test-tags.md
index 106455c7b798..eea36179c006 100644
--- a/docs/guide/test-tags.md
+++ b/docs/guide/test-tags.md
@@ -7,9 +7,28 @@ outline: deep
[`Tags`](/config/tags) let you label tests so you can filter what runs and override their options when needed.
+## Why tags
+
+Tags become useful once a suite has groups of tests that share runner options, like a longer timeout for database queries or retries for integration tests on CI. Repeating those options on every relevant test by hand is brittle, and the categories often don't line up with file paths anyway, so splitting them out by file isn't an option. Flaky tests in particular tend to accumulate wherever the bugs landed, not in a `flaky/` folder.
+
+A tag captures that kind of category: the definition holds the shared options, and any test marked with the tag inherits them. Those tag names can also be combined into expressions: `--tags-filter='db && !flaky'` runs database tests that aren't marked flaky. [`TestRunner.matchesTags`](#checking-tags-filter-at-runtime) exposes the same expression at runtime, useful when `globalSetup` does expensive work that should be skipped if no tagged tests are scheduled.
+
+## When to reach for tags
+
+| If you want to… | Use |
+| --- | --- |
+| Apply timeout/retry to a *category* of tests | **Tags** |
+| Mark cross-cutting categories (`flaky`, `slow`, `frontend`) scattered across many files | **Tags** |
+| Conditionally run expensive setup based on what's filtered | **Tags** + [`matchesTags`](#checking-tags-filter-at-runtime) |
+| Run a subset by test name match | [`-t` / `testNamePattern`](/config/testnamepattern) |
+| Run a subset by file path | `--include` / `--exclude` |
+| Run different files with different *runner settings* (isolation, pool, environment) | [Test Projects](/guide/projects) |
+
+You can combine projects and tags. A test that sits in a `Sequential` project can also carry a `flaky` tag, and Vitest applies both.
+
## Defining Tags
-Tags must be defined in your configuration file — Vitest does not provide any built-in tags. If a test uses a tag that isn't defined in the config, the test runner will throw an error. This prevents unexpected behavior from mistyped tag names. You can disable this check with the [`strictTags`](/config/stricttags) option.
+Tags must be defined in your configuration file. By default, Vitest does not provide any built-in tags. If a test uses a tag that isn't defined in the config, the test runner will throw an error. This prevents unexpected behavior from mistyped tag names. You can disable this check with the [`strictTags`](/config/stricttags) option.
You must define a `name` of the tag, and you may define additional options that will be applied to every test marked with the tag, e.g., a `timeout`, or `retry`. For the full list of available options, see [`tags`](/config/tags).
@@ -44,24 +63,6 @@ export default defineConfig({
})
```
-::: warning
-If several tags have the same options and are used on the same test, they will be resolved in the order they were specified, or sorted by priority first (the lower the number, the higher the priority). Tags without a defined priority are merged first and will be overridden by higher priority ones:
-
-```ts
-test('flaky database test', { tags: ['flaky', 'db'] })
-// { timeout: 30_000, retry: 3 }
-```
-
-Note that the `timeout` is 30 seconds (and not 60) because `flaky` tag has a priority of `1` while `db` (that defines 60 second timeout) has no priority.
-
-If test defines its own options, they will have the highest priority:
-
-```ts
-test('flaky database test', { tags: ['flaky', 'db'], timeout: 120_000 })
-// { timeout: 120_000, retry: 3 }
-```
-:::
-
If you are using TypeScript, you can enforce what tags are available by augmenting the `TestTags` type with a property that contains a union of strings (make sure this file is included by your `tsconfig`):
```ts [vitest.shims.ts]
@@ -119,6 +120,24 @@ To print it in JSON, pass down `--list-tags=json`:
}
```
+### Resolving option conflicts
+
+If several tags define the same option and are applied to the same test, they are resolved by `priority` first (lower number wins), then by the order they appear in the test's `tags` array. Tags without a `priority` are merged first and overridden by higher-priority ones:
+
+```ts
+test('flaky database test', { tags: ['flaky', 'db'] })
+// { timeout: 30_000, retry: 3 }
+```
+
+The `timeout` is 30 seconds (not 60) because `flaky` has priority `1` while `db` has no priority.
+
+Options defined on the test itself always win:
+
+```ts
+test('flaky database test', { tags: ['flaky', 'db'], timeout: 120_000 })
+// { timeout: 120_000, retry: 3 }
+```
+
## Using Tags in Tests
You can apply tags to individual tests or entire suites using the `tags` option:
@@ -303,7 +322,7 @@ vitest --tags-filter="unit || e2e" --tags-filter="!slow"
### Checking Tags Filter at Runtime
-You can use `TestRunner.matchesTags` (since Vitest 4.1.1) to check whether the current tags filter matches a set of tags. This is useful for conditionally running expensive setup logic only when relevant tests are included:
+You can use `TestRunner.matchesTags` to check whether the current tags filter matches a set of tags. This is useful for conditionally running expensive setup logic only when relevant tests are included:
```ts
import { beforeAll, TestRunner } from 'vitest'
@@ -317,3 +336,9 @@ beforeAll(async () => {
```
The method accepts an array of tags and returns `true` if the current `--tags-filter` would include a test with those tags. If no tags filter is active, it always returns `true`.
+
+## See also
+
+- [Per-File Isolation Settings](/guide/recipes/disable-isolation) and [Parallel and Sequential Test Files](/guide/recipes/parallel-sequential) use projects to partition tests by file. Reach for projects when categories need different runner settings rather than different timeouts or retries.
+- [Test Filtering](/guide/filtering) covers `-t`, `--include`, and the rest of the CLI filters.
+- [`tags`](/config/tags) and [`strictTags`](/config/stricttags) configuration reference.