From 004e264cfee01d02c8742d14ed0c545443e0a052 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 20 Feb 2026 11:48:59 +0900 Subject: [PATCH 1/8] feat: support `update: "none"` and add docs about CI behavior --- docs/config/update.md | 14 ++++++++++---- docs/guide/cli-generated.md | 2 +- docs/guide/snapshot.md | 6 ++++++ examples/basic/test/suite.test.ts | 1 + packages/vitest/src/node/cli/cli-config.ts | 2 +- packages/vitest/src/node/config/resolveConfig.ts | 2 +- packages/vitest/src/node/types/config.ts | 2 +- 7 files changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/config/update.md b/docs/config/update.md index a154000bd696..d38ad1ba863c 100644 --- a/docs/config/update.md +++ b/docs/config/update.md @@ -5,11 +5,17 @@ outline: deep # update {#update} -- **Type:** `boolean | 'new' | 'all'` +- **Type:** `boolean | 'new' | 'all' | 'none'` - **Default:** `false` -- **CLI:** `-u`, `--update`, `--update=false`, `--update=new` +- **CLI:** `-u`, `--update`, `--update=false`, `--update=new`, `--update=none` -Update snapshot files. The behaviour depends on the value: +Define snapshot update behavior. -- `true` or `'all'`: updates all changed snapshots and delete obsolete ones +- `true` or `'all'`: updates all changed snapshots and deletes obsolete ones - `new`: generates new snapshots without changing or deleting obsolete ones +- `none`: does not write snapshots and fails on snapshot mismatches, missing snapshots, and obsolete snapshots + +When `update` is `false` (the default), Vitest resolves snapshot update mode by environment: + +- Local runs (non-CI): works same as `new` +- CI runs (`process.env.CI` is truthy): works same as `none` diff --git a/docs/guide/cli-generated.md b/docs/guide/cli-generated.md index 52f67fc9a247..d86486f98517 100644 --- a/docs/guide/cli-generated.md +++ b/docs/guide/cli-generated.md @@ -16,7 +16,7 @@ Path to config file - **CLI:** `-u, --update [type]` - **Config:** [update](/config/update) -Update snapshot (accepts boolean, "new" or "all") +Update snapshot (accepts boolean, "new", "all" or "none") ### watch diff --git a/docs/guide/snapshot.md b/docs/guide/snapshot.md index b9a3ba6405d7..18b0b58a636a 100644 --- a/docs/guide/snapshot.md +++ b/docs/guide/snapshot.md @@ -79,6 +79,12 @@ Or you can use the `--update` or `-u` flag in the CLI to make Vitest update snap vitest -u ``` +### CI behavior + +By default, Vitest does not write snapshots in CI (`process.env.CI` is truthy) and any snapshot mismatches, missing snapshots, and obsolete snapshots fail the run. See [`update`](/config/update) for the details. + +An **obsolete snapshot** is a snapshot entry (or snapshot file) that no longer matches any collected test. This usually happens after removing or renaming tests. + ## File Snapshots When calling `toMatchSnapshot()`, we store all snapshots in a formatted snap file. That means we need to escape some characters (namely the double-quote `"` and backtick `` ` ``) in the snapshot string. Meanwhile, you might lose the syntax highlighting for the snapshot content (if they are in some language). diff --git a/examples/basic/test/suite.test.ts b/examples/basic/test/suite.test.ts index cecb84ccedc8..254f2d74a92b 100644 --- a/examples/basic/test/suite.test.ts +++ b/examples/basic/test/suite.test.ts @@ -7,6 +7,7 @@ describe('suite name', () => { it('bar', () => { expect(1 + 1).eq(2) + expect(3).toMatchInlineSnapshot() }) it('snapshot', () => { diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index b10a61856685..ddf7641096b2 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -84,7 +84,7 @@ export const cliOptionsConfig: VitestCLIOptions = { }, update: { shorthand: 'u', - description: 'Update snapshot (accepts boolean, "new" or "all")', + description: 'Update snapshot (accepts boolean, "new", "all" or "none")', argument: '[type]', }, watch: { diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index eb886f865d8e..3c849ea68201 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -566,7 +566,7 @@ export function resolveConfig( expand: resolved.expandSnapshotDiff ?? false, snapshotFormat: resolved.snapshotFormat || {}, updateSnapshot: - UPDATE_SNAPSHOT === 'all' || UPDATE_SNAPSHOT === 'new' + UPDATE_SNAPSHOT === 'all' || UPDATE_SNAPSHOT === 'new' || UPDATE_SNAPSHOT === 'none' ? UPDATE_SNAPSHOT : isCI && !UPDATE_SNAPSHOT ? 'none' : UPDATE_SNAPSHOT ? 'all' : 'new', resolveSnapshotPath: options.resolveSnapshotPath, diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index e603e1d4b8d9..29c83bc1108a 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -380,7 +380,7 @@ export interface InlineConfig { * * @default false */ - update?: boolean | 'all' | 'new' + update?: boolean | 'all' | 'new' | 'none' /** * Watch mode From f0e734b9c04147ba2d68ba9615baf2bb3a87ca29 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 20 Feb 2026 11:52:46 +0900 Subject: [PATCH 2/8] chore: cleanup --- examples/basic/test/suite.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/basic/test/suite.test.ts b/examples/basic/test/suite.test.ts index 254f2d74a92b..cecb84ccedc8 100644 --- a/examples/basic/test/suite.test.ts +++ b/examples/basic/test/suite.test.ts @@ -7,7 +7,6 @@ describe('suite name', () => { it('bar', () => { expect(1 + 1).eq(2) - expect(3).toMatchInlineSnapshot() }) it('snapshot', () => { From 872668c39dae2d9033002b9b1f50c346411fa9a0 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 20 Feb 2026 11:58:14 +0900 Subject: [PATCH 3/8] test: use none --- test/snapshots/test/test-update.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/snapshots/test/test-update.test.ts b/test/snapshots/test/test-update.test.ts index 5df1f63fc2e3..272b502d1f20 100644 --- a/test/snapshots/test/test-update.test.ts +++ b/test/snapshots/test/test-update.test.ts @@ -67,7 +67,7 @@ test('test update', async () => { `) // re-run without update and files are unchanged - const result2 = await runVitest({ root: dstDir, update: false }) + const result2 = await runVitest({ root: dstDir, update: 'none' }) expect(result2.stderr).toMatchInlineSnapshot(`""`) expect(result2.errorTree()).toEqual(result.errorTree()) expect(readFiles(dstDir)).toEqual(resultFiles) From 9047ba6a4e78e70a36c0543e1b4e325b9ef1d31c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 20 Feb 2026 12:03:08 +0900 Subject: [PATCH 4/8] test: use more none --- test/snapshots/test/obsolete.test.ts | 75 ++++++++++++++++++---------- 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/test/snapshots/test/obsolete.test.ts b/test/snapshots/test/obsolete.test.ts index 8e66f3583174..d539208d2f30 100644 --- a/test/snapshots/test/obsolete.test.ts +++ b/test/snapshots/test/obsolete.test.ts @@ -1,39 +1,62 @@ import fs from 'node:fs' import path from 'node:path' import { expect, test } from 'vitest' -import { runVitestCli } from '../../test-utils' +import { runVitest } from '../../test-utils' -test('obsolete snapshot fails CI', async () => { +test('obsolete snapshot fails with update:none', async () => { // cleanup snapshot const root = path.join(import.meta.dirname, 'fixtures/obsolete') fs.rmSync(path.join(root, 'src/__snapshots__'), { recursive: true, force: true }) // initial run to write snapshot - let vitest = await runVitestCli('--root', root, '--update') - expect(vitest.stdout).toContain('Snapshots 5 written') - expect(vitest.stderr).toBe('') + let result = await runVitest({ root, update: true }) + expect(result.stderr).toBe('') + expect(result.errorTree()).toMatchInlineSnapshot(` + Object { + "src/test1.test.ts": Object { + "bar": "passed", + "foo": "passed", + "fuu": "passed", + }, + "src/test2.test.ts": Object { + "bar": "passed", + "foo": "passed", + }, + } + `) // test fails with obsolete snapshots - // (use cli to test `updateSnapshot: 'none'`) - vitest = await runVitestCli( - { - nodeOptions: { - env: { - CI: 'true', - TEST_OBSOLETE: 'true', - }, - }, - }, - '--root', + result = await runVitest({ root, - ) - expect(vitest.stdout).toContain('2 obsolete') - expect(vitest.stdout).toContain('Test Files 1 failed | 1 passed') - expect(vitest.stdout).toContain('Tests 5 passed') - expect(vitest.stderr).toContain(` -Error: Obsolete snapshots found when no snapshot update is expected. -· foo 1 -· fuu 1 -`) - expect(vitest.exitCode).toBe(1) + update: 'none', + env: { + TEST_OBSOLETE: 'true', + }, + }) + expect(result.stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL src/test1.test.ts [ src/test1.test.ts ] + Error: Obsolete snapshots found when no snapshot update is expected. + · foo 1 + · fuu 1 + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + " + `) + expect(result.errorTree()).toMatchInlineSnapshot(` + Object { + "src/test1.test.ts": Object { + "bar": "passed", + "foo": "passed", + "fuu": "passed", + }, + "src/test2.test.ts": Object { + "bar": "passed", + "foo": "passed", + }, + } + `) }) From a6a7a0d3fa94660295414856bf9fe8fbec92c531 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 20 Feb 2026 12:17:55 +0900 Subject: [PATCH 5/8] test: add __module_errors__ to errorTree --- test/snapshots/test/obsolete.test.ts | 6 ++++++ test/test-utils/index.ts | 14 +++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/test/snapshots/test/obsolete.test.ts b/test/snapshots/test/obsolete.test.ts index d539208d2f30..a6992ccc4adc 100644 --- a/test/snapshots/test/obsolete.test.ts +++ b/test/snapshots/test/obsolete.test.ts @@ -49,6 +49,12 @@ test('obsolete snapshot fails with update:none', async () => { expect(result.errorTree()).toMatchInlineSnapshot(` Object { "src/test1.test.ts": Object { + "__module_errors__": Array [ + "Obsolete snapshots found when no snapshot update is expected. + · foo 1 + · fuu 1 + ", + ], "bar": "passed", "foo": "passed", "fuu": "passed", diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index 5b2d3a1828b2..738239854376 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -575,6 +575,16 @@ export function buildErrorTree(testModules: TestModule[]) { } return suiteChildren }, + (testModule, moduleChildren) => { + const errors = testModule.errors().map(error => error.message) + if (errors.length > 0) { + return { + ...moduleChildren, + __module_errors__: errors, + } + } + return moduleChildren + }, ) } @@ -582,6 +592,7 @@ export function buildTestTree( testModules: TestModule[], onTestCase?: (result: TestCase) => unknown, onTestSuite?: (testSuite: TestSuite, suiteChildren: Record) => unknown, + onTestModule?: (testModule: TestModule, moduleChildren: Record) => unknown, ) { type TestTree = Record @@ -613,7 +624,8 @@ export function buildTestTree( for (const module of testModules) { // Use relative module ID for cleaner output const key = module.relativeModuleId - tree[key] = walkCollection(module.children) + const moduleChildren = walkCollection(module.children) + tree[key] = onTestModule ? onTestModule(module, moduleChildren) : moduleChildren } return tree From 2c06a55dea52348c83b694b21527fc476c3b4d2d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 20 Feb 2026 12:43:38 +0900 Subject: [PATCH 6/8] test: snapshot on ci --- test/snapshots/test/ci.test.ts | 52 +++++++++++++++++++ test/snapshots/test/fixtures/ci/.gitignore | 1 + test/snapshots/test/fixtures/ci/basic.test.ts | 5 ++ 3 files changed, 58 insertions(+) create mode 100644 test/snapshots/test/ci.test.ts create mode 100644 test/snapshots/test/fixtures/ci/.gitignore create mode 100644 test/snapshots/test/fixtures/ci/basic.test.ts diff --git a/test/snapshots/test/ci.test.ts b/test/snapshots/test/ci.test.ts new file mode 100644 index 000000000000..11dcf23c89e2 --- /dev/null +++ b/test/snapshots/test/ci.test.ts @@ -0,0 +1,52 @@ +import fs from 'node:fs' +import path from 'node:path' +import { expect, test } from 'vitest' +import { runVitestCli } from '../../test-utils' + +test('CI behavior', async () => { + // cleanup snapshot + const root = path.join(import.meta.dirname, 'fixtures/ci') + fs.rmSync(path.join(root, '__snapshots__'), { recursive: true, force: true }) + + // snapshot fails with CI + let result = await runVitestCli({ + nodeOptions: { + env: { + CI: 'true', + }, + }, + }, '--root', root) + expect(result.stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL basic.test.ts > basic + Error: Snapshot \`basic 1\` mismatched + ❯ basic.test.ts:4:16 + 2| + 3| test("basic", () => { + 4| expect("ok").toMatchSnapshot() + | ^ + 5| }) + 6| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + " + `) + + // snapshot created without CI + result = await runVitestCli( + { + nodeOptions: { + env: { + CI: '', + }, + }, + }, + '--root', + root, + ) + expect(result.stderr).toMatchInlineSnapshot(`""`) + expect(result.stdout).toContain('Snapshots 1 written') +}) diff --git a/test/snapshots/test/fixtures/ci/.gitignore b/test/snapshots/test/fixtures/ci/.gitignore new file mode 100644 index 000000000000..b05c2dfa7007 --- /dev/null +++ b/test/snapshots/test/fixtures/ci/.gitignore @@ -0,0 +1 @@ +__snapshots__ diff --git a/test/snapshots/test/fixtures/ci/basic.test.ts b/test/snapshots/test/fixtures/ci/basic.test.ts new file mode 100644 index 000000000000..9727e71ae6f2 --- /dev/null +++ b/test/snapshots/test/fixtures/ci/basic.test.ts @@ -0,0 +1,5 @@ +import { test, expect } from "vitest" + +test("basic", () => { + expect("ok").toMatchSnapshot() +}) From c9a86e69628998084ade84dd21c09d9ce35aa4a5 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 20 Feb 2026 12:48:47 +0900 Subject: [PATCH 7/8] test: snapshot __module_errors__ --- test/cli/test/around-each.test.ts | 23 +++++++++ test/cli/test/mocking.test.ts | 82 ++++++++++++------------------- 2 files changed, 54 insertions(+), 51 deletions(-) diff --git a/test/cli/test/around-each.test.ts b/test/cli/test/around-each.test.ts index 247b64066fce..fff738ef7e12 100644 --- a/test/cli/test/around-each.test.ts +++ b/test/cli/test/around-each.test.ts @@ -1463,6 +1463,9 @@ test('aroundAll throws error when runSuite is not called', async () => { expect(errorTree()).toMatchInlineSnapshot(` { "no-run.test.ts": { + "__module_errors__": [ + "The \`runSuite()\` callback was not called in the \`aroundAll\` hook. Make sure to call \`runSuite()\` to run the suite.", + ], "test": "skipped", }, } @@ -1584,6 +1587,9 @@ test('aroundAll setup phase timeout', async () => { expect(errorTree()).toMatchInlineSnapshot(` { "timeout.test.ts": { + "__module_errors__": [ + "The setup phase of "aroundAll" hook timed out after 10ms.", + ], "test": "skipped", }, } @@ -1635,6 +1641,9 @@ test('aroundAll teardown phase timeout', async () => { expect(errorTree()).toMatchInlineSnapshot(` { "teardown-timeout.test.ts": { + "__module_errors__": [ + "The teardown phase of "aroundAll" hook timed out after 10ms.", + ], "test": "passed", }, } @@ -1974,6 +1983,9 @@ test('tests are skipped when aroundAll setup fails', async () => { expect(errorTree()).toMatchInlineSnapshot(` { "aroundAll-setup-error.test.ts": { + "__module_errors__": [ + "aroundAll setup error", + ], "test should be skipped": "skipped", }, } @@ -2461,6 +2473,9 @@ test('nested aroundAll setup error is not propagated to outer runSuite catch', a expect(errorTree()).toMatchInlineSnapshot(` { "nested-around-all-setup-error.test.ts": { + "__module_errors__": [ + "inner aroundAll setup error", + ], "repro": "skipped", }, } @@ -2524,6 +2539,9 @@ test('nested aroundAll teardown error is not propagated to outer runSuite catch' expect(errorTree()).toMatchInlineSnapshot(` { "nested-around-all-teardown-error.test.ts": { + "__module_errors__": [ + "inner aroundAll teardown error", + ], "repro": "passed", }, } @@ -2712,6 +2730,11 @@ test('three nested aroundAll teardown errors are all reported', async () => { expect(errorTree()).toMatchInlineSnapshot(` { "triple-around-all-teardown-errors.test.ts": { + "__module_errors__": [ + "inner aroundAll teardown error", + "middle aroundAll teardown error", + "outer aroundAll teardown error", + ], "repro": "passed", }, } diff --git a/test/cli/test/mocking.test.ts b/test/cli/test/mocking.test.ts index 16c3dd6935d6..8c0afda75423 100644 --- a/test/cli/test/mocking.test.ts +++ b/test/cli/test/mocking.test.ts @@ -43,26 +43,14 @@ test('spy is not called here', () => { }) test('invalid packages', async () => { - const { results, errorTree } = await runVitest({ + const { stderr, errorTree } = await runVitest({ root: path.join(import.meta.dirname, '../fixtures/invalid-package'), }) - const testModuleErrors = Object.fromEntries( - results.map(testModule => [ - testModule.relativeModuleId, - testModule.errors().map(e => e.message), - ]), - ) // requires Vite 8 for relaxed import analysis validataion // https://github.com/vitejs/vite/pull/21601 if (rolldownVersion) { - expect(testModuleErrors).toMatchInlineSnapshot(` - { - "mock-bad-dep.test.ts": [], - "mock-wrapper-and-bad-dep.test.ts": [], - "mock-wrapper.test.ts": [], - } - `) + expect(stderr).toMatchInlineSnapshot(`""`) expect(errorTree()).toMatchInlineSnapshot(` { "mock-bad-dep.test.ts": { @@ -78,32 +66,31 @@ test('invalid packages', async () => { `) } else { - expect(testModuleErrors).toMatchInlineSnapshot(` + expect(errorTree()).toMatchInlineSnapshot(` { - "mock-bad-dep.test.ts": [ - "Failed to resolve entry for package "test-dep-invalid". The package may have incorrect main/module/exports specified in its package.json.", - ], - "mock-wrapper-and-bad-dep.test.ts": [ - "Failed to resolve entry for package "test-dep-invalid". The package may have incorrect main/module/exports specified in its package.json.", - ], - "mock-wrapper.test.ts": [ - "Failed to resolve entry for package "test-dep-invalid". The package may have incorrect main/module/exports specified in its package.json.", - ], + "mock-bad-dep.test.ts": { + "__module_errors__": [ + "Failed to resolve entry for package "test-dep-invalid". The package may have incorrect main/module/exports specified in its package.json.", + ], + }, + "mock-wrapper-and-bad-dep.test.ts": { + "__module_errors__": [ + "Failed to resolve entry for package "test-dep-invalid". The package may have incorrect main/module/exports specified in its package.json.", + ], + }, + "mock-wrapper.test.ts": { + "__module_errors__": [ + "Failed to resolve entry for package "test-dep-invalid". The package may have incorrect main/module/exports specified in its package.json.", + ], + }, } `) - expect(errorTree()).toMatchInlineSnapshot(` - { - "mock-bad-dep.test.ts": {}, - "mock-wrapper-and-bad-dep.test.ts": {}, - "mock-wrapper.test.ts": {}, - } - `) } }) test('mocking modules with syntax error', async () => { // TODO: manual mocked module still gets transformed so this is not supported yet. - const { errorTree, results } = await runInlineTests({ + const { errorTree } = await runInlineTests({ './syntax-error.js': `syntax error`, './basic.test.js': /* ts */ ` import * as dep from './syntax-error.js' @@ -118,38 +105,31 @@ test('can mock invalid module', () => { `, }) - const testModuleErrors = Object.fromEntries( - results.map(testModule => [ - testModule.relativeModuleId, - testModule.errors().map(e => e.message), - ]), - ) if (rolldownVersion) { - expect(testModuleErrors).toMatchInlineSnapshot(` + expect(errorTree()).toMatchInlineSnapshot(` { - "basic.test.js": [ - "Parse failure: Parse failed with 1 error: + "basic.test.js": { + "__module_errors__": [ + "Parse failure: Parse failed with 1 error: Expected a semicolon or an implicit semicolon after a statement, but found none 1: syntax error ^ At file: /syntax-error.js:1:6", - ], + ], + }, } `) } else { - expect(testModuleErrors).toMatchInlineSnapshot(` + expect(errorTree()).toMatchInlineSnapshot(` { - "basic.test.js": [ - "Parse failure: Expected ';', '}' or + "basic.test.js": { + "__module_errors__": [ + "Parse failure: Expected ';', '}' or At file: /syntax-error.js:1:7", - ], + ], + }, } `) } - expect(errorTree()).toMatchInlineSnapshot(` - { - "basic.test.js": {}, - } - `) }) From c814423878183f1bf8fc110f8e1b84ff31022071 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 20 Feb 2026 13:05:00 +0900 Subject: [PATCH 8/8] test: deal with isCI --- test/snapshots/test/ci.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/snapshots/test/ci.test.ts b/test/snapshots/test/ci.test.ts index 11dcf23c89e2..37eec75015cc 100644 --- a/test/snapshots/test/ci.test.ts +++ b/test/snapshots/test/ci.test.ts @@ -13,6 +13,7 @@ test('CI behavior', async () => { nodeOptions: { env: { CI: 'true', + GITHUB_ACTIONS: 'true', }, }, }, '--root', root) @@ -41,6 +42,7 @@ test('CI behavior', async () => { nodeOptions: { env: { CI: '', + GITHUB_ACTIONS: '', }, }, },