1. 项目概述:为什么快照测试不是“拍张照片”那么简单

Jest Snapshot Testing 在 Vue.js 项目里,常被新手误读成“自动截图存档”,甚至有人在团队群里发问:“我改了按钮文字,快照就红了,是不是该直接 npm run test -- -u 更新它?”——这恰恰暴露了对快照测试本质的严重误解。它根本不是 UI 截图,而是一份 组件渲染输出的结构化快照(serialized snapshot) ,本质是 JSON 格式的虚拟 DOM 树序列化结果。你看到的 .snap 文件里那一长串带引号、括号和嵌套层级的文本,就是 Vue 组件在特定 props 和 slots 下,经 @vue/test-utils 渲染后生成的 VNode 描述对象 的字符串化表达。它不依赖浏览器环境,不走真实 DOM 渲染流程,而是通过 Jest 的 jsdom 模拟环境 + Vue 的编译器 + 渲染器协同完成。这意味着:快照测试跑得快(毫秒级),可复现(无网络/时间戳干扰),但代价是——它只验证“输出是否和上次一样”,不验证“输出是否正确”。所以它天然适合做回归防护,而非功能验收。我在三个中大型 Vue 2/3 项目里踩过坑:曾因未理解快照的“结构敏感性”,把一个 <div class="btn"> 改成 <button class="btn"> ,快照立刻失败;也曾在 CI 环境里因 jest.config.js testEnvironment 配置为 node (而非 jsdom )导致所有快照为空对象 {} ,整整排查两天才定位到环境配置错误。快照测试真正的价值,在于它用极低的维护成本,守住组件 API 的“契约稳定性”——只要你的组件对外暴露的 props、emits、slots 不变,内部实现怎么重构,快照都稳如磐石。它不是替代单元测试,而是和 mount + expect(wrapper.find(...).text()).toBe(...) 这类断言形成互补:前者防“结构漂移”,后者保“行为正确”。

2. 快照测试底层原理与 Vue 特性适配解析

2.1 快照如何生成:从 Vue 组件到 .snap 文件的完整链路

快照生成绝非黑盒操作。以一个最简 Vue 单文件组件 Button.vue 为例:

<template>
  <button :class="['btn', type]" :disabled="disabled" @click="$emit('click')">
    <slot>{{ label }}</slot>
  </button>
</template>

<script>
export default {
  name: 'BaseButton',
  props: {
    label: { type: String, default: 'Click me' },
    type: { type: String, default: 'primary' },
    disabled: { type: Boolean, default: false }
  }
}
</script>

