第12章 测试策略
第32节 单元测试
12.32.1 Vitest环境配置
什么是Vitest?
Vitest是一个基于Vite的单元测试框架,它具有以下特点:
- 快速的启动速度和热更新
- 与Vite配置无缝集成
- 支持TypeScript
- 支持ESM
- 支持组件测试
- 支持快照测试
- 支持覆盖率报告
安装Vitest
首先,我们需要安装Vitest及其相关依赖:
npm install vitest @vue/test-utils jsdom -D其中:
vitest:测试框架@vue/test-utils:Vue组件测试工具库jsdom:浏览器环境模拟库
配置Vitest
在项目根目录下创建vitest.config.js文件:
// vitest.config.js
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
// 测试环境
environment: 'jsdom',
// 开启全局API
globals: true,
// 测试文件匹配模式
include: ['**/__tests__/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
// 覆盖率报告配置
coverage: {
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{js,ts,vue}'],
exclude: ['src/main.js', 'src/App.vue', 'src/router/**', 'src/store/**']
}
},
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
})配置package.json
在package.json中添加测试脚本:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}其中:
test:启动Vitest开发模式,支持热更新test:run:运行所有测试并退出test:coverage:运行所有测试并生成覆盖率报告
创建测试目录结构
通常,我们会在src目录下创建__tests__目录来存放测试文件,或者在组件文件旁边创建*.test.js或*.spec.js文件。
src/
├── components/
│ ├── HelloWorld.vue
│ └── HelloWorld.test.js # 组件测试文件
├── utils/
│ ├── math.js
│ └── math.test.js # 工具函数测试文件
└── __tests__/ # 集中测试目录
└── app.test.js12.32.2 组件测试基础
测试工具函数
我们先从简单的工具函数测试开始,了解Vitest的基本用法。
创建一个简单的工具函数:
// src/utils/math.js
/**
* 加法函数
* @param {number} a - 第一个数
* @param {number} b - 第二个数
* @returns {number} - 和
*/
export function add(a, b) {
return a + b
}
/**
* 减法函数
* @param {number} a - 被减数
* @param {number} b - 减数
* @returns {number} - 差
*/
export function subtract(a, b) {
return a - b
}
/**
* 乘法函数
* @param {number} a - 第一个数
* @param {number} b - 第二个数
* @returns {number} - 积
*/
export function multiply(a, b) {
return a * b
}
/**
* 除法函数
* @param {number} a - 被除数
* @param {number} b - 除数
* @returns {number} - 商
*/
export function divide(a, b) {
if (b === 0) {
throw new Error('除数不能为0')
}
return a / b
}创建测试文件:
// src/utils/math.test.js
import { describe, it, expect } from 'vitest'
import { add, subtract, multiply, divide } from './math'
describe('math.js', () => {
describe('add', () => {
it('should return the sum of two numbers', () => {
expect(add(1, 2)).toBe(3)
expect(add(-1, 1)).toBe(0)
expect(add(0, 0)).toBe(0)
})
it('should handle floating point numbers', () => {
expect(add(0.1, 0.2)).toBeCloseTo(0.3)
})
})
describe('subtract', () => {
it('should return the difference of two numbers', () => {
expect(subtract(5, 3)).toBe(2)
expect(subtract(3, 5)).toBe(-2)
expect(subtract(0, 0)).toBe(0)
})
})
describe('multiply', () => {
it('should return the product of two numbers', () => {
expect(multiply(2, 3)).toBe(6)
expect(multiply(-2, 3)).toBe(-6)
expect(multiply(0, 5)).toBe(0)
})
})
describe('divide', () => {
it('should return the quotient of two numbers', () => {
expect(divide(6, 3)).toBe(2)
expect(divide(5, 2)).toBe(2.5)
})
it('should throw an error when dividing by zero', () => {
expect(() => divide(1, 0)).toThrow('除数不能为0')
})
})
})运行测试:
npm run test测试组件基础
创建一个简单的组件:
<!-- src/components/Counter.vue -->
<template>
<div class="counter">
<h3>Counter</h3>
<div class="counter-value">{{ count }}</div>
<div class="counter-buttons">
<button @click="increment">+</button>
<button @click="decrement">-</button>
<button @click="reset">Reset</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
initialCount: {
type: Number,
default: 0
}
})
const emit = defineEmits(['update:count'])
const count = ref(props.initialCount)
const increment = () => {
count.value++
emit('update:count', count.value)
}
const decrement = () => {
count.value--
emit('update:count', count.value)
}
const reset = () => {
count.value = props.initialCount
emit('update:count', count.value)
}
</script>
<style scoped>
.counter {
text-align: center;
padding: 20px;
border: 1px solid #eee;
border-radius: 4px;
}
.counter-value {
font-size: 48px;
font-weight: bold;
margin: 20px 0;
color: #409eff;
}
.counter-buttons {
display: flex;
gap: 10px;
justify-content: center;
}
button {
padding: 10px 20px;
font-size: 18px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #fff;
cursor: pointer;
transition: all 0.3s;
}
button:hover {
background-color: #f5f7fa;
border-color: #c6e2ff;
}
</style>创建组件测试文件:
// src/components/Counter.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter.vue', () => {
let wrapper
// 在每个测试前创建新的组件实例
beforeEach(() => {
wrapper = mount(Counter)
})
it('should render the component', () => {
expect(wrapper.exists()).toBe(true)
expect(wrapper.find('h3').text()).toBe('Counter')
})
it('should display the initial count', () => {
expect(wrapper.find('.counter-value').text()).toBe('0')
})
it('should display the custom initial count', () => {
const customWrapper = mount(Counter, {
props: {
initialCount: 5
}
})
expect(customWrapper.find('.counter-value').text()).toBe('5')
})
it('should increment the count when clicking the + button', async () => {
await wrapper.find('button:nth-child(1)').trigger('click')
expect(wrapper.find('.counter-value').text()).toBe('1')
})
it('should decrement the count when clicking the - button', async () => {
await wrapper.find('button:nth-child(2)').trigger('click')
expect(wrapper.find('.counter-value').text()).toBe('-1')
})
it('should reset the count when clicking the Reset button', async () => {
// 先增加计数
await wrapper.find('button:nth-child(1)').trigger('click')
await wrapper.find('button:nth-child(1)').trigger('click')
expect(wrapper.find('.counter-value').text()).toBe('2')
// 重置计数
await wrapper.find('button:nth-child(3)').trigger('click')
expect(wrapper.find('.counter-value').text()).toBe('0')
})
it('should emit update:count event when count changes', async () => {
await wrapper.find('button:nth-child(1)').trigger('click')
expect(wrapper.emitted('update:count')).toBeTruthy()
expect(wrapper.emitted('update:count')[0]).toEqual([1])
})
})12.32.3 Props、Slots、Events测试
测试Props
创建一个带有Props的组件:
<!-- src/components/MessageCard.vue -->
<template>
<div class="message-card" :class="type">
<div class="message-header">
<h4>{{ title }}</h4>
</div>
<div class="message-content">
<slot></slot>
</div>
<div class="message-footer" v-if="showFooter">
<slot name="footer"></slot>
</div>
</div>
</template>
<script setup>
const props = defineProps({
title: {
type: String,
required: true
},
type: {
type: String,
default: 'info',
validator: (value) => {
return ['info', 'success', 'warning', 'error'].includes(value)
}
},
showFooter: {
type: Boolean,
default: false
}
})
</script>
<style scoped>
.message-card {
padding: 16px;
border-radius: 4px;
margin: 8px 0;
border: 1px solid #eee;
}
.message-card.info {
border-left: 4px solid #409eff;
background-color: #ecf5ff;
}
.message-card.success {
border-left: 4px solid #67c23a;
background-color: #f0f9eb;
}
.message-card.warning {
border-left: 4px solid #e6a23c;
background-color: #fdf6ec;
}
.message-card.error {
border-left: 4px solid #f56c6c;
background-color: #fef0f0;
}
.message-header {
margin-bottom: 8px;
}
.message-header h4 {
margin: 0;
font-size: 16px;
}
.message-content {
margin-bottom: 8px;
}
.message-footer {
font-size: 14px;
color: #909399;
text-align: right;
}
</style>测试Props:
// src/components/MessageCard.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import MessageCard from './MessageCard.vue'
describe('MessageCard.vue', () => {
it('should render with default props', () => {
const wrapper = mount(MessageCard, {
props: {
title: 'Test Title'
},
slots: {
default: 'Test Content'
}
})
expect(wrapper.exists()).toBe(true)
expect(wrapper.classes()).toContain('info')
expect(wrapper.find('.message-header h4').text()).toBe('Test Title')
expect(wrapper.find('.message-content').text()).toBe('Test Content')
expect(wrapper.find('.message-footer').exists()).toBe(false)
})
it('should render with custom props', () => {
const wrapper = mount(MessageCard, {
props: {
title: 'Success Message',
type: 'success',
showFooter: true
},
slots: {
default: 'This is a success message',
footer: 'Footer Content'
}
})
expect(wrapper.classes()).toContain('success')
expect(wrapper.find('.message-footer').exists()).toBe(true)
expect(wrapper.find('.message-footer').text()).toBe('Footer Content')
})
it('should apply correct styles based on type', () => {
const types = ['info', 'success', 'warning', 'error']
types.forEach(type => {
const wrapper = mount(MessageCard, {
props: {
title: `${type} Message`,
type
}
})
expect(wrapper.classes()).toContain(type)
})
})
})测试Slots
创建一个带有多种Slots的组件:
<!-- src/components/Layout.vue -->
<template>
<div class="layout">
<header class="layout-header">
<slot name="header">Default Header</slot>
</header>
<main class="layout-main">
<slot></slot>
</main>
<aside class="layout-aside">
<slot name="aside">Default Sidebar</slot>
</aside>
<footer class="layout-footer">
<slot name="footer">Default Footer</slot>
</footer>
</div>
</template>
<script setup>
</script>
<style scoped>
.layout {
display: grid;
grid-template-columns: 200px 1fr;
grid-template-rows: auto 1fr auto;
grid-template-areas:
"header header"
"aside main"
"footer footer";
gap: 16px;
height: 100vh;
padding: 16px;
}
.layout-header {
grid-area: header;
background-color: #409eff;
color: white;
padding: 16px;
border-radius: 4px;
}
.layout-main {
grid-area: main;
background-color: #f5f7fa;
padding: 16px;
border-radius: 4px;
}
.layout-aside {
grid-area: aside;
background-color: #e6a23c;
color: white;
padding: 16px;
border-radius: 4px;
}
.layout-footer {
grid-area: footer;
background-color: #67c23a;
color: white;
padding: 16px;
border-radius: 4px;
text-align: center;
}
</style>测试Slots:
// src/components/Layout.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Layout from './Layout.vue'
describe('Layout.vue', () => {
it('should render default slots', () => {
const wrapper = mount(Layout)
expect(wrapper.find('.layout-header').text()).toBe('Default Header')
expect(wrapper.find('.layout-aside').text()).toBe('Default Sidebar')
expect(wrapper.find('.layout-footer').text()).toBe('Default Footer')
expect(wrapper.find('.layout-main').text()).toBe('')
})
it('should render custom slots', () => {
const wrapper = mount(Layout, {
slots: {
header: '<h1>Custom Header</h1>',
default: '<div>Main Content</div>',
aside: '<nav>Custom Sidebar</nav>',
footer: '<p>Custom Footer</p>'
}
})
expect(wrapper.find('.layout-header').html()).toContain('<h1>Custom Header</h1>')
expect(wrapper.find('.layout-main').html()).toContain('<div>Main Content</div>')
expect(wrapper.find('.layout-aside').html()).toContain('<nav>Custom Sidebar</nav>')
expect(wrapper.find('.layout-footer').html()).toContain('<p>Custom Footer</p>')
})
it('should render multiple elements in a slot', () => {
const wrapper = mount(Layout, {
slots: {
default: `
<div>First Element</div>
<div>Second Element</div>
<div>Third Element</div>
`
}
})
expect(wrapper.find('.layout-main').findAll('div')).toHaveLength(3)
})
})测试Events
创建一个带有事件的组件:
<!-- src/components/TodoItem.vue -->
<template>
<div class="todo-item" :class="{ completed: todo.completed }">
<input
type="checkbox"
v-model="todo.completed"
@change="handleChange"
>
<span class="todo-text">{{ todo.text }}</span>
<button class="todo-delete" @click="handleDelete">Delete</button>
</div>
</template>
<script setup>
const props = defineProps({
todo: {
type: Object,
required: true,
validator: (value) => {
return value.hasOwnProperty('id') &&
value.hasOwnProperty('text') &&
value.hasOwnProperty('completed')
}
}
})
const emit = defineEmits(['update:todo', 'delete'])
const handleChange = () => {
emit('update:todo', { ...props.todo })
}
const handleDelete = () => {
emit('delete', props.todo.id)
}
</script>
<style scoped>
.todo-item {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
color: #909399;
}
.todo-text {
flex: 1;
margin: 0 10px;
}
.todo-delete {
background-color: #f56c6c;
color: white;
border: none;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
}
</style>测试Events:
// src/components/TodoItem.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import TodoItem from './TodoItem.vue'
describe('TodoItem.vue', () => {
const mockTodo = {
id: 1,
text: 'Test Todo',
completed: false
}
it('should render todo item', () => {
const wrapper = mount(TodoItem, {
props: {
todo: mockTodo
}
})
expect(wrapper.exists()).toBe(true)
expect(wrapper.find('.todo-text').text()).toBe('Test Todo')
expect(wrapper.find('input[type="checkbox"]').element.checked).toBe(false)
})
it('should emit update:todo event when checkbox is toggled', async () => {
const wrapper = mount(TodoItem, {
props: {
todo: mockTodo
}
})
await wrapper.find('input[type="checkbox"]').trigger('change')
expect(wrapper.emitted('update:todo')).toBeTruthy()
expect(wrapper.emitted('update:todo').length).toBe(1)
expect(wrapper.emitted('update:todo')[0][0].completed).toBe(true)
})
it('should emit delete event when delete button is clicked', async () => {
const wrapper = mount(TodoItem, {
props: {
todo: mockTodo
}
})
await wrapper.find('.todo-delete').trigger('click')
expect(wrapper.emitted('delete')).toBeTruthy()
expect(wrapper.emitted('delete').length).toBe(1)
expect(wrapper.emitted('delete')[0][0]).toBe(1)
})
it('should display completed style when todo is completed', () => {
const completedTodo = {
...mockTodo,
completed: true
}
const wrapper = mount(TodoItem, {
props: {
todo: completedTodo
}
})
expect(wrapper.classes()).toContain('completed')
expect(wrapper.find('input[type="checkbox"]').element.checked).toBe(true)
})
})最佳实践
测试命名规范:
- 测试文件命名:
*.test.js或*.spec.js - 测试套件命名:使用
describe('组件名称', () => {}) - 测试用例命名:使用
it('描述测试行为', () => {})
- 测试文件命名:
测试覆盖范围:
- 测试组件的各种状态和行为
- 测试边界情况
- 测试错误处理
- 测试Props、Slots、Events
测试隔离:
- 每个测试用例应该独立运行
- 使用
beforeEach和afterEach来设置和清理测试环境
模拟依赖:
- 使用
vi.mock()来模拟外部依赖 - 避免测试依赖外部服务
- 使用
断言简洁:
- 每个测试用例应该只有一个断言点
- 使用清晰的断言信息
快照测试:
- 对于UI组件,可以使用快照测试来检测意外的UI变化
- 但要谨慎使用,避免快照文件过大
小结
本节我们学习了单元测试的基本概念和实践,包括:
- Vitest环境配置
- 测试工具函数
- 测试组件基础
- 测试Props
- 测试Slots
- 测试Events
单元测试是确保代码质量的重要手段,通过编写单元测试,我们可以:
- 提前发现和修复bug
- 提高代码的可维护性
- 方便重构
- 提供文档
在实际项目中,我们应该根据项目的具体情况来决定测试策略,平衡测试覆盖率和开发效率。
思考与练习
- 安装Vitest并配置测试环境。
- 编写一个工具函数并为其编写测试用例。
- 编写一个组件并测试其Props、Slots和Events。
- 运行测试并查看覆盖率报告。