Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
b46594e
fix: add expect.soft support to toMatchSnapshot (#8673)
iumehara Dec 10, 2025
81bddb7
Fix imported types in chai.ts
iumehara Dec 11, 2025
135dbc1
Move expect.soft Snapshot tests to snapshot directory to test across …
iumehara Dec 16, 2025
84e80f7
Fix linting errors
iumehara Jan 27, 2026
48e2175
Merge branch 'main' into soft-snapshot
hi-ogawa Feb 3, 2026
ddb7747
test: add e2e
hi-ogawa Feb 3, 2026
654bb37
test: remove artificial tests
hi-ogawa Feb 3, 2026
10b2914
test: update
hi-ogawa Feb 3, 2026
ad74f7e
test: update
hi-ogawa Feb 3, 2026
4e43f49
test: test toThrowErrorMatchingInlineSnapshot
hi-ogawa Feb 3, 2026
ffa7cf4
wip: is this needed?
hi-ogawa Feb 3, 2026
a7305c1
chore: comment
hi-ogawa Feb 4, 2026
4da1cf4
Merge branch 'main' into soft-snapshot
hi-ogawa Feb 4, 2026
686f7da
chore: cleanup unused
hi-ogawa Feb 4, 2026
bc08a4c
chore(test/snapshots): document test infra and rename test:snaps to t…
hi-ogawa Feb 4, 2026
d49fb31
refactor: reduce types
hi-ogawa Feb 4, 2026
71449ab
fix: support soft inline snapshot
hi-ogawa Feb 4, 2026
bd60ae4
fix: allow soft
hi-ogawa Feb 4, 2026
d3ac41b
chore: comment
hi-ogawa Feb 4, 2026
b24963b
test: test soft inline snapshot
hi-ogawa Feb 4, 2026
b65a986
fix: use __INLINE_SNAPSHOT_OFFSET_3__ to tweak stack
hi-ogawa Feb 4, 2026
c2a0c90
fix: webkit ptc
hi-ogawa Feb 4, 2026
add5a14
fix: use try/finally to prevent WebKit TCO optimization
hi-ogawa Feb 4, 2026
445a8f4
chore: comment
hi-ogawa Feb 4, 2026
834aed2
chore: todo
hi-ogawa Feb 4, 2026
9aad5a4
fix: make wrapAssertion and recordAsyncExpect work together
hi-ogawa Feb 4, 2026
dbd3bd3
test: test soft file snapshot warning
hi-ogawa Feb 4, 2026
59f03fa
fix: fix non awaited soft warning
hi-ogawa Feb 4, 2026
893628e
chore: cleanup
hi-ogawa Feb 4, 2026
847a2bd
test: update
hi-ogawa Feb 4, 2026
a03645f
fix: soft async tracking
hi-ogawa Feb 4, 2026
6b6bdf4
chore: comment
hi-ogawa Feb 4, 2026
7d754ab
test: update
hi-ogawa Feb 4, 2026
658a1a1
refactor: no wrapAssertion for toMatchFileSnapshot
hi-ogawa Feb 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions packages/expect/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,20 @@ export function createAssertionMessage(
assertion: Assertion,
hasArgs: boolean,
) {
const soft = util.flag(assertion, 'soft') ? '.soft' : ''
const not = util.flag(assertion, 'negate') ? 'not.' : ''
const name = `${util.flag(assertion, '_name')}(${hasArgs ? 'expected' : ''})`
const promiseName = util.flag(assertion, 'promise')
const promise = promiseName ? `.${promiseName}` : ''
return `expect(actual)${promise}.${not}${name}`
return `expect${soft}(actual)${promise}.${not}${name}`
}

export function recordAsyncExpect(
_test: any,
promise: Promise<any>,
assertion: string,
error: Error,
isSoft?: boolean,
): Promise<any> {
const test = _test as Test | undefined
// record promise for test, that resolves before test ends
Expand All @@ -39,6 +41,13 @@ export function recordAsyncExpect(
if (!test.promises) {
test.promises = []
}
// setup `expect.soft` handler here instead of `wrapAssertion`
// to avoid double error tracking while keeping non-await promise detection.
if (isSoft) {
promise = promise.then(noop, (err) => {
handleTestError(test, err)
})
}
test.promises.push(promise)

let resolved = false
Expand Down Expand Up @@ -93,7 +102,14 @@ export function wrapAssertion(
}

if (!utils.flag(this, 'soft')) {
return fn.apply(this, args)
// avoid WebKit's proper tail call to preserve stacktrace offset for inline snapshot
// https://webkit.org/blog/6240/ecmascript-6-proper-tail-calls-in-webkit
try {
return fn.apply(this, args)
}
finally {
// no lint
}
}

const test: Test = utils.flag(this, 'vitest-test')
Expand Down
9 changes: 9 additions & 0 deletions packages/snapshot/src/port/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,15 @@ export default class SnapshotState {
return stacks[promiseIndex + 3]
}

// inline snapshot function can be named __INLINE_SNAPSHOT_OFFSET_<n>__
// to specify a custom stack offset
for (let i = 0; i < stacks.length; i++) {
const match = stacks[i].method.match(/__INLINE_SNAPSHOT_OFFSET_(\d+)__/)
if (match) {
return stacks[i + Number(match[1])] ?? null
}
}
Comment on lines +171 to +178
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added minor enhancement on @vitest/snapshot side to support more flexible inline snapshot stack offset.


// inline snapshot function is called __INLINE_SNAPSHOT__
// in integrations/snapshot/chai.ts
const stackIndex = stacks.findIndex(i =>
Expand Down
25 changes: 13 additions & 12 deletions packages/vitest/src/integrations/snapshot/chai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
SnapshotClient,
stripSnapshotIndentation,
} from '@vitest/snapshot'
import { createAssertionMessage, recordAsyncExpect } from '../../../../expect/src/utils'
import { createAssertionMessage, recordAsyncExpect, wrapAssertion } from '../../../../expect/src/utils'

let _client: SnapshotClient

Expand Down Expand Up @@ -65,8 +65,8 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
utils.addMethod(
chai.Assertion.prototype,
key,
function (
this: Record<string, unknown>,
wrapAssertion(utils, key, function (
this,
properties?: object,
message?: string,
) {
Expand All @@ -90,7 +90,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
errorMessage,
...getTestNames(test),
})
},
}),
)
}

