Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
8180d44
feat: support custom snapshot matcher
hi-ogawa Mar 25, 2026
101cbe1
wip: API shape
hi-ogawa Mar 26, 2026
394cd3d
chore: todo
hi-ogawa Mar 26, 2026
4198ff8
wip: test
hi-ogawa Mar 26, 2026
6d05f6d
Merge branch 'main' into feat-support-custom-snapshot-matcher
hi-ogawa Mar 26, 2026
66700de
wip: generalize inline snapshot stack probing
hi-ogawa Mar 26, 2026
95dcf04
refactor: bake-in __VITEST_EXTEND_ASSERTION__ for stack probing
hi-ogawa Mar 26, 2026
ceb0ad2
test: cleanup
hi-ogawa Mar 26, 2026
d081be5
test: integration
hi-ogawa Mar 26, 2026
78af6a3
refactor: rename method -> assertionName
hi-ogawa Mar 26, 2026
9891c06
refactor: memoize regex
hi-ogawa Mar 26, 2026
715996f
refactor: nit
hi-ogawa Mar 26, 2026
7ea3891
wip: add SnapshotClient.match for non-throwing helper
hi-ogawa Mar 26, 2026
a234b54
chore: todo
hi-ogawa Mar 26, 2026
dd4e344
chore: lint
hi-ogawa Mar 26, 2026
eb02748
chore: tweak types
hi-ogawa Mar 26, 2026
777c182
chore: todo
hi-ogawa Mar 26, 2026
38e3a6a
chore: todo
hi-ogawa Mar 26, 2026
88c60ad
Merge branch 'main' into feat-support-custom-snapshot-matcher
hi-ogawa Mar 27, 2026
74f071e
chore: cleanup
hi-ogawa Mar 27, 2026
2fe20fd
test: update
hi-ogawa Mar 27, 2026
91a5a83
chore: rename
hi-ogawa Mar 27, 2026
a5ce476
chore: comment
hi-ogawa Mar 27, 2026
96bbe07
test: matcher result composability
hi-ogawa Mar 27, 2026
cf05a48
test: test snapshot with properties
hi-ogawa Mar 27, 2026
d0715f7
test: more test
hi-ogawa Mar 27, 2026
1975cbc
fix: fix properties subset snapshot error formatting
hi-ogawa Mar 27, 2026
327cd72
docs: document custom snapshot matchers
hi-ogawa Mar 27, 2026
16edc33
docs: fix stale description in migration guide
hi-ogawa Mar 27, 2026
52f4c98
docs: remove redundant sentence from snapshot guide
hi-ogawa Mar 27, 2026
6673db7
refactor: consolidate to toMatchSnapshotImpl for builtin assertions
hi-ogawa Mar 27, 2026
5a9069d
fix: revive diffOptions.expand
hi-ogawa Mar 27, 2026
3a79696
refactor: single option object argument is nice
hi-ogawa Mar 27, 2026
0c4fc8e
refactor: unify toMatchSnapshotImpl
hi-ogawa Mar 27, 2026
ded5eff
chore: docs slop
hi-ogawa Mar 27, 2026
1707111
chore: not todo
hi-ogawa Mar 27, 2026
588c2f7
chore: todo -> excuse
hi-ogawa Mar 27, 2026
d5d3e7d
Merge branch 'main' into feat-support-custom-snapshot-matcher
hi-ogawa Mar 27, 2026
6a82948
test: update
hi-ogawa Mar 27, 2026
fb09797
chore: solved TODO
hi-ogawa Mar 27, 2026
bf40941
chore: this is not slop comment
hi-ogawa Mar 27, 2026
64eb8f7
chore: move stripSnapshotIndentation into normalizeInlineArguments
hi-ogawa Mar 27, 2026
53bdc54
refactor: nit slop
hi-ogawa Mar 27, 2026
f853fa0
chore: bye merge conflict
hi-ogawa Mar 27, 2026
de0b595
fix: surface `not.toMatchInlineSnapshot` error properly
hi-ogawa Mar 27, 2026
0b9fcbb
feat: expose toMatchFileSnapshot
hi-ogawa Mar 28, 2026
4aba338
docs: document toMatchFileSnapshot
hi-ogawa Mar 28, 2026
b38ccbc
refactor: minor nit
hi-ogawa Mar 28, 2026
09e6bc9
fix: fix negate assertion check
hi-ogawa Mar 28, 2026
0cee3de
test: update exports
hi-ogawa Mar 28, 2026
76155eb
refactor: replace nonsense __vitest_context__.chaiUtils
hi-ogawa Mar 28, 2026
4bd8a28
refactor: drop __vitest_context__.assertionName too
hi-ogawa Mar 28, 2026
c6783df
refactor: more nit
hi-ogawa Mar 28, 2026
7a3d73f
refactor: nit types
hi-ogawa Mar 28, 2026
8d3a89f
refactor: nit __vitest_context__.assertion -> __vitest_assertion__
hi-ogawa Mar 28, 2026
7071004
Merge branch 'main' into feat-support-custom-snapshot-matcher
hi-ogawa Mar 28, 2026
fe0fc0e
refactor: reduce diff
hi-ogawa Mar 28, 2026
f79beb9
refactor: really last nit
hi-ogawa Mar 28, 2026
0a58099
chore: excuse
hi-ogawa Mar 28, 2026
5e97f69
chore: fix typecheck quirks
hi-ogawa Mar 28, 2026
7165ced
Merge branch 'main' into feat-support-custom-snapshot-matcher
hi-ogawa Mar 28, 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
4 changes: 4 additions & 0 deletions docs/guide/extending-matchers.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ function customMatcher(this: MatcherState, received: unknown, arg1: unknown, arg
expect.extend({ customMatcher })
```

::: tip
To build custom **snapshot matchers** (wrappers around `toMatchSnapshot` / `toMatchInlineSnapshot` / `toMatchFileSnapshot`), use the composable functions from `vitest/runtime`. See [Custom Snapshot Matchers](/guide/snapshot#custom-snapshot-matchers).
:::

Matcher function has access to `this` context with the following properties:

## `isNot`
Expand Down
30 changes: 30 additions & 0 deletions docs/guide/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,36 @@ export default defineConfig({

Otherwise your snapshots will have a lot of escaped `"` characters.

### Custom Snapshot Matchers

Jest imports snapshot composables from `jest-snapshot`. In Vitest, import from `vitest/runtime` instead:

```ts
const { toMatchSnapshot } = require('jest-snapshot') // [!code --]
import { toMatchSnapshot } from 'vitest/runtime' // [!code ++]

expect.extend({
toMatchTrimmedSnapshot(received: string, length: number) {
return toMatchSnapshot.call(this, received.slice(0, length))
},
})
```

For inline snapshots, the same applies:

```ts
const { toMatchInlineSnapshot } = require('jest-snapshot') // [!code --]
import { toMatchInlineSnapshot } from 'vitest/runtime' // [!code ++]

expect.extend({
toMatchTrimmedInlineSnapshot(received: string, inlineSnapshot?: string) {
return toMatchInlineSnapshot.call(this, received.slice(0, 10), inlineSnapshot)
},
})
```

See [Custom Snapshot Matchers](/guide/snapshot#custom-snapshot-matchers) for the full guide.

## Migrating from Mocha + Chai + Sinon {#mocha-chai-sinon}

Vitest provides excellent support for migrating from Mocha+Chai+Sinon test suites. While Vitest uses a Jest-compatible API by default, it also provides Chai-style assertions for spy/mock testing, making migration easier.
Expand Down
70 changes: 70 additions & 0 deletions docs/guide/snapshot.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,76 @@ Pretty foo: Object {

We are using Jest's `pretty-format` for serializing snapshots. You can read more about it here: [pretty-format](https://github.com/facebook/jest/blob/main/packages/pretty-format/README.md#serialize).

## Custom Snapshot Matchers

You can build custom snapshot matchers using the composable functions exported from `vitest/runtime`. These let you transform values before snapshotting while preserving full snapshot lifecycle support (creation, update, inline rewriting).

```ts
import { expect, test } from 'vitest'
import { toMatchFileSnapshot, toMatchInlineSnapshot, toMatchSnapshot } from 'vitest/runtime'

expect.extend({
toMatchTrimmedSnapshot(received: string, length: number) {
return toMatchSnapshot.call(this, received.slice(0, length))
},
toMatchTrimmedInlineSnapshot(received: string, inlineSnapshot?: string) {
return toMatchInlineSnapshot.call(this, received.slice(0, 10), inlineSnapshot)
},
async toMatchTrimmedFileSnapshot(received: string, file: string) {
return toMatchFileSnapshot.call(this, received.slice(0, 10), file)
},
})

test('file snapshot', () => {
expect('extra long string oh my gerd').toMatchTrimmedSnapshot(10)
})

test('inline snapshot', () => {
expect('extra long string oh my gerd').toMatchTrimmedInlineSnapshot()
})

test('raw file snapshot', async () => {
await expect('extra long string oh my gerd').toMatchTrimmedFileSnapshot('./raw-file.txt')
})
```

The composables return `{ pass, message }` so you can further customize the error:

```ts
expect.extend({
toMatchTrimmedSnapshot(received: string, length: number) {
const result = toMatchSnapshot.call(this, received.slice(0, length))
return { ...result, message: () => `Trimmed snapshot failed: ${result.message()}` }
},
})
```

::: warning
For inline snapshot matchers, the snapshot argument must be the last parameter (or second-to-last when using property matchers). Vitest rewrites the last string argument in the source code, so custom arguments before the snapshot work, but custom arguments after it are not supported.
:::

::: tip
File snapshot matchers must be `async` — `toMatchFileSnapshot` returns a `Promise`. Remember to `await` the result in the matcher and in your test.
:::

For TypeScript, extend the `Assertion` interface:

```ts
import 'vitest'

declare module 'vitest' {
interface Assertion<T = any> {
toMatchTrimmedSnapshot: (length: number) => T
toMatchTrimmedInlineSnapshot: (inlineSnapshot?: string) => T
toMatchTrimmedFileSnapshot: (file: string) => Promise<T>
}
}
```

::: tip
See [Extending Matchers](/guide/extending-matchers) for more on `expect.extend` and custom matcher conventions.
:::

## Difference from Jest

Vitest provides an almost compatible Snapshot feature with [Jest's](https://jestjs.io/docs/snapshot-testing) with a few exceptions:
Expand Down
5 changes: 3 additions & 2 deletions packages/expect/src/jest-extend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function getMatcherState(
suppressedErrors: [],
soft: util.flag(assertion, 'soft') as boolean | undefined,
poll: util.flag(assertion, 'poll') as boolean | undefined,
__vitest_assertion__: assertion as any,
}

return {
Expand Down Expand Up @@ -89,7 +90,7 @@ function JestExtendPlugin(
return (_, utils) => {
Object.entries(matchers).forEach(
([expectAssertionName, expectAssertion]) => {
function expectWrapper(
function __VITEST_EXTEND_ASSERTION__(
this: Chai.AssertionStatic & Chai.Assertion,
...args: any[]
) {
Expand Down Expand Up @@ -133,7 +134,7 @@ function JestExtendPlugin(
}
}

const softWrapper = wrapAssertion(utils, expectAssertionName, expectWrapper)
const softWrapper = wrapAssertion(utils, expectAssertionName, __VITEST_EXTEND_ASSERTION__)
utils.addMethod(
(globalThis as any)[JEST_MATCHERS_OBJECT].matchers,
expectAssertionName,
Expand Down
7 changes: 7 additions & 0 deletions packages/expect/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ export interface MatcherState {
}
soft?: boolean
poll?: boolean
/**
* this allows `expect.extend`-based custom matcher
* to implement builtin vitest/chai assertion equivalent feature.
* this used for custom snapshot matcher API.
*/
/** @internal */
__vitest_assertion__: Assertion
}

export interface SyncExpectationResult {
Expand Down
3 changes: 2 additions & 1 deletion packages/expect/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { noop } from '@vitest/utils/helpers'

export function createAssertionMessage(
util: Chai.ChaiUtils,
assertion: Assertion,
assertion: Chai.Assertion,
hasArgs: boolean,
) {
const soft = util.flag(assertion, 'soft') ? '.soft' : ''
Expand Down Expand Up @@ -92,6 +92,7 @@ function handleTestError(test: Test, err: unknown) {
test.result.errors.push(processError(err))
}

/** wrap assertion function to support `expect.soft` and provide assertion name as `_name` */
export function wrapAssertion(
utils: Chai.ChaiUtils,
name: string,
Expand Down
3 changes: 3 additions & 0 deletions packages/snapshot/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ interface AssertOptions {
error?: Error
errorMessage?: string
rawSnapshot?: RawSnapshotInfo
assertionName?: string
}

/** Same shape as expect.extend custom matcher result (SyncExpectationResult from @vitest/expect) */
Expand Down Expand Up @@ -119,6 +120,7 @@ export class SnapshotClient {
error,
errorMessage,
rawSnapshot,
assertionName,
} = options
let { received } = options

Expand Down Expand Up @@ -173,6 +175,7 @@ export class SnapshotClient {
error,
inlineSnapshot,
rawSnapshot,
assertionName,
})

return {
Expand Down
72 changes: 48 additions & 24 deletions packages/snapshot/src/port/inlineSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@ import {
offsetToLineNumber,
positionToOffset,
} from '@vitest/utils/offset'
import { memo } from './utils'

export interface InlineSnapshot {
snapshot: string
testId: string
file: string
line: number
column: number
// it maybe possible to accurately extract this from `ParsedStack.method`,
// but for now, we ask higher level assertion to pass it explicitly
// since this is useful for certain error messages before we extract stack.
assertionName?: string
}

export async function saveInlineSnapshots(
Expand All @@ -33,7 +38,7 @@ export async function saveInlineSnapshots(

for (const snap of snaps) {
const index = positionToOffset(code, snap.line, snap.column)
replaceInlineSnap(code, s, index, snap.snapshot)
replaceInlineSnap(code, s, index, snap.snapshot, snap.assertionName)
}

const transformed = s.toString()
Expand All @@ -44,17 +49,31 @@ export async function saveInlineSnapshots(
)
}

const startObjectRegex
const defaultStartObjectRegex
= /(?:toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot)\s*\(\s*(?:\/\*[\s\S]*\*\/\s*|\/\/.*(?:[\n\r\u2028\u2029]\s*|[\t\v\f \xA0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF]))*\{/

function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

const buildStartObjectRegex = memo((assertionName: string) => {
const replaced = defaultStartObjectRegex.source.replace(
'toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot',
escapeRegExp(assertionName),
)
return new RegExp(replaced)
})

function replaceObjectSnap(
code: string,
s: MagicString,
index: number,
newSnap: string,
assertionName?: string,
) {
let _code = code.slice(index)
const startMatch = startObjectRegex.exec(_code)
const regex = assertionName ? buildStartObjectRegex(assertionName) : defaultStartObjectRegex
const startMatch = regex.exec(_code)
if (!startMatch) {
return false
}
Expand Down Expand Up @@ -121,23 +140,17 @@ function prepareSnapString(snap: string, source: string, index: number) {
.replace(/\$\{/g, '\\${')}\n${indent}${quote}`
}

const toMatchInlineName = 'toMatchInlineSnapshot'
const toThrowErrorMatchingInlineName = 'toThrowErrorMatchingInlineSnapshot'
const defaultMethodNames = ['toMatchInlineSnapshot', 'toThrowErrorMatchingInlineSnapshot']

// on webkit, the line number is at the end of the method, not at the start
function getCodeStartingAtIndex(code: string, index: number) {
const indexInline = index - toMatchInlineName.length
if (code.slice(indexInline, index) === toMatchInlineName) {
return {
code: code.slice(indexInline),
index: indexInline,
}
}
const indexThrowInline = index - toThrowErrorMatchingInlineName.length
if (code.slice(index - indexThrowInline, index) === toThrowErrorMatchingInlineName) {
return {
code: code.slice(index - indexThrowInline),
index: index - indexThrowInline,
function getCodeStartingAtIndex(code: string, index: number, methodNames: string[]) {
for (const name of methodNames) {
const adjusted = index - name.length
if (adjusted >= 0 && code.slice(adjusted, index) === name) {
return {
code: code.slice(adjusted),
index: adjusted,
}
}
}
return {
Expand All @@ -146,24 +159,35 @@ function getCodeStartingAtIndex(code: string, index: number) {
}
}

const startRegex
const defaultStartRegex
= /(?:toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot)\s*\(\s*(?:\/\*[\s\S]*\*\/\s*|\/\/.*(?:[\n\r\u2028\u2029]\s*|[\t\v\f \xA0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF]))*[\w$]*(['"`)])/

const buildStartRegex = memo((assertionName: string) => {
const replaced = defaultStartRegex.source.replace(
'toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot',
escapeRegExp(assertionName),
)
return new RegExp(replaced)
})

export function replaceInlineSnap(
code: string,
s: MagicString,
currentIndex: number,
newSnap: string,
assertionName?: string,
): boolean {
const { code: codeStartingAtIndex, index } = getCodeStartingAtIndex(code, currentIndex)
const methodNames = assertionName ? [assertionName] : defaultMethodNames
const { code: codeStartingAtIndex, index } = getCodeStartingAtIndex(code, currentIndex, methodNames)

const startRegex = assertionName ? buildStartRegex(assertionName) : defaultStartRegex
const startMatch = startRegex.exec(codeStartingAtIndex)

const firstKeywordMatch = /toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot/.exec(
codeStartingAtIndex,
)
const keywordRegex = assertionName ? new RegExp(escapeRegExp(assertionName)) : /toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot/
const firstKeywordMatch = keywordRegex.exec(codeStartingAtIndex)

if (!startMatch || startMatch.index !== firstKeywordMatch?.index) {
return replaceObjectSnap(code, s, index, newSnap)
return replaceObjectSnap(code, s, index, newSnap, assertionName)
}

const quote = startMatch[1]
Expand Down
Loading
Loading