第5章 Vitest 快照测试详解
快照测试
提示:如需入门级快照测试介绍,请参阅快照测试教程。也可以通过 Vue School 的视频学习快照功能。
当你需要确保函数输出不会发生意外变化时,快照测试是非常有用的工具。
使用快照时,Vitest 会对给定值生成快照,然后将其与存储在工作目录下的参考快照文件进行对比。如果两者不匹配,测试将失败:这意味着变化是意外的,或者参考快照需要更新到结果的版本。
使用快照
要对某个值进行快照测试,可以使用 expect() API 中的 toMatchSnapshot():
import { expect, it } from 'vitest'
it('toUpperCase', () => {
const result = toUpperCase('foobar')
expect(result).toMatchSnapshot()
})
第一次运行该测试时,Vitest 会创建一个如下所示的快照文件:
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports['toUpperCase 1'] = '"FOOBAR"'
快照产物应与代码变更一起提交,并纳入代码审查流程。在后续测试运行中,Vitest 会将渲染输出与前一个快照进行比较。如果匹配,测试通过;如果不匹配,要么是测试运行器发现了代码中的 bug 需要修复,要么是实现已更改,需要更新快照。
Vitest 存储接收值的序列化表示形式。快照渲染由 @vitest/pretty-format 提供支持。snapshotFormat 允许在 Vitest 中配置一般的快照格式化行为。如需进一步自定义,可以实现自定义序列化器或自定义快照匹配器。
警告:在异步并发测试中使用快照时,必须使用本地测试上下文中的 expect,以确保检测的是正确的测试。
内联快照
类似地,可以使用 toMatchInlineSnapshot() 将快照内联存储在测试文件中。
import { expect, it } from 'vitest'
it('toUpperCase', () => {
const result = toUpperCase('foobar')
expect(result).toMatchInlineSnapshot()
})
Vitest 不会创建快照文件,而是直接修改测试文件,将快照作为字符串更新:
import { expect, it } from 'vitest'
it('toUpperCase', () => {
const result = toUpperCase('foobar')
expect(result).toMatchInlineSnapshot('"FOOBAR"')
})
这样无需跳转不同文件即可查看预期输出。
警告:在异步并发测试中使用快照时,必须使用本地测试上下文中的 expect,以确保检测的是正确的测试。
更新快照
当接收的值与快照不匹配时,测试失败并显示差异。如果快照的变化是预期的,可能需要从当前状态更新快照。
在监听模式下,可以在终端按 u 键直接更新失败的快照。
或者使用 CLI 中的 --update 或 -u 标志让 Vitest 更新快照。
vitest -u
CI 行为
默认情况下,Vitest 在 CI 环境(process.env.CI 为真值)中不写入快照,任何快照不匹配、缺失或过时的快照都会导致运行失败。详情参见更新文档。
过时快照是指不再匹配任何已收集测试的快照条目(或快照文件)。这通常发生在删除或重命名测试之后。
文件快照
调用 toMatchSnapshot() 时,所有快照都存储在格式化的 snap 文件中。这意味着需要在快照字符串中转义某些字符(具体是双引号 " 和反引号 `)。同时,如果快照内容属于某种语言,可能会失去语法高亮。
鉴于此,我们引入了 toMatchFileSnapshot() 以显式针对文件进行匹配。这允许为快照文件分配任意扩展名,使其更具可读性。
import { expect, it } from 'vitest'
it('render basic', async () => {
const result = renderHTML(h('div', { class: 'foo' }))
await expect(result).toMatchFileSnapshot('./test/basic.output.html')
})
它会与 ./test/basic.output.html 的内容进行比较。可以使用 --update 标志写回。
警告:不要将 Vitest 管理的快照路径(如 __snapshots__/basic.test.ts.snap)用于 toMatchFileSnapshot。请为文件快照选择单独的文件路径模式。
视觉快照
对于 UI 组件和页面的视觉回归测试,Vitest 通过浏览器模式和 toMatchScreenshot() 断言提供内置支持:
import { expect, test } from 'vitest'
import { page } from 'vitest/browser'
test('button looks correct', async () => {
const button = page.getByRole('button')
await expect(button).toMatchScreenshot('primary-button')
})
这会截取屏幕截图并将其与参考图像进行比较,以检测非预期的视觉变化。更多详情请参阅视觉回归测试指南。
ARIA 快照 实验性功能 4.1.4+
ARIA 快照捕获 DOM 元素的无障碍树,并将其与存储的模板进行比较。基于 Playwright 的 ARIA 快照,它们提供了视觉回归测试的语义替代方案——断言结构和含义,而非像素。
例如,给定以下 HTML:
<nav aria-label="Main">
<a href="https://vitest.dev/">Home</a>
<a href="https://vitest.dev/about">About</a>
</nav>
你可以断言其无障碍树:
import { expect, test } from 'vitest'
import { page } from 'vitest/browser'
test('navigation structure', async () => {
await expect.element(page.getByRole('navigation')).toMatchAriaInlineSnapshot(`
- navigation "Main":
- link "Home":
- /url: /
- link "About":
- /url: /about
`)
})
语法细节、浏览器模式中的重试行为以及文件与内联快照示例,请参阅专门的 ARIA 快照指南。完整 API 参考请参阅 toMatchAriaSnapshot 和 toMatchAriaInlineSnapshot。
自定义序列化器
你可以添加自己的逻辑来改变快照的序列化方式。与 Jest 类似,Vitest 为内置 JavaScript 类型、HTML 元素、ImmutableJS 和 React 元素提供默认序列化器。
可以使用 expect.addSnapshotSerializer API 显式添加自定义序列化器。
expect.addSnapshotSerializer({
serialize(val, config, indentation, depth, refs, printer) {
// `printer` 是一个使用现有插件序列化值的函数。
return `Pretty foo: ${printer(
val.foo,
config,
indentation,
depth,
refs,
)}`
},
test(val) {
return val && Object.prototype.hasOwnProperty.call(val, 'foo')
},
})
我们还支持 snapshotSerializers 选项来隐式添加自定义序列化器。
// path/to/custom-serializer.ts
import { SnapshotSerializer } from 'vitest'
export default {
serialize(val, config, indentation, depth, refs, printer) {
// `printer` 是一个使用现有插件序列化值的函数。
return `Pretty foo: ${printer(
val.foo,
config,
indentation,
depth,
refs,
)}`
},
test(val) {
return val && Object.prototype.hasOwnProperty.call(val, 'foo')
},
} satisfies SnapshotSerializer
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
snapshotSerializers: ['path/to/custom-serializer.ts'],
},
})
添加如下测试后:
test('foo snapshot test', () => {
const bar = {
foo: {
x: 1,
y: 2,
},
}
expect(bar).toMatchSnapshot()
})
你将得到以下快照:
Pretty foo: Object {
"x": 1,
"y": 2,
}
自定义快照匹配器 实验性功能 4.1.3+
可以使用从 Vitest 中暴露的 Snapshots 组合函数构建自定义快照匹配器。这允许你在进行快照之前转换值,同时保留完整的快照生命周期支持(创建、更新、内联重写)。
import { expect, test, Snapshots } from 'vitest'
const { toMatchFileSnapshot, toMatchInlineSnapshot, toMatchSnapshot } = Snapshots
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', () => {
// 创建 __snapshots__/demo.test.ts,内容为
// > exports[`file snapshot 1`] = `"extra long"`
expect('extra long string oh my gerd').toMatchTrimmedSnapshot(10)
})
test('inline snapshot', () => {
expect('super long string oh my gerd').toMatchTrimmedInlineSnapshot(`"super long"`)
})
test('raw file snapshot', async () => {
// 创建 raw-file.txt,内容为:
// > crazy long
await expect('crazy long string oh my gerd').toMatchTrimmedFileSnapshot('./raw-file.txt')
})
组合函数返回 { pass, message },以便进一步自定义错误信息:
import { Snapshots } from 'vitest'
const { toMatchSnapshot } = Snapshots
expect.extend({
toMatchTrimmedSnapshot(received: string, length: number) {
const result = toMatchSnapshot.call(this, received.slice(0, length))
return { ...result, message: () => `Trimmed snapshot failed: ${result.message()}` }
},
})
警告:对于内联快照匹配器,快照参数必须是最后一个参数(使用属性匹配器时为倒数第二个)。Vitest 会重写源代码中的最后一个字符串参数,因此快照之前的自定义参数有效,但快照之后的自定义参数不支持。
提示:文件快照匹配器必须是异步的——toMatchFileSnapshot 返回一个 Promise。记得在匹配器和测试中 await 结果。
警告:当自定义内联快照匹配器是异步时,Vitest 无法自动推断用于内联快照重写的调用位置。必须在 chai 断言对象上设置 'error' 标志以捕获调用位置:
import { expect, chai, Snapshots } from 'vitest'
const { toMatchInlineSnapshot } = Snapshots
expect.extend({
async toMatchTransformedInlineSnapshot(received: string, inlineSnapshot?: string) {
// 在匹配器实现顶部同步捕获调用位置
chai.util.flag(this.assertion, 'error', new Error())
const transformed = await transform(received)
return toMatchInlineSnapshot.call(this, transformed, inlineSnapshot)
},
})
对于 TypeScript,扩展 Matchers<R, T> 接口:
import 'vitest'
declare module 'vitest' {
interface Matchers<R, T> {
toMatchTrimmedSnapshot: (length: number) => R
toMatchTrimmedInlineSnapshot: (inlineSnapshot?: string) => R
toMatchTrimmedFileSnapshot: (file: string) => Promise<void>
}
}
提示:关于 expect.extend 和自定义匹配器约定,请参阅扩展匹配器文档。
自定义快照领域 实验性功能 4.1.4+
自定义序列化器控制值如何渲染为快照字符串,但比较仍然是字符串相等性。领域快照适配器更进一步:它拥有自定义匹配器的整个比较流水线,包括如何捕获值、渲染值、解析存储的快照以及语义匹配。
适配器接口
领域适配器实现四个方法,并泛型化两个类型——Captured(值实际是什么)和 Expected(存储的快照解析后是什么):
import type { DomainMatchResult, DomainSnapshotAdapter } from 'vitest'
const myAdapter: DomainSnapshotAdapter<Captured, Expected> = {
name: 'my-domain',
// 从接收值中提取结构化数据
capture(received: unknown): Captured { /* ... */ },
// 将捕获的数据渲染为快照字符串(即存储的内容)
render(captured: Captured): string { /* ... */ },
// 将存储的快照字符串解析为结构化的预期值
parseExpected(input: string): Expected { /* ... */ },
// 比较捕获值与预期值,返回通过/失败及解析后的输出
match(captured: Captured, expected: Expected): DomainMatchResult { /* ... */ },
}
DomainMatchResult
match 方法返回一个 DomainMatchResult,除 pass 外还有两个可选字符串字段:
resolved— 透过模板视角查看捕获值。模板使用模式(例如正则表达式)或省略细节的地方,解析后的字符串会采用这些模式。模板不匹配的地方,则使用字面捕获值。这既用作差异的实际侧,也用作--update时写入的值。如果省略,则回退到render(capture(received))。expected— 存储的模板重新渲染为字符串。用作差异的预期侧。如果省略,则回退到来自 snap 文件或内联快照的原始快照字符串。
为什么 Captured 和 Expected 是分离的类型?
快照首次生成时,render(captured) 产生一个纯字符串并存储。但一旦存储,用户就可以手动编辑它——将字面量替换为正则模式、放宽断言或添加领域特定的查询语法。编辑后,parseExpected(input) 将此修改后的字符串解析为比 capture 产生的类型更丰富的类型。
例如,在下面的键值适配器中,Captured 值始终是 string,但 Expected 值可以是 string | RegExp:
type KVCaptured = Record<string, string>
type KVExpected = Record<string, string | RegExp>
这种不对称性使 --update 能够正确工作:match 返回一个解析后的字符串,更新更改的字面量部分,同时保留用户手动编辑的模式。如果两侧是相同类型,就无法区分“值实际是什么”和“用户选择断言什么”——每次更新都会覆盖用户的模式。
从适配器构建匹配器
使用 expect.extend(...) 注册自定义匹配器,并调用 Vitest 中的快照组合函数:
// setup.ts
import { expect, Snapshots } from 'vitest'
declare module 'vitest' {
interface Matchers<R, T> {
toMatchMyDomainSnapshot: () => R
toMatchMyDomainInlineSnapshot: (inlineSnapshot?: string) => R
}
}
expect.extend({
toMatchMyDomainSnapshot(received: unknown) {
return Snapshots.toMatchDomainSnapshot.call(this, myAdapter, received)
},
toMatchMyDomainInlineSnapshot(received: unknown, inlineSnapshot?: string) {
return Snapshots.toMatchDomainInlineSnapshot.call(
this,
myAdapter,
received,
inlineSnapshot,
)
},
})
然后在测试中使用你的匹配器:
expect(value).toMatchMyDomainSnapshot()
expect(value).toMatchMyDomainInlineSnapshot(`key=value`)
示例:键值适配器
一个最小适配器,将对象存储为 key=value 行,支持正则模式和子集键匹配(完整源码):
// kv-adapter.ts
import type { DomainMatchResult, DomainSnapshotAdapter } from 'vitest'
type KVCaptured = Record<string, string>
type KVExpected = Record<string, string | RegExp>
function renderKV(obj: Record<string, unknown>) {
return `\n${Object.entries(obj).map(([k, v]) => `${k}=${v}`).join('\n')}\n`
}
export const kvAdapter: DomainSnapshotAdapter<KVCaptured, KVExpected> = {
name: 'kv',
capture(received: unknown): KVCaptured {
if (received && typeof received === 'object') {
return Object.fromEntries(
Object.entries(received).map(([k, v]) => [k, String(v)]),
)
}
throw new TypeError('kv adapter expects a plain object')
},
render(captured: KVCaptured): string {
return renderKV(captured)
},
parseExpected(input: string): KVExpected {
const entries = input.trim().split('\n').map((line) => {
const eq = line.indexOf('=')
const key = line.slice(0, eq)
const raw = line.slice(eq + 1)
const value = (raw.startsWith('/') && raw.endsWith('/') && raw.length > 1)
? new RegExp(raw.slice(1, -1))
: raw
return [key, value]
})
return Object.fromEntries(entries)
},
match(captured: KVCaptured, expected: KVExpected): DomainMatchResult {
const resolvedLines: string[] = []
let pass = true
for (const [key, actualValue] of Object.entries(captured)) {
const expectedValue = expected[key]
// 未断言的键被跳过(作为子集匹配工作)
if (typeof expectedValue === 'undefined') {
continue
}
// 保留匹配的模式用于标准化差异和部分更新
if (expectedValue instanceof RegExp && expectedValue.test(actualValue)) {
resolvedLines.push(`${key}=/${expectedValue.source}/`)
continue
}
resolvedLines.push(`${key}=${actualValue}`)
pass &&= actualValue === expectedValue
}
return {
pass,
message: pass ? undefined : 'KV entries do not match',
resolved: `\n${resolvedLines.join('\n')}\n`,
expected: `\n${renderKV(expected)}\n`,
}
},
}
// setup.ts
import { expect, Snapshots } from 'vitest'
import { kvAdapter } from './kv-adapter'
declare module 'vitest' {
interface Matchers<R, T> {
toMatchKvSnapshot: () => R
toMatchKvInlineSnapshot: (inlineSnapshot?: string) => R
}
}
expect.extend({
toMatchKvSnapshot(received: unknown) {
return Snapshots.toMatchDomainSnapshot.call(this, kvAdapter, received)
},
toMatchKvInlineSnapshot(received: unknown, inlineSnapshot?: string) {
return Snapshots.toMatchDomainInlineSnapshot.call(this, kvAdapter, received, inlineSnapshot)
},
})
// example.test.ts
import { expect, test } from 'vitest'
test('user data', () => {
const user = { name: 'Alice', score: '42' }
expect(user).toMatchKvSnapshot()
})
test('user data inline', () => {
const user = { name: 'Alice', age: 100, score: '42' }
expect(user).toMatchKvInlineSnapshot(`
name=Alice
score=/\\d+/
`)
})
与 Jest 的区别
Vitest 提供了与 Jest 几乎兼容的快照功能,但有以下几点例外:
1. 快照文件中的注释头不同
- // Jest Snapshot v1, https://goo.gl/fbAQLP
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
这不会影响功能,但可能会影响从 Jest 迁移时的提交差异。
2. printBasicPrototype 默认为 false
Jest 和 Vitest 的快照都由 pretty-format 提供支持,但 Vitest 在 @vitest/pretty-format 之上应用了自己的快照默认值。特别是,Vitest 将 printBasicPrototype 设置为 false 以提供更整洁的快照输出,而在 Jest <29.0.0 中默认为 true。
import { expect, test } from 'vitest'
test('snapshot', () => {
const bar = [
{
foo: 'bar',
},
]
// 在 Jest 中
expect(bar).toMatchInlineSnapshot(`
Array [
Object {
"foo": "bar",
},
]
`)
// 在 Vitest 中
expect(bar).toMatchInlineSnapshot(`
[
{
"foo": "bar",
},
]
`)
})
我们认为这是可读性和整体开发体验的更合理默认值。如果你仍然喜欢 Jest 的行为,可以更改配置:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
snapshotFormat: {
printBasicPrototype: true,
},
},
})
3. 使用大于号 > 作为分隔符代替冒号 : 用于自定义消息
为了可读性,Vitest 在创建快照文件时,若传递了自定义消息,使用大于号 > 作为分隔符代替冒号 :。
对于以下示例测试代码:
test('toThrowErrorMatchingSnapshot', () => {
expect(() => {
throw new Error('error')
}).toThrowErrorMatchingSnapshot('hint')
})
在 Jest 中,快照将是:
exports[`toThrowErrorMatchingSnapshot: hint 1`] = `"error"`;
在 Vitest 中,等效的快照将是:
exports[`toThrowErrorMatchingSnapshot > hint 1`] = `[Error: error]`;
4. toThrowErrorMatchingSnapshot 和 toThrowErrorMatchingInlineSnapshot 的默认 Error 快照不同
import { expect, test } from 'vitest'
test('snapshot', () => {
// 在 Jest 和 Vitest 中
expect(new Error('error')).toMatchInlineSnapshot(`[Error: error]`)
// Jest 为 `Error` 实例快照 `Error.message`
// Vitest 打印与 toMatchInlineSnapshot 相同的值
expect(() => {
throw new Error('error')
}).toThrowErrorMatchingInlineSnapshot(`"error"`)
}).toThrowErrorMatchingInlineSnapshot(`[Error: error]`)
})