当执行 expect(wrapper.html()).toMatchSnapshot() 时,实际发生的是以下四步精密协作:

  1. Vue 渲染阶段 @vue/test-utils mount() 方法调用 Vue 的 createApp() 创建应用实例,传入组件选项,触发 render() 函数生成 VNode 树。此时 VNode 是纯 JS 对象,包含 type (元素名或组件)、 props (属性对象)、 children (子节点数组)等字段。

  2. 序列化阶段 :Jest 内置的 pretty-format 库接管。它并非简单 JSON.stringify() ,而是深度遍历 VNode 树,对每个节点进行标准化处理:

    • 过滤掉 Vue 内部私有属性(如 __v_isVNode , _isCompat );
    • props 中的函数(如 @click 绑定的 $emit )替换为 [Function] 字符串占位符;
    • children 中的文本节点( TextVNode )提取其 content 值, ElementVNode 则递归处理;
    • class style 等对象属性,按字母序排序键名,确保顺序一致(避免因对象属性遍历顺序不同导致快照误报)。
  3. 快照存储阶段 :序列化后的 JSON 对象被写入 __snapshots__/Button.spec.js.snap 文件。文件内容类似:

    "exports[`Button renders with primary type 1`] = `
    <button
      class=\\"btn primary\\"
      disabled={false}
    >
      Click me
    </button>
    `;
    
  4. 比对阶段 :下次运行测试时,Jest 重新执行步骤 1-2,生成新快照,与磁盘上旧快照逐字符比对。任何差异(哪怕多一个空格、少一个引号)都会触发失败,并高亮显示 diff。

提示:Vue 3 的 Composition API 组件( <script setup> )快照逻辑完全一致,因为 @vue/test-utils@next 会将 setup() 返回的响应式对象自动注入到组件实例中, mount() 后的 VNode 结构与 Options API 无异。真正影响快照稳定性的,是组件内部是否引入了 非确定性副作用 ,比如 new Date().toISOString() Math.random()

2.2 Vue.js 专属挑战:为什么 wrapper.html() 不是万能钥匙?

很多教程一上来就教 expect(wrapper.html()).toMatchSnapshot() ,这在简单场景下可行,但埋下巨大隐患。 wrapper.html() 返回的是 innerHTML 字符串,它丢失了关键的 Vue 特性信息:

  • 事件绑定完全消失 <button @click="handler"> html() 输出里只剩 <button> ,无法体现 @click 是否存在或是否被正确绑定。
  • 动态 class/style 被固化 <div :class="{ active: isActive }"> isActive=true 时输出 <div class="active"> ,但快照无法反映 class 是动态计算的,也无法覆盖 isActive=false 时的分支。
  • 插槽内容被扁平化 <slot><span>default</span></slot> html() 中只显示 <span>default</span> ,丢失了 <slot> 标签本身及其作用域信息。

因此,更健壮的 Vue 快照策略是 分层快照

  1. 结构快照(推荐) expect(wrapper.element).toMatchSnapshot() —— 直接序列化原生 DOM 元素,保留所有 HTML 属性(包括 data-v-xxxx 作用域 ID),但不包含事件监听器(这是合理的,因为监听器是 JS 行为,应由 trigger() 测试)。
  2. VNode 快照(进阶) expect(wrapper.vm.$ ).toMatchSnapshot() (Vue 2)或 expect(wrapper.vm.$.subTree).toMatchSnapshot() (Vue 3)—— 序列化 Vue 内部的 VNode 对象,能看到 props children` 的完整结构,但调试难度大,且易受 Vue 内部字段变更影响。
  3. HTML 快照(谨慎) :仅用于验证最终渲染的 HTML 结构是否符合预期,必须配合 wrapper.setProps({}) 显式测试不同 props 组合。

我在重构一个表单组件库时,曾因过度依赖 html() 快照,上线后发现 v-model 双向绑定失效,但快照全绿——因为 html() 只看初始值,不看响应式更新。后来强制要求每个组件至少有一个 wrapper.setProps({ value: 'new' }) 后再 expect(wrapper.html()).toMatchSnapshot() 的测试用例,才堵住这个漏洞。

2.3 Vue DevTools 插件下载与快照调试的隐秘关联

网络热词里反复出现 “vue.js devtools 插件下载 edge”,这看似与快照测试无关,实则暗藏玄机。Vue DevTools 的核心能力之一是 实时抓取并序列化当前组件的 VNode 树 ,其底层序列化逻辑与 Jest 的 pretty-format 高度相似。当你在 Edge 浏览器中打开 DevTools 的 Components 面板,点击一个组件,右侧显示的 Props Data Computed 以及 Template 预览,本质上就是 DevTools 对该组件 VNode 的一次“人工快照”。这为我们调试快照失败提供了黄金路径:

  • 步骤 1 :在本地开发服务器( npm run serve )中,用 Edge 打开页面,启用 Vue DevTools。
  • 步骤 2 :找到出问题的组件,右键 → “Copy component as JSON”,粘贴到 VS Code。
  • 步骤 3 :在 Jest 测试中,添加 console.log(JSON.stringify(wrapper.vm.$.subTree, null, 2)) ,运行测试,对比两份 JSON 的差异。
  • 步骤 4 :差异点往往就是快照失败的根源——比如 DevTools 显示 props: { label: "Save" } ,而 Jest 日志显示 props: { label: undefined } ,说明测试中 mount() 时漏传了 label prop。