Expand Down Expand Up @@ -124,15 +124,16 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
promise,
createAssertionMessage(utils, this, true),
error,
utils.flag(this, 'soft'),
)
},
)

utils.addMethod(
chai.Assertion.prototype,
'toMatchInlineSnapshot',
function __INLINE_SNAPSHOT__(
this: Record<string, unknown>,
wrapAssertion(utils, 'toMatchInlineSnapshot', function __INLINE_SNAPSHOT_OFFSET_3__(
this,
properties?: object,
inlineSnapshot?: string,
message?: string,
Expand Down Expand Up @@ -171,12 +172,12 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
errorMessage,
...getTestNames(test),
})
},
}),
)
utils.addMethod(
chai.Assertion.prototype,
'toThrowErrorMatchingSnapshot',
function (this: Record<string, unknown>, message?: string) {
wrapAssertion(utils, 'toThrowErrorMatchingSnapshot', function (this, properties?: object, message?: string) {
utils.flag(this, '_name', 'toThrowErrorMatchingSnapshot')
const isNot = utils.flag(this, 'negate')
if (isNot) {
Expand All @@ -194,13 +195,13 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
errorMessage,
...getTestNames(test),
})
},
}),
)
utils.addMethod(
chai.Assertion.prototype,
'toThrowErrorMatchingInlineSnapshot',
function __INLINE_SNAPSHOT__(
this: Record<string, unknown>,
wrapAssertion(utils, 'toThrowErrorMatchingInlineSnapshot', function __INLINE_SNAPSHOT_OFFSET_3__(
this,
inlineSnapshot: string,
message: string,
) {
Expand Down Expand Up @@ -235,7 +236,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
errorMessage,
...getTestNames(test),
})
},
}),
)
utils.addMethod(chai.expect, 'addSnapshotSerializer', addSerializer)
}
36 changes: 23 additions & 13 deletions test/cli/test/fails.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { TestCase } from 'vitest/node'
import { playwright } from '@vitest/browser-playwright'

import { resolve } from 'pathe'
Expand Down Expand Up @@ -51,7 +50,7 @@ it('should not report coverage when "coverage.reportOnFailure" has default value
})

