From 75e8216f332823cb44f24d4ef7f6c39c0f1a2c4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Wed, 18 Mar 2026 19:25:14 +0200 Subject: [PATCH 1/5] docs(recipes): missing `test` property (#9902) --- docs/guide/recipes.md | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/docs/guide/recipes.md b/docs/guide/recipes.md index 7732006ed02a..7ab89617db84 100644 --- a/docs/guide/recipes.md +++ b/docs/guide/recipes.md @@ -15,15 +15,19 @@ export default defineConfig({ test: { projects: [ { - // Non-isolated unit tests - name: 'Unit tests', - isolate: false, - exclude: ['**.integration.test.ts'], + test: { + // Non-isolated unit tests + name: 'Unit tests', + isolate: false, + exclude: ['**.integration.test.ts'], + }, }, { - // Isolated integration tests - name: 'Integration tests', - include: ['**.integration.test.ts'], + test: { + // Isolated integration tests + name: 'Integration tests', + include: ['**.integration.test.ts'], + }, }, ], }, @@ -41,13 +45,17 @@ export default defineConfig({ test: { projects: [ { - name: 'Parallel', - exclude: ['**.sequential.test.ts'], + test: { + name: 'Parallel', + exclude: ['**.sequential.test.ts'], + }, }, { - name: 'Sequential', - include: ['**.sequential.test.ts'], - fileParallelism: false, + test: { + name: 'Sequential', + include: ['**.sequential.test.ts'], + fileParallelism: false, + }, }, ], }, From a2bfc0c2cbd4ccfd49a5d827177562ca22cd66ff Mon Sep 17 00:00:00 2001 From: Shaneth Dehipitiya Date: Wed, 18 Mar 2026 15:29:17 -0400 Subject: [PATCH 2/5] docs(browser): explain locator differences from testing-library (#9903) Co-authored-by: Vladimir Sheremet --- docs/api/browser/locators.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/api/browser/locators.md b/docs/api/browser/locators.md index fbcd838bd7f0..a4da5a8cce80 100644 --- a/docs/api/browser/locators.md +++ b/docs/api/browser/locators.md @@ -13,6 +13,29 @@ The locator API uses a fork of [Playwright's locators](https://playwright.dev/do This page covers API usage. To better understand locators and their usage, read [Playwright's "Locators" documentation](https://playwright.dev/docs/locators). ::: +::: tip Difference from `testing-library` +Vitest's `page.getBy*` methods return a locator object, not a DOM element. This makes locator queries composable and allows Vitest to retry interactions and assertions when needed. + +Compared to testing-library queries: + +- Use locator chaining (`.getBy*`, `.filter`, `.nth`) instead of `within(...)`. +- Keep locators around and interact with them later (`await locator.click()`), instead of resolving elements up front. +- Single-element escape hatches like `.element()` and `.query()` are strict and throw if multiple elements match. + +```ts +import { expect } from 'vitest' +import { page } from 'vitest/browser' + +const deleteButton = page + .getByRole('row') + .filter({ hasText: 'Vitest' }) + .getByRole('button', { name: /delete/i }) + +await deleteButton.click() +await expect.element(deleteButton).toBeEnabled() +``` +::: + ## getByRole ```ts From ae5ec03ef98097aed2851f287a76d595582fe578 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Mar 2026 04:49:57 +0900 Subject: [PATCH 3/5] fix: properly re-evaluate actual modules of mocked external (#9898) --- .../moduleRunner/startVitestModuleRunner.ts | 2 +- pnpm-lock.yaml | 8 + test/cli/deps/dep-simple/index.js | 1 + test/cli/deps/dep-simple/package.json | 6 + test/cli/package.json | 1 + test/cli/test/mocking.test.ts | 149 ++++++++++++++++++ 6 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 test/cli/deps/dep-simple/index.js create mode 100644 test/cli/deps/dep-simple/package.json diff --git a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts index 33ce48443729..22d7eb6c9988 100644 --- a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts +++ b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts @@ -135,7 +135,7 @@ export function startVitestModuleRunner(options: ContextModuleRunnerOptions): Vi // if module is invalidated, the worker will be recreated, // so cached is always true in a single worker - if (options?.cached) { + if (!isImportActual && options?.cached) { return { cache: true } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index badb1d146786..a907a964e130 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1311,6 +1311,9 @@ importers: test-dep-invalid: specifier: link:./deps/dep-invalid version: link:deps/dep-invalid + test-dep-simple: + specifier: file:./deps/dep-simple + version: file:test/cli/deps/dep-simple tinyspy: specifier: 'catalog:' version: 4.0.4 @@ -9782,6 +9785,9 @@ packages: test-dep-error@file:test/browser/deps/test-dep-error: resolution: {directory: test/browser/deps/test-dep-error, type: directory} + test-dep-simple@file:test/cli/deps/dep-simple: + resolution: {directory: test/cli/deps/dep-simple, type: directory} + text-decoder@1.1.1: resolution: {integrity: sha512-8zll7REEv4GDD3x4/0pW+ppIxSNs7H1J10IKFZsuOMscumCdM2a+toDGLPA3T+1+fLBql4zbt5z83GEQGGV5VA==} @@ -19163,6 +19169,8 @@ snapshots: test-dep-error@file:test/browser/deps/test-dep-error: {} + test-dep-simple@file:test/cli/deps/dep-simple: {} + text-decoder@1.1.1: dependencies: b4a: 1.6.4 diff --git a/test/cli/deps/dep-simple/index.js b/test/cli/deps/dep-simple/index.js new file mode 100644 index 000000000000..0d74b5ea77ed --- /dev/null +++ b/test/cli/deps/dep-simple/index.js @@ -0,0 +1 @@ +export default 'test-dep-simple' diff --git a/test/cli/deps/dep-simple/package.json b/test/cli/deps/dep-simple/package.json new file mode 100644 index 000000000000..bb33afe54b28 --- /dev/null +++ b/test/cli/deps/dep-simple/package.json @@ -0,0 +1,6 @@ +{ + "name": "test-dep-simple", + "type": "module", + "private": true, + "exports": "./index.js" +} diff --git a/test/cli/package.json b/test/cli/package.json index 41977b9ff895..714a061ef545 100644 --- a/test/cli/package.json +++ b/test/cli/package.json @@ -30,6 +30,7 @@ "obug": "^2.1.1", "playwright": "catalog:", "test-dep-invalid": "link:./deps/dep-invalid", + "test-dep-simple": "file:./deps/dep-simple", "tinyspy": "catalog:", "typescript": "catalog:", "unplugin-swc": "^1.5.9", diff --git a/test/cli/test/mocking.test.ts b/test/cli/test/mocking.test.ts index 0603417436c2..65dea4c0720b 100644 --- a/test/cli/test/mocking.test.ts +++ b/test/cli/test/mocking.test.ts @@ -391,3 +391,152 @@ test('mock works without loading original', () => { } `) }) + +test.for([ + 'node', + 'playwright', + 'webdriverio', +])('repeating mock, importActual, and resetModules (%s)', async (mode) => { + const { stderr, errorTree } = await runInlineTests({ + // external + './external.test.ts': ` +import { expect, test, vi } from "vitest" + +test("external", async () => { + vi.doMock(import("test-dep-simple"), async (importActual) => { + const lib = await importActual(); + return lib; + }) + const lib1: any = await import("test-dep-simple") + expect(lib1.default).toBe("test-dep-simple") + + vi.resetModules(); + vi.doMock(import("test-dep-simple"), async (importActual) => { + const lib = await importActual(); + return lib; + }) + const lib2: any = await import("test-dep-simple") + expect(lib2.default).toBe("test-dep-simple") + expect.soft(lib1 !== lib2).toBe(true) + + vi.resetModules(); + vi.doMock(import("test-dep-simple"), async () => ({ mocked: true })); + const lib3 = await import("test-dep-simple"); + expect(lib3).toMatchObject({ mocked: true }) + + const lib4 = await vi.importActual("test-dep-simple"); + expect(lib4.default).toBe("test-dep-simple") + const lib5 = await vi.importActual("test-dep-simple"); + expect(lib4).toBe(lib5) +}); + `, + // builtin module + './builtin.test.ts': ` +import { expect, test, vi } from "vitest" + +test("builtin", async () => { + vi.doMock(import("node:path"), async (importActual) => { + const lib = await importActual(); + return lib; + }) + const lib1: any = await import("node:path") + expect(lib1).toHaveProperty('join') + + vi.resetModules(); + vi.doMock(import("node:path"), async (importActual) => { + const lib = await importActual(); + return lib; + }) + const lib2: any = await import("node:path") + expect(lib2).toHaveProperty('join') + expect.soft(lib1 !== lib2).toBe(true) + + vi.resetModules(); + vi.doMock(import("node:path"), async () => ({ mocked: true })); + const lib3 = await import("node:path"); + expect(lib3).toMatchObject({ mocked: true }) + + const lib4 = await vi.importActual("node:path"); + expect(lib4).toHaveProperty('join') + const lib5 = await vi.importActual("node:path"); + expect(lib4).toBe(lib5) +}); + `, + // local module + './local.test.ts': ` +import { expect, test, vi } from "vitest" + +test("local", async () => { + vi.doMock(import("./local.js"), async (importActual) => { + const lib = await importActual(); + return lib; + }) + const lib1: any = await import("./local.js") + expect(lib1).toHaveProperty('local') + + vi.resetModules(); + vi.doMock(import("./local.js"), async (importActual) => { + const lib = await importActual(); + return lib; + }) + const lib2: any = await import("./local.js") + expect(lib2).toHaveProperty('local') + expect.soft(lib1 !== lib2).toBe(true) + + vi.resetModules(); + vi.doMock(import("./local.js"), async () => ({ mocked: true })); + const lib3 = await import("./local.js"); + expect(lib3).toMatchObject({ mocked: true }) + + const lib4 = await vi.importActual("./local.js"); + expect(lib4).toHaveProperty('local') + const lib5 = await vi.importActual("./local.js"); + expect(lib4).toBe(lib5) +}); + `, + './local.js': `export const local = 'local'`, + }, modeToConfig(mode)) + + if (mode === 'webdriverio' || mode === 'playwright') { + // browser mode doesn't support resetModules nor node builtin + expect(errorTree()).toMatchInlineSnapshot(` + { + "builtin.test.ts": { + "builtin": [ + "Cannot convert a Symbol value to a string", + ], + }, + "external.test.ts": { + "external": [ + "expected false to be true // Object.is equality", + "expected { default: 'test-dep-simple', …(1) } to match object { mocked: true } + (1 matching property omitted from actual)", + ], + }, + "local.test.ts": { + "local": [ + "expected false to be true // Object.is equality", + "expected { local: 'local', …(1) } to match object { mocked: true } + (1 matching property omitted from actual)", + ], + }, + } + `) + return + } + + expect(stderr).toMatchInlineSnapshot(`""`) + expect(errorTree()).toMatchInlineSnapshot(` + { + "builtin.test.ts": { + "builtin": "passed", + }, + "external.test.ts": { + "external": "passed", + }, + "local.test.ts": { + "local": "passed", + }, + } + `) +}) From 2d81ad897b3ba5de234ad370b8e41e4e194d17b4 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Mar 2026 04:50:19 +0900 Subject: [PATCH 4/5] fix: preserve coverage report when html reporter overlaps (#9889) --- packages/ui/node/reporter.ts | 6 ++++ test/cli/test/reporters/html.test.ts | 43 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/packages/ui/node/reporter.ts b/packages/ui/node/reporter.ts index 7c5a03483912..ba5f19173c4d 100644 --- a/packages/ui/node/reporter.ts +++ b/packages/ui/node/reporter.ts @@ -159,6 +159,12 @@ export default class HTMLReporter implements Reporter { if (this.ctx.config.coverage.enabled && this.ctx.config.coverage.htmlDir) { const coverageHtmlDir = this.ctx.config.coverage.htmlDir const destCoverageDir = resolve(this.reporterDir, 'coverage') + if (coverageHtmlDir === destCoverageDir) { + // skip and preserve already generated coverage report. + // this can happen when users configures `outputFile` + // next to `coverage.reportsDirectory`. + return + } await fs.rm(destCoverageDir, { recursive: true, force: true }) await fs.mkdir(destCoverageDir, { recursive: true }) await fs.cp(coverageHtmlDir, destCoverageDir, { recursive: true }) diff --git a/test/cli/test/reporters/html.test.ts b/test/cli/test/reporters/html.test.ts index 39df19047709..13a60bb3b2d9 100644 --- a/test/cli/test/reporters/html.test.ts +++ b/test/cli/test/reporters/html.test.ts @@ -118,6 +118,7 @@ test('basic', () => {}); ], }, }) + expect(result.stderr).toMatchInlineSnapshot(`""`) expect(result.errorTree()).toMatchInlineSnapshot(` { "basic.test.ts": { @@ -127,3 +128,45 @@ test('basic', () => {}); `) expect(result.fs.statFile('html/index.html').isFile()).toBe(true) }) + +it('html and coverage already next each other', async () => { + const result = await runInlineTests({ + 'basic.ts': ` +export const add = (a: number, b: number) => a + b; +`, + 'basic.test.ts': ` +import { test, expect } from "vitest"; +import { add } from "./basic"; +test('add', () => { + expect(add(1, 2)).toBe(3); +}); +`, + }, { + reporters: [ + 'default', + ['html', { outputFile: './custom-dir/index.html' }], + ], + coverage: { + enabled: true, + reporter: ['html'], + reportsDirectory: './custom-dir/coverage', + }, + }) + expect(result.stderr).toMatchInlineSnapshot(`""`) + expect(result.errorTree()).toMatchInlineSnapshot(` + { + "basic.test.ts": { + "add": "passed", + }, + } + `) + expect({ + html: result.fs.statFile('custom-dir/index.html').isFile(), + coverage: result.fs.statFile('custom-dir/coverage/index.html').isFile(), + }).toMatchInlineSnapshot(` + { + "coverage": true, + "html": true, + } + `) +}) From fe9ed7ac857c2e3d44217f87ad78bc2f20c6b2be Mon Sep 17 00:00:00 2001 From: Srasti Jain Date: Thu, 19 Mar 2026 01:32:21 +0530 Subject: [PATCH 5/5] docs: add Unhandled Promise Rejection section to common errors guide (#9879) Co-authored-by: Vladimir Sheremet --- docs/guide/common-errors.md | 44 +++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/docs/guide/common-errors.md b/docs/guide/common-errors.md index e3808fb7612c..23a154ef7dab 100644 --- a/docs/guide/common-errors.md +++ b/docs/guide/common-errors.md @@ -121,3 +121,47 @@ export default defineConfig({ vitest --pool=forks ``` ::: + +## Unhandled Promise Rejection + +This error happens when a Promise rejects but no `.catch()` handler or `await` is attached to it before the microtask queue flushes. This behavior comes from JavaScript itself and is not specific to Vitest. Learn more in the [Node.js documentation](https://nodejs.org/api/process.html#event-unhandledrejection). + +A common cause is calling an async function without `await`ing it: + +```ts +async function fetchUser(id) { + const res = await fetch(`/api/users/${id}`) + if (!res.ok) { + throw new Error(`User ${id} not found`) // [!code highlight] + } + return res.json() +} + +test('fetches user', async () => { + fetchUser(123) // [!code error] +}) +``` + +Because `fetchUser()` is not `await`ed, its rejection has no handler and Vitest reports: + +``` +Unhandled Rejection: Error: User 123 not found +``` + +### Fix + +`await` the promise so Vitest can catch the error: + +```ts +test('fetches user', async () => { + await fetchUser(123) // [!code ++] +}) +``` + +If you expect the call to throw, use [`expect().rejects`](/api/expect#rejects): + +```ts +test('rejects for missing user', async () => { + await expect(fetchUser(123)).rejects.toThrow('User 123 not found') +}) +```