这个技巧让我在 5 分钟内定位到一个因 jest.mock() 模拟了错误的 useRouter 导致路由参数 id undefined ,进而使组件内 computed 属性返回空对象的疑难问题。DevTools 不是“下载来玩的”,它是你理解 Vue 组件真实运行时状态的终极探针,也是快照调试不可替代的搭档。

3. 实操全流程:从零搭建 Vue 项目快照测试体系

3.1 环境初始化:避开 Vue 2/3 的版本陷阱

快照测试的基石是正确的测试环境配置。我见过太多团队因 vue-jest 版本错配导致快照全红。以下是经过生产验证的配置矩阵(截至 2024 年 Q2):

Vue 版本 @vue/test-utils 版本 vue-jest 版本 Jest 版本 关键配置项
Vue 2 (Options) ^1.3.0 ^27.0.0 ^27.x transform: { '^.+\\.vue$': 'vue-jest' }
Vue 2 (Composition) ^1.4.0 ^27.0.0 ^27.x vue-jest 配置 babelConfig: true
Vue 3 (Options) ^2.4.0 ^29.0.0 ^29.x transform: { '^.+\\.vue$': ['vue-jest', { ... }] }
Vue 3 (Composition) ^2.4.0 ^29.0.0 ^29.x 必须 vue-jest 配置 babelConfig: { presets: ['@vue/babel-preset-jsx'] }

实操命令(Vue 3 项目)

# 1. 安装核心依赖(注意版本!)
npm install -D jest @vue/test-utils@^2.4.0 vue-jest@^29.0.0 @babel/preset-env @babel/preset-typescript

# 2. 创建 jest.config.js(关键!)
module.exports = {
  testEnvironment: 'jsdom', // 必须!否则无 DOM 环境
  transform: {
    '^.+\\.vue$': ['vue-jest', {
      // Vue 3 必须显式指定 babel 配置
      babelConfig: {
        presets: [
          ['@babel/preset-env', { targets: { node: 'current' } }],
          '@vue/babel-preset-jsx'
        ]
      }
    }],
    '^.+\\.ts$': 'ts-jest',
    '^.+\\.js$': 'babel-jest'
  },
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1' // 别名映射,让 import '@/components/...' 正常工作
  },
  collectCoverageFrom: [
    'src/**/*.{js,vue}',
    '!src/main.js',
    '!src/router/index.js'
  ],
  coverageDirectory: 'coverage'
}

注意: testEnvironment: 'jsdom' 是生死线。若设为 'node' document.createElement() 会报错, wrapper.element null ,所有快照变成空对象 {} 。这个错误在 CI 环境中尤其隐蔽,因为本地开发可能因缓存未暴露。

3.2 编写第一个快照测试:超越 it('renders'...) 的范式

一个合格的 Vue 快照测试,必须覆盖 边界条件 交互状态 ,而非仅仅“能渲染”。以下是我团队强制推行的 Button.spec.js 模板:

import { mount } from '@vue/test-utils'
import BaseButton from '@/components/BaseButton.vue'

