Skip to content

Commit 59b0e64

Browse files
hi-ogawaclaudecodex
authored
feat(experimental/snapshot): support custom snapshot matcher (#9973)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com>
1 parent 398657e commit 59b0e64

File tree

17 files changed

+867
-151
lines changed

17 files changed

+867
-151
lines changed

docs/guide/extending-matchers.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ function customMatcher(this: MatcherState, received: unknown, arg1: unknown, arg
107107
expect.extend({ customMatcher })
108108
```
109109

110+
::: tip
111+
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).
112+
:::
113+
110114
Matcher function has access to `this` context with the following properties:
111115

112116
## `isNot`

docs/guide/migration.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,36 @@ export default defineConfig({
650650

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

653+
### Custom Snapshot Matchers <Badge type="warning">experimental</Badge> <Version>4.1.3</Version>
654+
655+
Jest imports snapshot composables from `jest-snapshot`. In Vitest, import from `vitest/runtime` instead:
656+
657+
```ts
658+
const { toMatchSnapshot } = require('jest-snapshot') // [!code --]
659+
import { toMatchSnapshot } from 'vitest/runtime' // [!code ++]
660+
661+
expect.extend({
662+
toMatchTrimmedSnapshot(received: string, length: number) {
663+
return toMatchSnapshot.call(this, received.slice(0, length))
664+
},
665+
})
666+
```
667+
668+
For inline snapshots, the same applies:
669+
670+
```ts
671+
const { toMatchInlineSnapshot } = require('jest-snapshot') // [!code --]
672+
import { toMatchInlineSnapshot } from 'vitest/runtime' // [!code ++]
673+
674+
expect.extend({
675+
toMatchTrimmedInlineSnapshot(received: string, inlineSnapshot?: string) {
676+
return toMatchInlineSnapshot.call(this, received.slice(0, 10), inlineSnapshot)
677+
},
678+
})
679+
```
680+
681+
See [Custom Snapshot Matchers](/guide/snapshot#custom-snapshot-matchers) for the full guide.
682+
653683
## Migrating from Mocha + Chai + Sinon {#mocha-chai-sinon}
654684

655685
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.

docs/guide/snapshot.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,76 @@ Pretty foo: Object {
200200

201201
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).
202202

203+
## Custom Snapshot Matchers <Badge type="warning">experimental</Badge> <Version>4.1.3</Version> {#custom-snapshot-matchers}
204+
205+
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).
206+
207+
```ts
208+
import { expect, test } from 'vitest'
209+
import { toMatchFileSnapshot, toMatchInlineSnapshot, toMatchSnapshot } from 'vitest/runtime'
210+
211+
expect.extend({
212+
toMatchTrimmedSnapshot(received: string, length: number) {
213+
return toMatchSnapshot.call(this, received.slice(0, length))
214+
},
215+
toMatchTrimmedInlineSnapshot(received: string, inlineSnapshot?: string) {
216+
return toMatchInlineSnapshot.call(this, received.slice(0, 10), inlineSnapshot)
217+
},
218+
async toMatchTrimmedFileSnapshot(received: string, file: string) {
219+
return toMatchFileSnapshot.call(this, received.slice(0, 10), file)
220+
},
221+
})
222+
223+
test('file snapshot', () => {
224+
expect('extra long string oh my gerd').toMatchTrimmedSnapshot(10)
225+
})
226+
227+
test('inline snapshot', () => {
228+
expect('extra long string oh my gerd').toMatchTrimmedInlineSnapshot()
229+
})
230+
231+
test('raw file snapshot', async () => {
232+
await expect('extra long string oh my gerd').toMatchTrimmedFileSnapshot('./raw-file.txt')
233+
})
234+
```
235+
236+
The composables return `{ pass, message }` so you can further customize the error:
237+
238+
```ts
239+
expect.extend({
240+
toMatchTrimmedSnapshot(received: string, length: number) {
241+
const result = toMatchSnapshot.call(this, received.slice(0, length))
242+
return { ...result, message: () => `Trimmed snapshot failed: ${result.message()}` }
243+
},
244+
})
245+
```
246+
247+
::: warning
248+
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.
249+
:::
250+
251+
::: tip
252+
File snapshot matchers must be `async``toMatchFileSnapshot` returns a `Promise`. Remember to `await` the result in the matcher and in your test.
253+
:::
254+
255+
For TypeScript, extend the `Assertion` interface:
256+
257+
```ts
258+
import 'vitest'
259+
260+
declare module 'vitest' {
261+
interface Assertion<T = any> {
262+
toMatchTrimmedSnapshot: (length: number) => T
263+
toMatchTrimmedInlineSnapshot: (inlineSnapshot?: string) => T
264+
toMatchTrimmedFileSnapshot: (file: string) => Promise<T>
265+
}
266+
}
267+
```
268+
269+
::: tip
270+
See [Extending Matchers](/guide/extending-matchers) for more on `expect.extend` and custom matcher conventions.
271+
:::
272+
203273
## Difference from Jest
204274

205275
Vitest provides an almost compatible Snapshot feature with [Jest's](https://jestjs.io/docs/snapshot-testing) with a few exceptions:

packages/expect/src/jest-extend.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ function getMatcherState(
5353
suppressedErrors: [],
5454
soft: util.flag(assertion, 'soft') as boolean | undefined,
5555
poll: util.flag(assertion, 'poll') as boolean | undefined,
56+
__vitest_assertion__: assertion as any,
5657
}
5758
Object.assign(matcherState, { task })
5859

@@ -89,7 +90,7 @@ function JestExtendPlugin(
8990
return (_, utils) => {
9091
Object.entries(matchers).forEach(
9192
([expectAssertionName, expectAssertion]) => {
92-
function expectWrapper(
93+
function __VITEST_EXTEND_ASSERTION__(
9394
this: Chai.AssertionStatic & Chai.Assertion,
9495
...args: any[]
9596
) {
@@ -133,7 +134,7 @@ function JestExtendPlugin(
133134
}
134135
}
135136

136-
const softWrapper = wrapAssertion(utils, expectAssertionName, expectWrapper)
137+
const softWrapper = wrapAssertion(utils, expectAssertionName, __VITEST_EXTEND_ASSERTION__)
137138
utils.addMethod(
138139
(globalThis as any)[JEST_MATCHERS_OBJECT].matchers,
139140
expectAssertionName,

packages/expect/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ export interface MatcherState {
8282
}
8383
soft?: boolean
8484
poll?: boolean
85+
/**
86+
* this allows `expect.extend`-based custom matcher
87+
* to implement builtin vitest/chai assertion equivalent feature.
88+
* this used for custom snapshot matcher API.
89+
*/
90+
/** @internal */
91+
__vitest_assertion__: Assertion
8592
}
8693

8794
export interface SyncExpectationResult {

packages/expect/src/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { noop } from '@vitest/utils/helpers'
55

66
export function createAssertionMessage(
77
util: Chai.ChaiUtils,
8-
assertion: Assertion,
8+
assertion: Chai.Assertion,
99
hasArgs: boolean,
1010
) {
1111
const soft = util.flag(assertion, 'soft') ? '.soft' : ''
@@ -92,6 +92,7 @@ function handleTestError(test: Test, err: unknown) {
9292
test.result.errors.push(processError(err))
9393
}
9494

95+
/** wrap assertion function to support `expect.soft` and provide assertion name as `_name` */
9596
export function wrapAssertion(
9697
utils: Chai.ChaiUtils,
9798
name: string,

packages/snapshot/src/client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ interface AssertOptions {
4848
error?: Error
4949
errorMessage?: string
5050
rawSnapshot?: RawSnapshotInfo
51+
assertionName?: string
5152
}
5253

5354
/** Same shape as expect.extend custom matcher result (SyncExpectationResult from @vitest/expect) */
@@ -119,6 +120,7 @@ export class SnapshotClient {
119120
error,
120121
errorMessage,
121122
rawSnapshot,
123+
assertionName,
122124
} = options
123125
let { received } = options
124126

@@ -173,6 +175,7 @@ export class SnapshotClient {
173175
error,
174176
inlineSnapshot,
175177
rawSnapshot,
178+
assertionName,
176179
})
177180

178181
return {

packages/snapshot/src/port/inlineSnapshot.ts

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,18 @@ import {
66
offsetToLineNumber,
77
positionToOffset,
88
} from '@vitest/utils/offset'
9+
import { memo } from './utils'
910

1011
export interface InlineSnapshot {
1112
snapshot: string
1213
testId: string
1314
file: string
1415
line: number
1516
column: number
17+
// it maybe possible to accurately extract this from `ParsedStack.method`,
18+
// but for now, we ask higher level assertion to pass it explicitly
19+
// since this is useful for certain error messages before we extract stack.
20+
assertionName?: string
1621
}
1722

1823
export async function saveInlineSnapshots(
@@ -33,7 +38,7 @@ export async function saveInlineSnapshots(
3338

3439
for (const snap of snaps) {
3540
const index = positionToOffset(code, snap.line, snap.column)
36-
replaceInlineSnap(code, s, index, snap.snapshot)
41+
replaceInlineSnap(code, s, index, snap.snapshot, snap.assertionName)
3742
}
3843

3944
const transformed = s.toString()
@@ -44,17 +49,31 @@ export async function saveInlineSnapshots(
4449
)
4550
}
4651

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

55+
function escapeRegExp(s: string): string {
56+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
57+
}
58+
59+
const buildStartObjectRegex = memo((assertionName: string) => {
60+
const replaced = defaultStartObjectRegex.source.replace(
61+
'toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot',
62+
escapeRegExp(assertionName),
63+
)
64+
return new RegExp(replaced)
65+
})
66+
5067
function replaceObjectSnap(
5168
code: string,
5269
s: MagicString,
5370
index: number,
5471
newSnap: string,
72+
assertionName?: string,
5573
) {
5674
let _code = code.slice(index)
57-
const startMatch = startObjectRegex.exec(_code)
75+
const regex = assertionName ? buildStartObjectRegex(assertionName) : defaultStartObjectRegex
76+
const startMatch = regex.exec(_code)
5877
if (!startMatch) {
5978
return false
6079
}
@@ -121,23 +140,17 @@ function prepareSnapString(snap: string, source: string, index: number) {
121140
.replace(/\$\{/g, '\\${')}\n${indent}${quote}`
122141
}
123142

124-
const toMatchInlineName = 'toMatchInlineSnapshot'
125-
const toThrowErrorMatchingInlineName = 'toThrowErrorMatchingInlineSnapshot'
143+
const defaultMethodNames = ['toMatchInlineSnapshot', 'toThrowErrorMatchingInlineSnapshot']
126144

127145
// on webkit, the line number is at the end of the method, not at the start
128-
function getCodeStartingAtIndex(code: string, index: number) {
129-
const indexInline = index - toMatchInlineName.length
130-
if (code.slice(indexInline, index) === toMatchInlineName) {
131-
return {
132-
code: code.slice(indexInline),
133-
index: indexInline,
134-
}
135-
}
136-
const indexThrowInline = index - toThrowErrorMatchingInlineName.length
137-
if (code.slice(index - indexThrowInline, index) === toThrowErrorMatchingInlineName) {
138-
return {
139-
code: code.slice(index - indexThrowInline),
140-
index: index - indexThrowInline,
146+
function getCodeStartingAtIndex(code: string, index: number, methodNames: string[]) {
147+
for (const name of methodNames) {
148+
const adjusted = index - name.length
149+
if (adjusted >= 0 && code.slice(adjusted, index) === name) {
150+
return {
151+
code: code.slice(adjusted),
152+
index: adjusted,
153+
}
141154
}
142155
}
143156
return {
@@ -146,24 +159,35 @@ function getCodeStartingAtIndex(code: string, index: number) {
146159
}
147160
}
148161

149-
const startRegex
162+
const defaultStartRegex
150163
= /(?:toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot)\s*\(\s*(?:\/\*[\s\S]*\*\/\s*|\/\/.*(?:[\n\r\u2028\u2029]\s*|[\t\v\f \xA0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF]))*[\w$]*(['"`)])/
164+
165+
const buildStartRegex = memo((assertionName: string) => {
166+
const replaced = defaultStartRegex.source.replace(
167+
'toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot',
168+
escapeRegExp(assertionName),
169+
)
170+
return new RegExp(replaced)
171+
})
172+
151173
export function replaceInlineSnap(
152174
code: string,
153175
s: MagicString,
154176
currentIndex: number,
155177
newSnap: string,
178+
assertionName?: string,
156179
): boolean {
157-
const { code: codeStartingAtIndex, index } = getCodeStartingAtIndex(code, currentIndex)
180+
const methodNames = assertionName ? [assertionName] : defaultMethodNames
181+
const { code: codeStartingAtIndex, index } = getCodeStartingAtIndex(code, currentIndex, methodNames)
158182

183+
const startRegex = assertionName ? buildStartRegex(assertionName) : defaultStartRegex
159184
const startMatch = startRegex.exec(codeStartingAtIndex)
160185

161-
const firstKeywordMatch = /toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot/.exec(
162-
codeStartingAtIndex,
163-
)
186+
const keywordRegex = assertionName ? new RegExp(escapeRegExp(assertionName)) : /toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot/
187+
const firstKeywordMatch = keywordRegex.exec(codeStartingAtIndex)
164188

165189
if (!startMatch || startMatch.index !== firstKeywordMatch?.index) {
166-
return replaceObjectSnap(code, s, index, newSnap)
190+
return replaceObjectSnap(code, s, index, newSnap, assertionName)
167191
}
168192

169193
const quote = startMatch[1]

0 commit comments

Comments
 (0)