it('prints a warning if the assertion is not awaited', async () => {
const { stderr, results, root } = await runInlineTests({
const { stderr, root, errorTree } = await runInlineTests({
'base.test.js': ts`
import { expect, test } from 'vitest';

Expand All @@ -69,21 +68,30 @@ it('prints a warning if the assertion is not awaited', async () => {
expect(1).toBe(2)
})

test('toMatchSnapshot not awaited', () => {
test('toMatchFileSnapshot not awaited', () => {
expect(1).toMatchFileSnapshot('./snapshot.txt')
})

test('soft + toMatchFileSnapshot not awaited', () => {
expect.soft(1).toMatchFileSnapshot('./snapshot-soft.txt')
})
`,
}, {
update: true,
})
expect(results[0].children.size).toEqual(4)
const failedTest = results[0].children.at(2) as TestCase
expect(failedTest.result()).toEqual({
state: 'failed',
errors: [
expect.objectContaining({
message: expect.stringContaining('expected 1 to be 2'),
}),
],
})
expect(errorTree()).toMatchInlineSnapshot(`
{
"base.test.js": {
"not awaited and failed": [
"expected 1 to be 2 // Object.is equality",
],
"several not awaited": "passed",
"single not awaited": "passed",
"soft + toMatchFileSnapshot not awaited": "passed",
"toMatchFileSnapshot not awaited": "passed",
},
}
`)
const warnings: string[] = []
const lines = stderr.split('\n')
lines.forEach((line, index) => {
Expand All @@ -103,6 +111,8 @@ it('prints a warning if the assertion is not awaited', async () => {
at <rootDir>/base.test.js:14:33",
"Promise returned by \`expect(actual).toMatchFileSnapshot(expected)\` was not awaited. Vitest currently auto-awaits hanging assertions at the end of the test, but this will cause the test to fail in the next Vitest major. Please remember to await the assertion.
at <rootDir>/base.test.js:19:17",
"Promise returned by \`expect.soft(actual).toMatchFileSnapshot(expected)\` was not awaited. Vitest currently auto-awaits hanging assertions at the end of the test, but this will cause the test to fail in the next Vitest major. Please remember to await the assertion.
at <rootDir>/base.test.js:23:22",
]
`)
})
Expand Down
55 changes: 55 additions & 0 deletions test/snapshots/README.md
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a quick guide for snapshot test suites. We can likely simplify test:generate -> update combo to unify within a single test:integration later.

Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Snapshot Tests

This directory contains integration tests for Vitest's snapshot functionality. It uses a meta-testing approach where integration tests programmatically run fixture tests to validate snapshot behavior.

## Directory Structure

```
test/snapshots/
├── test/ # Integration tests that validate snapshot features
│ └── fixtures/ # Test fixture files (copied to test-update/)
├── test-update/ # Generated directory - populated from fixtures
├── generate.mjs # Resets test-update/ from fixtures
└── vitest.config.ts # Test configuration
```

## Test Scripts

| Script | Purpose |
|--------|---------|
| `test` | Runs the complete test suite (all scripts below in sequence) |
| `test:generate` | Resets `test-update/` by copying fresh fixtures |
| `test:update` | Runs tests with `-u` flag to update existing snapshots |
| `test:update-new` | Runs with `CI=false` to create new snapshots |
| `test:update-none` | Runs with `CI=true` to validate without updates (strict mode) |
| `test:integration` | Runs the main integration tests in `test/` |

## How It Works

1. **`generate.mjs`** copies fixture files from `test/fixtures/test-update/` to `test-update/`
2. **`test:update*` scripts** run the fixture tests with different snapshot update modes
3. **`test:integration`** runs integration tests that use `runVitest()` to programmatically execute fixtures and assert on the results

This setup allows testing snapshot features like:
- Inline snapshots (`toMatchInlineSnapshot`)
- File-based snapshots (`toMatchFileSnapshot`)
- Snapshot update behavior with `-u` flag
- CI vs non-CI snapshot creation modes
- Custom serializers, soft assertions, retry logic, etc.

## Running Tests

```bash
# Run all snapshot tests
pnpm test

# Or run individual stages
# - Reset fixtures
pnpm test:generate
# - Run integration tests only
pnpm test:integration
pnpm test:integration test/summary.test.ts

# Run one of fixtures directly
pnpm test:fixtures --root test/fixtures/summary
```
4 changes: 2 additions & 2 deletions test/snapshots/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
"type": "module",
"private": true,
"scripts": {
"test": "pnpm run test:generate && pnpm run test:update && pnpm test:update-new && pnpm test:update-none && pnpm run test:snaps",
"test": "pnpm run test:generate && pnpm run test:update && pnpm test:update-new && pnpm test:update-none && pnpm run test:integration",
"test:generate": "node ./generate.mjs",
"test:snaps": "vitest run --dir test",
"test:integration": "vitest run --dir test",
"test:update": "vitest run -u --dir test-update",
"test:update-none": "CI=true vitest run --dir test-update",
"test:update-new": "CI=false vitest run --dir test-update",
Expand Down
11 changes: 11 additions & 0 deletions test/snapshots/test/fixtures/soft-inline/basic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { expect, test } from 'vitest'

test('toMatchInlineSnapshot', () => {
expect.soft('--snap-1--').toMatchInlineSnapshot(`"--snap-1--"`)
expect.soft('--snap-2--').toMatchInlineSnapshot(`"--snap-2--"`)
})

test('toThrowErrorMatchingInlineSnapshot', () => {
expect.soft(() => { throw new Error('--error-1--') }).toThrowErrorMatchingInlineSnapshot(`[Error: --error-1--]`)
expect.soft(() => { throw new Error('--error-2--') }).toThrowErrorMatchingInlineSnapshot(`[Error: --error-2--]`)
})
1 change: 1 addition & 0 deletions test/snapshots/test/fixtures/soft/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__snapshots__
16 changes: 16 additions & 0 deletions test/snapshots/test/fixtures/soft/basic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { expect, test } from 'vitest'

test('toMatchSnapshot', () => {
expect.soft('--snap-1--').toMatchSnapshot()
expect.soft('--snap-2--').toMatchSnapshot()
})

test('toMatchFileSnapshot', async () => {
await expect.soft('--file-1--').toMatchFileSnapshot('./__snapshots__/custom1.txt')
await expect.soft('--file-2--').toMatchFileSnapshot('./__snapshots__/custom2.txt')
})

test('toThrowErrorMatchingSnapshot', () => {
expect.soft(() => { throw new Error('--error-1--') }).toThrowErrorMatchingSnapshot()
expect.soft(() => { throw new Error('--error-2--') }).toThrowErrorMatchingSnapshot()
})
Loading
Loading