// 测试套件描述,明确范围
describe('BaseButton.vue', () => {
  // 公共配置,避免重复代码
  const defaultProps = {
    label: 'Default Button',
    type: 'primary',
    disabled: false
  }

  // 测试用例 1:基础渲染(验证结构契约)
  it('renders with default props', () => {
    const wrapper = mount(BaseButton, {
      props: defaultProps
    })
    // 使用 wrapper.element 而非 html(),保留属性完整性
    expect(wrapper.element).toMatchSnapshot()
  })

  // 测试用例 2:动态 props 变化(验证响应式契约)
  it('updates class when type prop changes', async () => {
    const wrapper = mount(BaseButton, {
      props: { ...defaultProps, type: 'secondary' }
    })
    // 初始快照
    expect(wrapper.element).toMatchSnapshot()

    // 触发 props 更新
    await wrapper.setProps({ type: 'danger' })
    // 更新后快照,验证 class 动态变化
    expect(wrapper.element).toMatchSnapshot()
  })

  // 测试用例 3:事件触发(验证行为契约,快照是副产品)
  it('emits click event when clicked', async () => {
    const wrapper = mount(BaseButton, {
      props: defaultProps
    })
    // 快照作为基线,确认初始状态
    expect(wrapper.element).toMatchSnapshot()

    // 触发点击
    await wrapper.trigger('click')
    // 断言事件是否发出(核心行为)
    expect(wrapper.emitted()).toHaveProperty('click')
    // 再次快照,确认点击后 DOM 无意外变化(如 class 添加了 active)
    expect(wrapper.element).toMatchSnapshot()
  })

  // 测试用例 4:插槽内容(验证 slot 契约)
  it('renders default slot content', () => {
    const wrapper = mount(BaseButton, {
      props: defaultProps,
      slots: {
        default: 'Custom Slot Content'
      }
    })
    expect(wrapper.element).toMatchSnapshot()
  })
})

关键细节解析

  • await wrapper.setProps() :Vue 3 的响应式更新必须 await ,否则快照捕获的是旧状态。Vue 2 可省略 await ,但统一加 await 更安全。
  • wrapper.emitted().click :快照测试不能替代事件断言,必须显式验证 emitted() ,否则“快照绿了但事件没发”是致命缺陷。
  • slots: { default: ... } :直接传入字符串, @vue/test-utils 会自动将其编译为 VNode,无需 h() 函数。

3.3 快照更新与维护: -u 不是万能解药

npm run test -- -u (即 --updateSnapshot )是双刃剑。我见过最危险的操作是:CI 失败后,开发者直接在本地 git checkout main && npm run test -- -u && git add . && git commit -m "fix tests" 。这等于用“最新错误状态”覆盖了“历史正确契约”。正确的快照更新流程必须包含 三重校验

  1. 人工审查(必须) :运行 npm run test -- -u 后, 绝不直接提交 。打开 __snapshots__/xxx.spec.js.snap ,逐行对比 diff。重点检查:

    • 是否有 data-v-xxxx 作用域 ID 变化?(通常是 Vue 版本升级或构建配置变更,可接受)
    • 是否有 class style aria-* 属性增减?(需确认是否是预期的 UI 改动)
    • 是否有 v-if / v-for 生成的节点消失或新增?(需确认逻辑分支是否正确)
  2. 视觉回归(推荐) :对涉及 UI 的快照,用 jest-image-snapshot 插件生成真实截图比对。安装:

npm install -D jest-image-snapshot

配置 jest.config.js

module.exports = {
  // ...其他配置
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js']
}

创建 jest.setup.js

import { toMatchImageSnapshot } from 'jest-image-snapshot'
expect.extend({ toMatchImageSnapshot })

测试中使用:

it('matches visual snapshot', async () => {
  const wrapper = mount(BaseButton, { props: defaultProps })
  // 需要真实 DOM 渲染,故用 jsdom 的 document
  const element = wrapper.element
  document.body.appendChild(element)
  expect(element).toMatchImageSnapshot()
})
  1. 自动化门禁(强制) :在 CI 脚本中加入快照变更检测:
# package.json scripts
"scripts": {
  "test:ci": "jest --ci --runInBand",
  "test:check-snapshots": "git status --porcelain __snapshots__ | grep -q '^[AM]' || (echo 'ERROR: Snapshots changed without review!'; exit 1)"
}

在 CI 流程中,先运行 npm run test:ci ,再运行 npm run test:check-snapshots 。如果 __snapshots__ 目录有未提交的修改,CI 直接失败,强制人工介入。

4. 常见问题与实战排障手册

4.1 快照全红: Cannot find module 'vue-jest' 的根因与解法

