咳咳,各位观众老爷们,晚上好!我是你们的老朋友,Bug终结者,今天给大家带来一场关于Vue 3单元测试的“相声”——啊不,是讲座。
今天的主题是:Vue 3源码极客之:Vue Test Utils:如何进行组件的单元测试。
咱们的目标是:让测试不再是“玄学”,而是变成咱们代码的“护身符”。
1. 为什么要单元测试?这玩意儿有啥用?
想象一下,你辛辛苦苦写了一段代码,信心满满地合并到主分支,结果第二天就被告知线上出了bug。更惨的是,这个bug还是个很基础的bug,比如某个变量忘了初始化,或者某个条件判断写反了。
这时候你是不是想捶胸顿足,恨不得穿越回去给自己两巴掌?
单元测试就是避免这种惨剧发生的“时光机”。
- 尽早发现bug: 在代码合并之前,甚至在开发过程中,就能发现bug,避免bug蔓延到其他模块,降低修复成本。
- 提高代码质量: 逼迫你写出更容易测试的代码,通常也意味着代码结构更清晰,模块化程度更高。
- 方便代码重构: 有了单元测试,你可以放心地修改代码,不用担心改坏了什么。如果修改导致测试失败,说明你的修改引入了bug,或者测试用例需要更新。
- 文档作用: 单元测试用例可以作为代码的“活文档”,展示了代码的预期行为。
总之,单元测试就是代码的“体检报告”,帮你及时发现问题,保持代码健康。
2. Vue Test Utils:你的单元测试“神器”
Vue Test Utils (VTU) 是 Vue 官方提供的单元测试工具库,专门用来测试 Vue 组件。它提供了一系列 API,让你能够:
- 挂载组件: 将组件渲染到虚拟 DOM 中,模拟组件的运行环境。
- 查找元素: 方便地找到组件中的元素,比如按钮、输入框等。
- 触发事件: 模拟用户交互,比如点击按钮、输入文本等。
- 断言: 验证组件的行为是否符合预期。
简单来说,VTU就是你的“遥控器”,你可以用它来控制组件,然后观察组件的反应,判断组件是否“听话”。
3. 环境搭建:磨刀不误砍柴工
首先,你需要安装 VTU 和 Jest (或者其他你喜欢的测试框架)。
npm install -D @vue/test-utils jest
或者使用 yarn:
yarn add -D @vue/test-utils jest
接下来,配置 Jest。在 package.json
文件中添加以下内容:
{
"scripts": {
"test": "jest"
}
}
创建一个 jest.config.js
文件,配置 Jest 的一些选项:
// jest.config.js
module.exports = {
moduleFileExtensions: ['js', 'vue'],
transform: {
'^.+\.vue$': '@vue/vue3-jest',
'^.+\.js$': 'babel-jest'
},
testEnvironment: 'jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1' // 方便导入模块
}
}
这个配置文件的作用是:
moduleFileExtensions
: 指定 Jest 识别的文件类型。transform
: 指定如何转换不同类型的文件。@vue/vue3-jest
用于转换 Vue 组件,babel-jest
用于转换 JavaScript 文件。testEnvironment
: 指定测试环境为jsdom
,模拟浏览器环境。moduleNameMapper
: 配置模块别名,方便导入模块。比如,'@/components/MyComponent.vue'
可以简写成'@/components/MyComponent.vue'
。
最后,安装 Babel 的相关依赖:
npm install -D @babel/core @babel/preset-env babel-jest
或者使用 yarn:
yarn add -D @babel/core @babel/preset-env babel-jest
创建一个 .babelrc.js
文件,配置 Babel:
// .babelrc.js
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]]
}
这个配置文件的作用是:将 ES6+ 代码转换为 Node.js 可以执行的代码。
4. 第一个单元测试:Hello, World!
咱们先来测试一个最简单的 Vue 组件:
// src/components/HelloWorld.vue
<template>
<h1>{{ message }}</h1>
</template>
<script>
export default {
data() {
return {
message: 'Hello, World!'
}
}
}
</script>
创建一个测试文件:
// tests/unit/HelloWorld.spec.js
import { mount } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue'
describe('HelloWorld.vue', () => {
it('renders the correct message', () => {
const wrapper = mount(HelloWorld)
expect(wrapper.text()).toContain('Hello, World!')
})
})
这个测试文件的作用是:
import { mount } from '@vue/test-utils'
: 导入mount
函数,用于挂载组件。import HelloWorld from '@/components/HelloWorld.vue'
: 导入要测试的组件。describe('HelloWorld.vue', () => { ... })
: 定义一个测试套件,用于组织相关的测试用例。it('renders the correct message', () => { ... })
: 定义一个测试用例,用于测试组件的特定行为。const wrapper = mount(HelloWorld)
: 挂载HelloWorld
组件,返回一个wrapper
对象,包含了组件的实例和一些辅助方法。expect(wrapper.text()).toContain('Hello, World!')
: 断言组件的文本内容包含'Hello, World!'
。
运行测试:
npm run test
如果一切顺利,你将会看到测试通过的提示。
5. 常用 API:工欲善其事,必先利其器
VTU 提供了很多 API,咱们来介绍一些常用的:
-
mount(Component, options)
: 挂载组件。Component
: 要挂载的组件。options
: 可选参数,用于配置挂载选项,比如props
、slots
、global
等。
-
shallowMount(Component, options)
: 浅挂载组件。- 与
mount
类似,但是只会渲染组件本身,不会渲染子组件。适用于测试独立组件的行为,提高测试速度。
- 与
-
wrapper.find(selector)
: 查找元素。selector
: CSS 选择器,用于查找元素。- 返回一个
wrapper
对象,包含了查找到的元素。
-
wrapper.findAll(selector)
: 查找所有元素。selector
: CSS 选择器,用于查找元素。- 返回一个
wrapper
对象数组,包含了查找到的所有元素。
-
wrapper.text()
: 获取元素的文本内容。 -
wrapper.html()
: 获取元素的 HTML 内容。 -
wrapper.classes()
: 获取元素的 CSS 类名。 -
wrapper.attributes()
: 获取元素的属性。 -
wrapper.props()
: 获取组件的 props。 -
wrapper.emitted()
: 获取组件触发的事件。 -
wrapper.setValue(value)
: 设置输入框的值。 -
wrapper.trigger(eventName)
: 触发事件。eventName
: 事件名称,比如'click'
、'input'
等。
-
wrapper.vm
: 获取组件的实例。 -
wrapper.unmount()
: 卸载组件。
这些 API 可以帮助你方便地控制组件,并验证组件的行为。
6. 测试 Props:组件的“参数”
Props 是组件接收外部数据的“入口”,测试 Props 也是单元测试的重要组成部分。
// src/components/MyComponent.vue
<template>
<h1>{{ title }}</h1>
<p>{{ content }}</p>
</template>
<script>
export default {
props: {
title: {
type: String,
required: true
},
content: {
type: String,
default: ''
}
}
}
</script>
测试 Props:
// tests/unit/MyComponent.spec.js
import { mount } from '@vue/test-utils'
import MyComponent from '@/components/MyComponent.vue'
describe('MyComponent.vue', () => {
it('renders the title prop', () => {
const title = 'My Title'
const wrapper = mount(MyComponent, {
props: {
title: title
}
})
expect(wrapper.text()).toContain(title)
})
it('renders the default content prop', () => {
const wrapper = mount(MyComponent, {
props: {
title: 'My Title'
}
})
expect(wrapper.text()).toContain('') // 默认 content 为空字符串
})
it('renders the content prop', () => {
const content = 'My Content'
const wrapper = mount(MyComponent, {
props: {
title: 'My Title',
content: content
}
})
expect(wrapper.text()).toContain(content)
})
})
在 mount
函数的 options
中,可以使用 props
选项传递 Props。
7. 测试 Events:组件的“出口”
Events 是组件向外部传递数据的“出口”,测试 Events 可以验证组件是否正确地触发了事件。
// src/components/MyButton.vue
<template>
<button @click="handleClick">Click me</button>
</template>
<script>
export default {
methods: {
handleClick() {
this.$emit('my-event', 'Hello from button!')
}
}
}
</script>
测试 Events:
// tests/unit/MyButton.spec.js
import { mount } from '@vue/test-utils'
import MyButton from '@/components/MyButton.vue'
describe('MyButton.vue', () => {
it('emits my-event when clicked', async () => {
const wrapper = mount(MyButton)
await wrapper.find('button').trigger('click')
const emitted = wrapper.emitted('my-event')
expect(emitted).toBeTruthy()
expect(emitted[0]).toEqual(['Hello from button!'])
})
})
wrapper.find('button').trigger('click')
: 找到按钮并触发click
事件。wrapper.emitted('my-event')
: 获取组件触发的my-event
事件。expect(emitted).toBeTruthy()
: 断言事件被触发了。expect(emitted[0]).toEqual(['Hello from button!'])
: 断言事件传递的参数是否正确。
8. 测试 Slots:组件的“插槽”
Slots 是组件提供给外部的“扩展点”,测试 Slots 可以验证组件是否正确地渲染了插槽内容。
// src/components/MyLayout.vue
<template>
<div class="layout">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
</template>
测试 Slots:
// tests/unit/MyLayout.spec.js
import { mount } from '@vue/test-utils'
import MyLayout from '@/components/MyLayout.vue'
describe('MyLayout.vue', () => {
it('renders the default slot', () => {
const wrapper = mount(MyLayout, {
slots: {
default: 'Main Content'
}
})
expect(wrapper.text()).toContain('Main Content')
})
it('renders the header slot', () => {
const wrapper = mount(MyLayout, {
slots: {
header: 'Header Content'
}
})
expect(wrapper.find('header').text()).toContain('Header Content')
})
it('renders the footer slot', () => {
const wrapper = mount(MyLayout, {
slots: {
footer: 'Footer Content'
}
})
expect(wrapper.find('footer').text()).toContain('Footer Content')
})
})
在 mount
函数的 options
中,可以使用 slots
选项传递插槽内容。
9. 测试异步操作:告别“卡顿”
组件中经常会有异步操作,比如网络请求、定时器等。测试异步操作需要使用 async/await
或者 Promise
。
// src/components/MyAsyncComponent.vue
<template>
<p>{{ message }}</p>
</template>
<script>
export default {
data() {
return {
message: 'Loading...'
}
},
async mounted() {
await new Promise(resolve => setTimeout(resolve, 1000))
this.message = 'Data loaded!'
}
}
</script>
测试异步操作:
// tests/unit/MyAsyncComponent.spec.js
import { mount } from '@vue/test-utils'
import MyAsyncComponent from '@/components/MyAsyncComponent.vue'
describe('MyAsyncComponent.vue', () => {
it('renders the correct message after async operation', async () => {
const wrapper = mount(MyAsyncComponent)
expect(wrapper.text()).toContain('Loading...')
await new Promise(resolve => setTimeout(resolve, 1000)) // 等待异步操作完成
expect(wrapper.text()).toContain('Data loaded!')
})
})
或者使用 nextTick
:
import { mount } from '@vue/test-utils'
import MyAsyncComponent from '@/components/MyAsyncComponent.vue'
import { nextTick } from 'vue';
describe('MyAsyncComponent.vue', () => {
it('renders the correct message after async operation', async () => {
const wrapper = mount(MyAsyncComponent)
expect(wrapper.text()).toContain('Loading...')
await nextTick()
await new Promise(resolve => setTimeout(resolve, 1000)) // 等待异步操作完成
await nextTick()
expect(wrapper.text()).toContain('Data loaded!')
})
})
await new Promise(resolve => setTimeout(resolve, 1000))
: 等待 1 秒,模拟异步操作。- 在断言之前,需要确保异步操作已经完成。可以使用
async/await
或者Promise.then
来等待异步操作完成。
10. Mocking:模拟外部依赖
组件可能依赖于外部模块,比如 API 请求、第三方库等。在单元测试中,为了避免依赖外部环境,可以使用 Mocking 来模拟外部依赖。
// src/api/user.js
export async function getUser(id) {
// 模拟 API 请求
await new Promise(resolve => setTimeout(resolve, 500))
return {
id: id,
name: 'John Doe'
}
}
// src/components/MyUserComponent.vue
<template>
<p>{{ user ? user.name : 'Loading...' }}</p>
</template>
<script>
import { getUser } from '@/api/user'
export default {
data() {
return {
user: null
}
},
async mounted() {
this.user = await getUser(123)
}
}
</script>
测试 Mocking:
// tests/unit/MyUserComponent.spec.js
import { mount } from '@vue/test-utils'
import MyUserComponent from '@/components/MyUserComponent.vue'
import * as userApi from '@/api/user'
describe('MyUserComponent.vue', () => {
it('renders the user name after fetching data', async () => {
const mockGetUser = jest.spyOn(userApi, 'getUser')
mockGetUser.mockResolvedValue({
id: 123,
name: 'Mock User'
})
const wrapper = mount(MyUserComponent)
expect(wrapper.text()).toContain('Loading...')
await new Promise(resolve => setTimeout(resolve, 500))
await wrapper.vm.$nextTick() // 等待组件更新
expect(wrapper.text()).toContain('Mock User')
mockGetUser.mockRestore() // 恢复原始函数
})
})
jest.spyOn(userApi, 'getUser')
: 监听userApi.getUser
函数。mockGetUser.mockResolvedValue({ ... })
: 模拟getUser
函数的返回值。mockGetUser.mockRestore()
: 恢复原始函数,避免影响其他测试用例。
11. 总结:单元测试的“葵花宝典”
- 从小到大: 先测试最小的单元,再测试组合的单元。
- 覆盖所有情况: 考虑所有可能的输入和输出,包括正常情况和异常情况。
- 保持测试独立: 每个测试用例应该独立运行,避免相互影响。
- 及时更新测试: 当代码修改时,及时更新测试用例。
- 不要过度测试: 只测试组件的核心逻辑,不要测试 Vue 框架本身的实现。
单元测试是一个持续学习的过程,需要不断实践和总结。希望今天的讲座能帮助你入门 Vue 3 的单元测试,让你的代码更加健壮和可靠。
记住,代码的“护身符”不是玄学,而是扎实的单元测试!
好了,今天的“相声”就到这里,感谢各位观众老爷的收听! 咱们下期再见!