在 Vue 项目中使用 snapshot 测试
snapshot 介绍
snapshot 测试又称快照测试,可以直观地反映出组件 UI 是否发生了未预见到的变化。snapshot 如字面上所示,直观描述出组件的样子。通过对比前后的快照,可以很快找出 UI 的变化之处。
第一次运行快照测试时会生成一个快照文件。之后每次执行测试的时候,会生成一个快照,然后对比最初生成的快照文件,如果没有发生改变,则通过测试。否则测试不通过,同时会输出结果,对比不匹配的地方。
jest 中的快照文件以为 snap 拓展名结尾,格式如下(ps: 在没有了解之前,我还以为是快照文件是截图)。一个快照文件中可以包含多个快照,快照的格式其实是 HTML 字符串,对于 UI 组件,其 HTML 会反映出其内部的 state。每次测试只需要对比字符串是否符合初始快照即可。
exports[`button 1`] = `”<div><span class=\\”count\\”>1</span> <button>Increment</button> <button class=\\”desc\\”>Descrement</button> <button class=\\”custom\\”>not emitted</button></div>”`;
snapshot 测试不通过的原因有两个。一个原因是组件发生了未曾预见的变化,此时应检查代码。另一个原因是组件更新而快照文件并没有更新,此时要运行 jest - u 更新快照。
› 1 snapshot failed from 1 test suite. Inspect your code changes or re-run jest with -u to update them.
结合 Vue 进行 snapshot 测试
生成快照时需要渲染并挂载组件,在 Vue 中可以使用官方的单元测试实用工具 Vue Test Utils。
Vue Test Utils 提供了 mount、shallowMount 这两个方法,用于创建一个包含被挂载和渲染的 Vue 组件的 Wrapper。component 是一个 vue 组件,options 是实例化 Vue 时的配置,包括挂载选项和其他选项 (非挂载选项,会将它们通过 extend 覆写到其组件选项),结果返回一个包括了一个挂载组件或 vnode,以及测试该组件或 vnode 的方法的 Wrapper 实例。
mount(component:{Component}, options:{Object})
shallowMount 与 mount 不同的是被存根的子组件,详细请戳文档。
Wrapper 上的丰富的属性和方法,足以应付本文中的测试需求。html() 方法返回 Wrapper DOM 节点的 HTML 字符串。find() 和 findAll() 可以查找 Wrapper 里的 DOM 节点或 Vue 组件,可用于查找监听事件的元素。trigger 可以在 DOM 节点 / 组件上触发一个事件。
结合上述的方法,我们可以完成一个模拟事件触发的快照测试。
细心的读者可能会发现,我们平时在使用 Vue 时,数据更新后视图并不会立即更新,需要在 nextTick 回调中处理更新完成后的任务。但在 Vue Test Utils 中,为简化用法,更新是同步的,所以无需在测试中使用 Vue.nextTick 来等待 DOM 更新。
demo 演示
Vue Test Utils 官方文档中提供了一个集成 VTU 和 Jest 的 demo,不过这个 demo 比较旧,官方推荐用 CLI3 创建项目。
执行 vue create vue-snapshot-demo 创建 demo 项目,创建时要选择单元测试,提供的库有 Mocha + Chai 及 Jest,在这里选择 Jest. 安装完成之后运行 npm run serve 即可运行项目。
本文中将用一个简单的 Todo 应用项目来演示。这个 Todo 应用有简单的添加、删除和修改 Todo 项状态的功能;Todo 项的状态有已完成和未完成,已完成时不可删除,未完成时可删除;已完成的 Todo 项会用一条线横贯文本,未完成项会在鼠标悬浮时展示删除按钮。
组件简单地划分为 Todo 和 TodoItem。TodoItem 在 Todo 项未完成且触发 mouseover 事件时会展示删除按钮,触发 mouseleave 时则隐藏按钮(这样可以在快照测试中模拟事件)。TodoItem 中有一个 checkbox,用于切换 Todo 项的状态。Todo 项完成时会有一个 todo-finished 类,用于实现删除线效果。
为方便这里只介绍 TodoItem 组件的代码和测试。
<template>
<li
:class=”[‘todo-item’, item.finished?’todo-finished’:”]”
@mouseover=”handleItemMouseIn”
@mouseleave=”handleItemMouseLeave”
>
<input type=”checkbox” v-model=”item.finished”>
<span class=”content”>{{item.content}}</span>
<button class=”del-btn” v-show=”!item.finished&&hover” @click=”emitDelete”>delete</button>
</li>
</template>
<script>
export default {
name: “TodoItem”,
props: {
item: Object
},
data() {
return {
hover: false
};
},
methods: {
handleItemMouseIn() {
this.hover = true;
},
handleItemMouseLeave() {
this.hover = false;
},
emitDelete() {
this.$emit(“delete”);
}
}
};
</script>
<style lang=”scss”>
.todo-item {
list-style: none;
padding: 4px 16px;
height: 22px;
line-height: 22px;
.content {
margin-left: 16px;
}
.del-btn {
margin-left: 16px;
}
&.todo-finished {
text-decoration: line-through;
}
}
</style>
进行快照测试时,除了测试数据渲染是否正确外还可以模拟事件。这里只贴快照测试用例的代码,完整的代码戳我。
describe(‘TodoItem snapshot test’, () => {
it(‘first render’, () => {
const wrapper = shallowMount(TodoItem, {
propsData: {
item: {
finished: true,
content: ‘test TodoItem’
}
}
})
expect(wrapper.html()).toMatchSnapshot()
})
it(‘toggle checked’, () => {
const renderer = createRenderer();
const wrapper = shallowMount(TodoItem, {
propsData: {
item: {
finished: true,
content: ‘test TodoItem’
}
}
})
const checkbox = wrapper.find(‘input’);
checkbox.trigger(‘click’);
renderer.renderToString(wrapper.vm, (err, str) => {
expect(str).toMatchSnapshot()
})
})
it(‘mouseover’, () => {
const renderer = createRenderer();
const wrapper = shallowMount(TodoItem, {
propsData: {
item: {
finished: false,
content: ‘test TodoItem’
}
}
})
wrapper.trigger(‘mouseover’);
renderer.renderToString(wrapper.vm, (err, str) => {
expect(str).toMatchSnapshot()
})
})
})
这里有三个测试。第二个测试模拟 checkbox 点击,将 Todo 项从已完成切换到未完成,期待类 todo-finished 会被移除。第三个测试在未完成 Todo 项上模拟鼠标悬浮,触发 mouseover 事件,期待删除按钮会展示。
这里使用 toMatchSnapshot() 来进行匹配快照。这里生成快照文件所需的 HTML 字符串有 wrapper.html() 和 Renderer.renderToString 这两种方式,区别在于前者是同步获取,后者是异步获取。
测试模拟事件时,最好以异步方式获取 HTML 字符串。同步方式获取的字符串并不一定是 UI 更新后的视图。
尽管 VTU 文档中说所有的更新都是同步,但实际上在第二个快照测试中,如果使用 expect(wrapper.html()).toMatchSnapshot(),生成的快照文件中 Todo 项仍有类 todo-finished,期待的结果应该是没有类 todo-finished,结果并非更新后的视图。而在第三个快照测试中,使用 expect(wrapper.html()).toMatchSnapshot() 生成的快照,按钮如期望展示,是 UI 更新后的视图。所以才不建议在 DOM 更新的情况下使用 wrapper.html() 获取 HTML 字符串。
下面是两种对比的结果,1 是使用 wrapper.html() 生成的快照,2 是使用 Renderer.renderToString 生成的。
exports[`TodoItem snapshot test mouseover 1`] = `<li class=”todo-item”><input type=”checkbox”> <span class=”content”>test TodoItem</span> <button class=”del-btn” style=””>delete</button></li>`;
exports[`TodoItem snapshot test mouseover 2`] = `<li class=”todo-item”><input type=”checkbox”> <span class=”content”>test TodoItem</span> <button class=”del-btn”>delete</button></li>`;
exports[`TodoItem snapshot test toggle checked 1`] = `<li class=”todo-item todo-finished”><input type=”checkbox”> <span class=”content”>test TodoItem</span> <button class=”del-btn” style=”display: none;”>delete</button></li>`;
exports[`TodoItem snapshot test toggle checked 2`] = `<li class=”todo-item”><input type=”checkbox”> <span class=”content”>test TodoItem</span> <button class=”del-btn” style=”display:none;”>delete</button></li>`;
这里使用 vue-server-renderer 提供的 createRenderer 来生成一个 Renderer 实例,实例方法 renderToString 来获取 HTML 字符串。这种是典型的回调风格,断言语句在回调中执行即可。
// …
wrapper.trigger(‘mouseover’);
renderer.renderToString(wrapper.vm, (err, str) => {
expect(str).toMatchSnapshot()
})
如果不想使用这个库,也可以使用 VTU 中提供的异步案例。由于 wrapper.html() 是同步获取,所以获取操作及断言语句需要在 Vue.nextTick() 返回的 Promise 中执行。
// …
wrapper.trigger(‘mouseover’);
Vue.nextTick().then(()=>{
expect(wrapper.html()).toMatchSnapshot()
})
观察测试结果
执行 npm run test:unit 或 yarn test:unit 运行测试。
初次执行,终端输出会有 Snapshots: 3 written, 3 total 这一行,表示新增三个快照测试,并生成初始快照文件。
› 3 snapshots written.
Snapshot Summary
› 3 snapshots written from 1 test suite.
Test Suites: 1 passed, 1 total
Tests: 7 passed, 7 total
Snapshots: 3 written, 3 total
Time: 2.012s
Ran all test suites.
Done in 3.13s.
快照文件如下示:
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TodoItem snapshot test first render 1`] = `<li class=”todo-item todo-finished”><input type=”checkbox”> <span class=”content”>test TodoItem</span> <button class=”del-btn” style=”display: none;”>delete</button></li>`;
exports[`TodoItem snapshot test mouseover 1`] = `<li class=”todo-item”><input type=”checkbox”> <span class=”content”>test TodoItem</span> <button class=”del-btn”>delete</button></li>`;
exports[`TodoItem snapshot test toggle checked 1`] = `<li class=”todo-item”><input type=”checkbox”> <span class=”content”>test TodoItem</span> <button class=”del-btn” style=”display:none;”>delete</button></li>`;
第二次执行测试后,输出中有 Snapshots: 3 passed, 3 total,表示有三个快照测试成功通过,总共有三个快照测试。
Test Suites: 1 passed, 1 total
Tests: 7 passed, 7 total
Snapshots: 3 passed, 3 total
Time: 2s
Ran all test suites.
Done in 3.11s.
修改第一个快照中传入的 content,重新运行测试时,终端会输出不匹配的地方,输出数据的格式与 Git 类似,会标明哪一行是新增的,哪一行是被删除的,并提示不匹配代码所在行。
– Snapshot
+ Received
– <li class=”todo-item todo-finished”><input type=”checkbox”> <span class=”content”>test TodoItem</span> <button class=”del-btn” style=”display: none;”>delete</button></li>
+ <li class=”todo-item todo-finished”><input type=”checkbox”> <span class=”content”>test TodoItem content change</span> <button class=”del-btn” style=”display: none;”>delete</button></li>
88 | }
89 | })
> 90 | expect(wrapper.html()).toMatchSnapshot()
| ^
91 | })
92 |
93 | it(‘toggle checked’, () => {
at Object.toMatchSnapshot (tests/unit/TodoItem.spec.js:90:32)
同时会提醒你检查代码是否错误或重新运行测试并提供参数 - u 以更新快照文件。
Snapshot Summary
› 1 snapshot failed from 1 test suite. Inspect your code changes or re-run jest with `-u` to update them.
执行 npm run test:unit — - u 或 yarn test:unit - u 更新快照,输出如下示,可以发现有一个快照测试的输出更新了。下次快照测试对照的文件是这个更新后的文件。
Test Suites: 1 passed, 1 total
Tests: 7 passed, 7 total
Snapshots: 1 updated, 2 passed, 3 total
Time: 2.104s, estimated 3s
Ran all test suites.
Done in 2.93s.
其他
除了使用 toMatchSnapshot() 外,还可以使用 toMatchInlineSnapshot()。二者不同之处在于 toMatchSnapshot() 从快照文件中查找快照,而 toMatchInlineSnapshot() 则将传入的参数当成快照文件进行匹配。
配置 Jest
Jest 配置可以保存在 jest.config.js 文件里,可以保存在 package.json 里,用键名 jest 表示,同时也允许行内配置。
介绍几个常用的配置。
rootDir
查找 Jest 配置的目录,默认是 pwd。
testMatch
jest 查找测试文件的匹配规则,默认是 [“**/__tests__/**/*.js?(x)”, “**/?(*.)+(spec|test).js?(x)” ]。默认查找在__test__文件夹中的 js/jsx 文件和以.test/.spec 结尾的 js/jsx 文件,同时包括 test.js 和 spec.js。
snapshotSerializers
生成的快照文件中 HTML 文本没有换行,是否能进行换行美化呢?答案是肯定的。
可以在配置中添加 snapshotSerializers,接受一个数组,可以对匹配的快照文件做处理。jest-serializer-vue 这个库做的就是这样任务。
如果你想要实现这个自己的序列化任务,需要实现的方法有 test 和 print。test 用于筛选处理的快照,print 返回处理后的结果。
后记
在未了解测试之前,我一直以为测试是枯燥无聊的。了解过快照测试后,我发现测试其实蛮有趣且实用,同时由衷地感叹快照测试的巧妙之处。如果这个简单的案例能让你了解快照测试的作用及使用方法,就是我最大的收获。
如果有问题或错误之处,欢迎指出交流。
参考链接
vue-test-utils-jest-example
Jest – Snapshot Testing
Vue Test Utils
Vue SSR 指南