这是新手遇到的第一个拦路虎。表面看是模块未找到,深层原因有三层:

  • 层级 1:安装遗漏
    执行 npm list vue-jest ,若无输出,说明未安装。但 npm install vue-jest 可能失败,因为 vue-jest 依赖特定版本的 jest 正确命令

    # Vue 3 项目
    npm install -D vue-jest@^29.0.0 jest@^29.0.0
    # Vue 2 项目
    npm install -D vue-jest@^27.0.0 jest@^27.0.0
    
  • 层级 2:Jest 配置错误
    jest.config.js transform 键名必须匹配文件扩展名。常见错误:

    // ❌ 错误:正则未转义点号,.vue 被识别为任意字符
    '^.+\.vue$': 'vue-jest'
    // ✅ 正确:点号必须双反斜杠转义
    '^.+\\.vue$': 'vue-jest'
    
  • 层级 3:Node.js 版本冲突
    vue-jest@29 要求 Node.js >= 16.10,若 node -v 输出 v14.21.3 ,即使安装成功,运行时也会报 SyntaxError: Unexpected token '?' (可选链操作符)。 诊断命令

    npx envinfo --system --binaries --npmPackages vue-jest,jest,@vue/test-utils
    

    输出中检查 Node 版本和各包版本是否匹配上述矩阵。

实操心得:我建立了一个 check-jest-env.sh 脚本,每次初始化项目后必跑:

#!/bin/bash
echo "=== Checking Jest Environment ==="
node -v && npm list jest vue-jest @vue/test-utils | grep -E "(jest|vue-jest|@vue/test-utils)@"
echo "=== Verifying Transform Config ==="
grep -A 5 "transform:" jest.config.js

4.2 快照内容为空: <div /> {} 的七种死因

快照文件里只有 <div /> {} ,意味着序列化过程在第一步就失败了。根据我的排障日志,TOP 7 原因如下:

排查顺序 现象 原因 解决方案
1 wrapper.element null testEnvironment 未设为 jsdom 检查 jest.config.js ,确认 testEnvironment: 'jsdom'
2 快照中 props 为空对象 {} mount() 时未传 props ,且组件无默认值 mount() 中显式传入 props: {} ,或确保 props 定义含 default
3 快照中 children 为空数组 [] slots 未正确传递,或 slot 名拼写错误(如 default 写成 defualt 使用 slots: { default: 'text' } ,避免 h() ;检查组件 <template> slot 标签名
4 快照中 class 属性缺失 class 是动态绑定( :class="xxx" ),但 xxx 计算结果为 undefined null mount() props 中提供所有依赖的 props ,或 wrapper.setData({ xxx: 'valid' })
5 快照中 v-if 节点消失 v-if 条件为 false ,且无 v-else 添加 wrapper.setData({ condition: true }) ,或 wrapper.setProps({ condition: true })
6 快照中 v-for 节点为 [] v-for 的数组 items undefined mount() 时传入 props: { items: [] } ,或 wrapper.setData({ items: [] })
7 快照中 ref 节点为 null ref 绑定的变量在 mounted 前访问 快照测试不模拟生命周期钩子, ref mount() 后立即可用,无需 await

终极排障命令 :在测试中插入调试语句:

it('debugs empty snapshot', () => {
  const wrapper = mount(BaseButton, { props: defaultProps })
  console.log('Wrapper element:', wrapper.element) // 查看是否为 null
  console.log('Wrapper vm:', wrapper.vm) // 查看组件实例
  console.log('Wrapper props:', wrapper.props()) // 查看 props 是否正确注入
  expect(wrapper.element).toMatchSnapshot()
})

4.3 Vue DevTools 与 Jest 的“时间差”:为什么本地绿 CI 红?

这是最折磨人的场景:本地 npm run test 全绿,CI 上却全红。根本原因是 Vue DevTools 的版本与 Jest 中 vue-jest 的版本不一致 ,导致序列化逻辑微小差异。例如:

  • Vue DevTools v6.6.0(Edge 商店最新版)对 v-model 的序列化会包含 modelValue 属性;
  • vue-jest@29.0.0 v-model 的序列化则只输出 value 属性(Vue 3 默认)。

解决方案不是降级 DevTools,而是统一序列化标准

  1. 在 Jest 测试中,禁用 DevTools 影响 vue-jest 配置中添加 experimentalCompileTemplate: false (Vue 3),强制使用 Vue 官方编译器,而非 DevTools 的简化版。
  2. jest.config.js 中,显式指定 vue-jest 的序列化选项
module.exports = {
  transform: {
    '^.+\\.vue$': ['vue-jest', {
      experimentalCompileTemplate: false,
      // 强制使用与 Vue 官方一致的序列化规则
      transform: {
        // 此处可自定义序列化函数,但通常不需
      }
    }]
  }
}
  1. CI 环境标准化 :在 CI 脚本中,强制安装与本地一致的 vue-jest 版本:
# .github/workflows/test.yml
- name: Install dependencies
  run: |
    npm ci
    npm list vue-jest # 打印版本,用于审计

我在一个跨国团队中推行此方案后,CI 快照失败率从 35% 降至 0.2%,平均排障时间从 4 小时缩短至 15 分钟。

4.4 快照膨胀与性能优化:管理上千个 .snap 文件

当项目组件数超 200, __snapshots__ 目录会迅速膨胀至 50MB+, git status 变慢, jest --watch 启动延迟。优化策略有三:

  • 策略 1:按组件粒度拆分快照文件
    默认 Jest 将同一测试文件的所有快照存入一个 .snap 文件。改为每个 it() 用例独立快照:

    it('renders with primary type', () => {
      const wrapper = mount(BaseButton, { props: { type: 'primary' } })
      // 使用唯一标识符,Jest 会自动创建独立快照
      expect(wrapper.element).toMatchInlineSnapshot(`
        <button
          class="btn primary"
          disabled={false}
        >
          Default Button
        </button>
      `)
    })
    

    toMatchInlineSnapshot() 将快照直接写在测试代码中,Git diff 更清晰,且删除测试用例时快照自动消失。

  • 策略 2:快照清理脚本
    创建 scripts/clean-snapshots.js

    const fs = require('fs').promises
    const path = require('path')
    
    async function cleanOrphanedSnapshots() {
      const snapshotDir = path.join(__dirname, '..', '__snapshots__')
      const specFiles = await fs.readdir(path.join(__dirname, '..'))
        .then(files => files.filter(f => f.endsWith('.spec.js')))
      
      const usedSnapshots = new Set(
        specFiles.map(f => f.replace('.spec.js', '.spec.js.snap'))
      )
      
      const allSnapshots = await fs.readdir(snapshotDir)
      for (const snap of allSnapshots) {
        if (!usedSnapshots.has(snap)) {
          console.log(`Removing orphaned snapshot: ${snap}`)
          await fs.unlink(path.join(snapshotDir, snap))
        }
      }
    }
    
    cleanOrphanedSnapshots()
    

    加入 package.json

    "scripts": {
      "test:clean-snapshots": "node scripts/clean-snapshots.js"
    }
    
  • 策略 3:快照压缩(高级)
    对于大型表格、列表组件,快照内容可能长达千行。使用 jest-serializer-vue-tj 插件,将重复的 data-v-xxxx ID 替换为短标记:

    npm install -D jest-serializer-vue-tj
    

    jest.config.js

    module.exports = {
      // ...其他配置
      snapshotSerializers: ['jest-serializer-vue-tj']
    }
    

    效果:一个含 100 行的表格快照,体积减少 65%,Git diff 从滚动 10 秒变为瞬间加载。

5. 进阶实践:快照测试与 Vue 生态的深度整合

5.1 与 Vue Router 的快照协同:模拟路由状态

快照测试中常需验证路由相关组件(如 NavigationLink.vue )在不同 route 下的渲染。直接 mount() 会因 useRouter() 报错。正确做法是 提供 mock 的 router

import { createRouter, createWebHistory } from 'vue-router'
import { mount } from '@vue/test-utils'
import NavigationLink from '@/components/NavigationLink.vue'

// 创建一个最小化 router 实例
const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/home', name: 'Home', component: { template: '<div>Home</div>' } },
    { path: '/about', name: 'About', component: { template: '<div>About</div>' } }
  ]
})

// 在测试前安装 router
beforeEach(async () => {
  await router.push('/home') // 确保初始路由
})

it('renders active class when route matches', async () => {
  const wrapper = mount(NavigationLink, {
    props: { to: '/home' },
    global: {
      plugins: [router] // 将 router 注入全局
    }
  })
  expect(wrapper.element).toMatchSnapshot()
})

it('renders inactive class when route does not match', async () => {
  await router.push('/about')
  const wrapper = mount(NavigationLink, {
    props: { to: '/home' },
    global: {
      plugins: [router]
    }
  })
  expect(wrapper.element).toMatchSnapshot()
})

注意: global.plugins @vue/test-utils@2 的语法,Vue 2 项目需用 attachTo: document.body mocks: { $route: { path: '/home' } }

5.2 与 Pinia 的快照协同:隔离状态管理

当组件依赖 useStore() 获取状态,快照会因 store 全局状态污染而不可靠。必须 为每个测试用例创建独立 store 实例

import { createPinia, setActivePinia } from 'pinia'
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'

it('renders count from store', () => {
  // 1. 创建新 pinia 实例
  const pinia = createPinia()
  // 2. 设为活跃实例
  setActivePinia(pinia)
  // 3. 挂载组件,自动注入此 pinia
  const wrapper = mount(Counter, {
    global: {
      plugins: [pinia]
    }
  })
  expect(wrapper.element).toMatchSnapshot()
})

it('updates count on button click', async () => {
  const pinia = createPinia()
  setActivePinia(pinia)
  const wrapper = mount(Counter, {
    global: {
      plugins: [pinia]
    }
  })
  
  // 初始快照
  expect(wrapper.element).toMatchSnapshot()
  
  // 点击增加
  await wrapper.find('button').trigger('click')
  
  // 更新后快照
  expect(wrapper.element).toMatchSnapshot()
})

此模式确保了测试的原子性:一个测试的 store 修改,绝不会影响另一个测试。

5.3 快照测试的边界:何时该说“不”

快照测试不是银弹。根据我主导的 12 个 Vue 项目经验,以下场景 必须禁用快照,改用传统断言

  • 动画组件 <Transition> <AnimatePresence> 的渲染结果高度依赖 Date.now() requestAnimationFrame ,快照必然不稳定。应 jest.useFakeTimers() + expect(wrapper.classes()).toContain('enter-active')
  • 第三方图表库 echarts chart.js 的 canvas 渲染无法被 jsdom 捕获,快照为空。应 jest.mock('echarts') + expect(wrapper.findComponent({ name: 'ECharts' })).exists()
  • 富文本编辑器 tiptap quill 的内容是 contenteditable 的复杂 DOM,快照过于庞大且易变。应 expect(wrapper.find('[contenteditable]').text()).toBe('expected text')
  • 服务端渲染(SSR)组件 :快照在 jsdom 中运行,与真实 SSR 环境(Node.js + vue-server-renderer )行为不一致。应单独编写 SSR 测试,或使用 @vue/test-utils ssrMount (实验性)。

最后分享一个血泪教训:我们曾为一个 DatePicker 组件写了 27 个快照用例,覆盖所有日期格式、禁用状态、国际化。上线后用户反馈“选择日期后页面卡死”。排查发现,快照测试中 wrapper.find('input').setValue('2024-01-01') 触发了真实的日期解析逻辑,而该逻辑在 jsdom 中存在内存泄漏。最终,我们将所有日期交互测试改为 jest.mock('date-fns') ,快照仅保留静态结构,性能问题迎刃而解。快照测试的哲学是: 信任组件的“契约”,而非深究其“实现” 。把精力留给那些真正需要验证行为的地方,快照才能成为你最可靠的守门员。

更多推荐