Vue3 全栈实战第五周:Vitest 单元测试从零搭建实战记录
「Vue前端转全栈实战」系列第七篇。这周内容是 Vitest 单元测试:环境搭建、Store 测试、组件测试、Mock 知识储备、测试覆盖率检查。这篇把基础知识点、完整代码、真实踩过的坑都记录下来。
目录
- 本周项目结构
- [Day1:环境搭建 + Store 单元测试](#Day1:环境搭建 + Store 单元测试 "#day1%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BA--store-%E5%8D%95%E5%85%83%E6%B5%8B%E8%AF%95")
- Day2:组件测试
- [Day3:Mock 知识储备](#Day3:Mock 知识储备 "#day3mock-%E7%9F%A5%E8%AF%86%E5%82%A8%E5%A4%87")
- [Day4:整合优化 + 覆盖率检查](#Day4:整合优化 + 覆盖率检查 "#day4%E6%95%B4%E5%90%88%E4%BC%98%E5%8C%96--%E8%A6%86%E7%9B%96%E7%8E%87%E6%A3%80%E6%9F%A5")
- [常用测试 API 对比](#常用测试 API 对比 "#%E5%B8%B8%E7%94%A8%E6%B5%8B%E8%AF%95-api-%E5%AF%B9%E6%AF%94")
- 本周总结
- 下周计划
本周项目结构
bash
src/
stores/
mailStore.ts
mailStore.test.ts ← 新增:Store 单元测试,11 条用例
components/
MailItem.vue
MailItem.test.ts ← 新增:组件测试,5 条用例
MailSearch.vue
MailSearch.test.ts ← 新增:组件测试,6 条用例
MailList.vue
MailList.test.ts ← 新增:组件测试,5 条用例
vite.config.ts ← 修改:新增 test/coverage 配置
package.json ← 修改:新增 test、test:coverage 脚本
全周一共 27 条测试用例,整体语句覆盖率 95.65%。
Day1:环境搭建 + Store 单元测试
为什么需要单元测试
前四周写的功能,验证方式基本靠"手动点一遍浏览器"------改完代码,打开页面,点标记已读、点删除,眼睛看结果对不对。这种方式有两个问题:改动一处代码,没法保证没影响到别的功能;每次验证都要重复点一遍,费时间。单元测试 把"验证过程"写成代码,跑一条命令就能知道所有功能还正不正常,而且能精确到"哪个函数的哪种输入场景坏了"。
环境搭建
bash
pnpm add -D vitest @vue/test-utils jsdom
vitest:测试框架本体,跑测试用例、断言、覆盖率统计@vue/test-utils:Vue 官方测试工具,专门用来"挂载"组件、模拟交互、检查渲染结果jsdom:在 Node.js 环境里模拟浏览器 DOM,测试跑在命令行里没有真实浏览器,document、window这些全局对象需要它模拟出来
vite.config.ts 里加测试配置(Vitest 是 Vite 生态的测试框架,配置直接写在 vite.config.ts 里):
typescript
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom', // 用 jsdom 模拟浏览器环境
globals: true, // 全局注入 describe/it/expect,不用每个文件手动 import
},
})
package.json 加脚本:
json
{
"scripts": {
"test": "vitest"
}
}
基础知识点:测试的三件套
typescript
describe('一组相关的测试', () => { // 分组,通常对应一个文件/一个功能模块
it('具体的一条用例', () => { // 单条测试,描述"验证什么行为"
expect(实际值).toBe(期望值) // 断言,判断结果对不对
})
})
describe 可以嵌套,用例多了按模块分组,测试报告更清楚。
基础知识点:常用断言方法的区别
typescript
expect(1 + 1).toBe(2) // toBe:全等比较(===),适合基本类型
expect(store.mails).toEqual([]) // toEqual:深度比较,适合对象/数组
expect(store.selectedMail).toBeNull() // 专门判断 null
expect(store.mails.find(m => m.id === 999)).toBeUndefined() // 专门判断 undefined
toBe 和 toEqual 的坑 :toBe 用 === 比较,两个内容一样但引用不同的对象/数组会判定为不相等:
typescript
expect({ id: 1 }).toBe({ id: 1 }) // ❌ 失败,两个对象引用不同
expect({ id: 1 }).toEqual({ id: 1 }) // ✅ 通过,深度比较内容
判断 store.mails 初始值用 toEqual([]) 而不是 toBe([]),就是这个原因------[] 每次创建都是新的数组引用。
基础知识点:beforeEach 和测试隔离
typescript
describe('mailStore', () => {
beforeEach(() => {
setActivePinia(createPinia()) // 每条用例跑之前都执行一次
})
})
Pinia Store 是单例------useMailStore() 第一次调用会创建实例,之后每次调用拿到的都是同一个实例。如果不重新创建 Pinia,第一条用例改过的 mails 数据会带到第二条用例里,两条用例互相污染,测试结果不可靠、也不可复现。
基础知识点:为什么异步测试要 async/await
typescript
it('fetchMails 应该正确加载邮件列表', async () => { // 必须标 async
const store = useMailStore()
await store.fetchMails() // 必须 await,等异步操作真正完成
expect(store.mails.length).toBe(3)
})
不写 async/await,测试函数会在异步操作还没完成时就跑完并判定"通过",看着是绿的,实际上什么都没验证到------这是异步测试最容易踩的隐形坑。
新增文件:src/stores/mailStore.test.ts(Day1 部分)
typescript
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useMailStore } from './mailStore'
describe('mailStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('初始状态应该是空列表', () => {
const store = useMailStore()
expect(store.mails).toEqual([])
expect(store.loading).toBe(false)
})
it('fetchMails 应该正确加载邮件列表', async () => {
const store = useMailStore()
const fetchPromise = store.fetchMails()
expect(store.loading).toBe(true)
await fetchPromise
expect(store.loading).toBe(false)
expect(store.mails.length).toBe(3)
expect(store.mails[0].subject).toBe('周一例会通知')
})
it('markAsRead 应该把对应邮件标记为已读', async () => {
const store = useMailStore()
await store.fetchMails()
const firstMail = store.mails[0]
store.markAsRead(firstMail.id)
expect(store.mails[0].isRead).toBe(true)
})
it('markAsRead 不应该影响其他邮件的已读状态', async () => {
const store = useMailStore()
await store.fetchMails()
store.markAsRead(1)
expect(store.mails.find(m => m.id === 2)?.isRead).toBe(true)
expect(store.mails.find(m => m.id === 3)?.isRead).toBe(false)
})
it('markAllAsRead 应该把所有邮件标记为已读', async () => {
const store = useMailStore()
await store.fetchMails()
store.markAllAsRead()
expect(store.unreadCount).toBe(0)
expect(store.mails.every(m => m.isRead)).toBe(true)
})
it('deleteMail 应该从列表中移除对应邮件', async () => {
const store = useMailStore()
await store.fetchMails()
const originalLength = store.mails.length
store.deleteMail(1)
expect(store.mails.length).toBe(originalLength - 1)
expect(store.mails.find(m => m.id === 1)).toBeUndefined()
})
it('deleteMail 删除当前选中邮件时应该清空 selectedMail', async () => {
const store = useMailStore()
await store.fetchMails()
store.selectMail(store.mails[0])
store.deleteMail(store.mails[0].id)
expect(store.selectedMail).toBeNull()
})
// Day4 整合优化时补充的用例,见文末
})
Day2:组件测试
为什么需要 @vue/test-utils
mailStore.test.ts 测的是纯逻辑(Pinia Store),不涉及 DOM。但 MailItem.vue、MailSearch.vue 这类组件,需要验证的是"渲染出来的内容对不对"、"点击之后有没有触发正确的事件"------这些必须先把组件真实渲染成 DOM 才能验证,@vue/test-utils 的 mount 就是干这件事的。
新增文件:src/components/MailItem.test.ts
typescript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
import MailItem from './MailItem.vue'
import { themeModeKry } from '@/types/injectionKeys'
describe('MailItem', () => {
const baseProps = {
id: 1,
subject: '周一例会通知',
from: 'boss@company.com',
isRead: false,
createdAt: new Date('2025-01-06'),
}
it('应该正确渲染邮件主题和发件人', () => {
const wrapper = mount(MailItem, { props: baseProps })
expect(wrapper.text()).toContain('周一例会通知')
expect(wrapper.text()).toContain('boss@company.com')
})
it('未读邮件应该加粗显示', () => {
const wrapper = mount(MailItem, { props: baseProps })
const li = wrapper.find('li')
expect(li.attributes('style')).toContain('font-weight: bold')
})
it('已读邮件不应该加粗', () => {
const wrapper = mount(MailItem, {
props: { ...baseProps, isRead: true }
})
const li = wrapper.find('li')
expect(li.attributes('style')).toContain('font-weight: normal')
})
it('点击邮件应该触发 markRead 和 click 事件', async () => {
const wrapper = mount(MailItem, { props: baseProps })
await wrapper.find('li').trigger('click')
expect(wrapper.emitted('markRead')).toBeTruthy()
expect(wrapper.emitted('markRead')?.[0]).toEqual([1])
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')?.[0]).toEqual([1])
})
it('深色主题下应该应用深色背景', () => {
const wrapper = mount(MailItem, {
props: baseProps,
global: {
provide: {
[themeModeKry as symbol]: ref('dark')
}
}
})
const li = wrapper.find('li')
// jsdom 会把十六进制颜色转成 rgb() 格式,断言要跟着改(详见下方踩坑记录)
expect(li.attributes('style')).toContain('rgb(34, 34, 34)')
})
})
知识点:mount 和 props
mount 借助 jsdom 模拟浏览器环境,把组件真正"跑起来",生成一棵可以查询的虚拟 DOM 树。wrapper 是这棵树的包装对象,后面所有查询、断言都是对 wrapper 操作。props 选项和父组件模板里写 <MailItem :subject="..." /> 是同一件事,测试环境没有父组件,手动把 props 传进去模拟。
知识点:text() vs find()
typescript
wrapper.text() // 拿到整个组件渲染出的纯文本,适合验证"内容有没有出现"
wrapper.find('li') // 用 CSS 选择器定位具体元素,能进一步读它的属性/样式
判断加粗这种视觉效果不会出现在 text() 里(text() 只关心文字内容),必须 find 到具体元素、读它的 style 属性才能验证。
知识点:trigger + emitted()
typescript
await wrapper.find('li').trigger('click')
expect(wrapper.emitted('markRead')?.[0]).toEqual([1])
trigger('click') 模拟真实点击,await 是必须的------点击触发事件处理函数到 Vue 完成响应式更新之间有异步过程。emitted() 是"回放"组件触发过哪些事件的工具,数据结构是"数组的数组":外层数组表示触发了几次,内层数组对应 emit 传的参数列表。
知识点:global.provide 模拟注入上下文
typescript
mount(MailItem, {
props: baseProps,
global: {
provide: { [themeModeKry as symbol]: ref('dark') }
}
})
MailItem.vue 用 inject 读取主题状态,正常靠组件树的父子关系传递;但测试里单独 mount 一个 MailItem,它上面没有真实的父组件,inject 只能拿到默认值。global.provide 告诉 mount"假装这个组件外面有一层 provide 了这些值的父组件"。
新增文件:src/components/MailSearch.test.ts
typescript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import MailSearch from './MailSearch.vue'
describe('MailSearch', () => {
it('初始状态应该是收起的', () => {
const wrapper = mount(MailSearch)
expect(wrapper.find('button').exists()).toBe(true)
expect(wrapper.find('input').exists()).toBe(false)
})
it('点击搜索图标应该展开输入框', async () => {
const wrapper = mount(MailSearch)
await wrapper.find('button').trigger('click')
expect(wrapper.find('input').exists()).toBe(true)
})
it('输入内容应该触发 search 事件', async () => {
const wrapper = mount(MailSearch)
await wrapper.find('button').trigger('click')
const input = wrapper.find('input')
await input.setValue('会议')
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')?.[0]).toEqual(['会议'])
})
it('按 ESC 键应该关闭搜索框', async () => {
const wrapper = mount(MailSearch)
await wrapper.find('button').trigger('click')
const input = wrapper.find('input')
await input.trigger('keydown', { key: 'Escape' })
expect(wrapper.find('input').exists()).toBe(false)
})
it('点击清空按钮应该清空关键词并触发 clear 事件', async () => {
const wrapper = mount(MailSearch)
await wrapper.find('button').trigger('click')
await wrapper.find('input').setValue('会议')
const buttons = wrapper.findAll('button')
const clearBtn = buttons.find(btn => btn.text() === '✕')
await clearBtn?.trigger('click')
expect(wrapper.emitted('clear')).toBeTruthy()
})
it('通过 defineExpose 暴露的 focus 方法应该能被外部调用', async () => {
const wrapper = mount(MailSearch, {
attachTo: document.body
})
await wrapper.find('button').trigger('click')
const inputEl = wrapper.find('input').element as HTMLInputElement
wrapper.vm.focus()
expect(document.activeElement).toBe(inputEl)
wrapper.unmount()
})
})
知识点:setValue、带详情的 trigger、wrapper.vm、attachTo
setValue 是模拟"用户在输入框里打字"的快捷方法,内部自动处理好 v-model 需要的 input 事件触发。trigger 的第二个参数可以传事件详情,比如 trigger('keydown', { key: 'Escape' }) 模拟按下特定的键,不需要真的有键盘。
emitted() 只能看组件"对外广播了什么事件",但 defineExpose 暴露的是方法本身,父组件是直接调用的------测试里对应 wrapper.vm,组件实例的引用,defineExpose 暴露过的东西都能在上面直接访问到。
focus() 这类和浏览器焦点系统相关的行为,只有元素真实存在于 document 里才会生效,mount 默认渲染在游离容器里不行,需要 attachTo: document.body 挂到真实页面上。用了 attachTo 之后要记得 wrapper.unmount(),否则组件会一直挂在 document.body 上,影响后面其他用例的 DOM 结构。
Day2 踩坑记录:jsdom 会把十六进制颜色转成 rgb() 格式
less
AssertionError: expected 'padding: 12px 16px; margin-bottom: 8p...' to contain '#222'
Received: "...background: rgb(34, 34, 34); color: rgb(255, 255, 255);"
MailItem.vue 深色主题的背景色写的是 #222,但只要这个样式值经过 jsdom(浏览器 CSSOM 的标准行为,Chrome DevTools 里查看也会看到同样的转换)解析、渲染到 DOM 上,读出来的 style 属性就会被自动转换成 rgb(r, g, b) 格式。#222 换算成 RGB 正好是 rgb(34, 34, 34)(#222 是 #222222 的缩写,三个通道都是十六进制 22,对应十进制 34)。
typescript
// ❌ expect(li.attributes('style')).toContain('#222')
// ✅ jsdom 会把十六进制颜色转成 rgb() 格式,断言要跟着改
expect(li.attributes('style')).toContain('rgb(34, 34, 34)')
以后写颜色断言要注意这个转换规律:#fff → rgb(255, 255, 255),#1890ff → rgb(24, 144, 255)。如果不想每次手动换算,也可以断言 class 名(如果背景色通过 class 切换),或者用 toMatchSnapshot() 存快照自动比对。
Day3:Mock 知识储备
为什么需要 Mock
mailStore.ts 里注释写着"第9周接入后端后改成 mails.value = await mailApi.getList()"。测试环境里不应该、也不能依赖真实后端,需要用假数据"冒充"一次真实请求的返回结果------把不可控的外部依赖(网络请求、路由跳转)替换成可控的假实现,这就是 Mock。
这一节纯粹是知识储备:项目目前还没接后端、没有真实的路由跳转逻辑,没有对应的真实代码可以测,没有新建文件、没有实战练习,等以后真用到了回头翻出来对照着写。
vi.fn():创建一个假函数
typescript
const mockFn = vi.fn(() => 42)
mockFn('hello')
expect(mockFn).toHaveBeenCalled()
expect(mockFn).toHaveBeenCalledWith('hello')
expect(mockFn).toHaveBeenCalledTimes(1)
vi.fn() 记录自己被调用的每一次细节,同时可以指定调用它时应该返回什么,是所有 Mock 场景的基础工具。
vi.mock:模块级别的替换
typescript
vi.mock('@/api/mailApi', () => ({
mailApi: {
getList: vi.fn(() => Promise.resolve([...]))
}
}))
不管代码里哪个地方 import { mailApi } from '@/api/mailApi',拿到的都是这个假版本,不会真的发请求。vi.mock 要写在文件最顶部(Vitest 内部会把它提升到所有 import 之前执行)。
typescript
// 模拟接口失败的场景
vi.mocked(mailApi.getList).mockRejectedValueOnce(new Error('网络错误'))
await expect(store.fetchMails()).rejects.toThrow('网络错误')
vi.clearAllMocks() 要在 beforeEach 里调用,清空调用记录,避免用例间互相影响。
Mock 路由跳转
typescript
const mockPush = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush })
}))
原理和 mock mailApi 完全一样,把整个模块替换掉,验证"有没有被正确调用、参数对不对",但不会真的触发浏览器跳转。
Mock 的核心心法
不管 mock 的是接口、路由还是别的什么,思路都是同一句话:把测试不需要关心、也不该真实执行的外部依赖,换成一个"记录调用+返回预设结果"的假版本,让测试只聚焦在自己代码的逻辑上。
Day4:整合优化 + 覆盖率检查
覆盖率工具配置
bash
pnpm add -D @vitest/coverage-v8
typescript
// vite.config.ts
test: {
environment: 'jsdom',
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['node_modules/', 'src/main.ts', '**/*.d.ts', 'src/types/']
}
}
json
{
"scripts": {
"test:coverage": "vitest run --coverage"
}
}
注意这里是 vitest run --coverage,多了个 run------不加的话 vitest 默认是 watch 模式,测完不会退出,不适合看覆盖率报告这种"跑一次看结果"的场景。
覆盖率报告怎么看
arduino
File | % Stmts | % Branch | % Funcs | % Lines |
mailStore.ts | 82.35 | 85.71 | 77.27 | 84.44 |
- % Stmts(语句覆盖率):代码里的每一条语句,有多少比例在测试中被执行过
- % Branch(分支覆盖率) :
if/else、三元表达式这类"有分岔的逻辑",两条分支是不是都测到了 - % Funcs(函数覆盖率):定义的函数里,有多少比例被调用过至少一次
- % Lines(行覆盖率):按物理行数统计
pnpm test:coverage 跑完会在项目根目录生成 coverage/ 文件夹,浏览器打开 coverage/index.html 能看到逐行的可视化标记,绿色是测过的代码,红色是没测过的。覆盖率数字不是越接近 100% 越好,实际项目 70-80% 是比较健康的水平,把精力放在有分支判断、有副作用的核心逻辑上更值得。
新增文件:src/components/MailList.test.ts
typescript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import MailList from './MailList.vue'
describe('MailList', () => {
const mockMails = [
{ id: 1, subject: '邮件一', from: 'a@test.com', to: ['me@test.com'], body: '邮件一的正文', isRead: false, createdAt: new Date() },
{ id: 2, subject: '邮件二', from: 'b@test.com', to: ['me@test.com'], body: '邮件二的正文', isRead: true, createdAt: new Date() },
]
it('loading 为 true 时应该显示加载中', () => {
const wrapper = mount(MailList, { props: { mails: [], loading: true } })
expect(wrapper.text()).toContain('加载中')
})
it('mails 为空时应该显示暂无邮件', () => {
const wrapper = mount(MailList, { props: { mails: [], loading: false } })
expect(wrapper.text()).toContain('暂无邮件')
})
it('应该渲染出对应数量的邮件项', () => {
const wrapper = mount(MailList, { props: { mails: mockMails, loading: false } })
const items = wrapper.findAllComponents({ name: 'MailItem' })
expect(items.length).toBe(2)
})
it('点击邮件项应该触发 mailClick 事件', async () => {
const wrapper = mount(MailList, { props: { mails: mockMails, loading: false } })
const firstItem = wrapper.findAllComponents({ name: 'MailItem' })[0]
await firstItem.vm.$emit('click', 1)
expect(wrapper.emitted('mailClick')).toBeTruthy()
expect(wrapper.emitted('mailClick')?.[0]).toEqual([1])
})
it('MailItem 触发 markRead 应该透传 mailMarkRead 事件', async () => {
const wrapper = mount(MailList, { props: { mails: mockMails, loading: false } })
const firstItem = wrapper.findAllComponents({ name: 'MailItem' })[0]
await firstItem.vm.$emit('markRead', 1)
expect(wrapper.emitted('mailMarkRead')).toBeTruthy()
expect(wrapper.emitted('mailMarkRead')?.[0]).toEqual([1])
})
})
findAllComponents({ name: 'MailItem' }) 依赖 MailItem.vue 已经 defineOptions({ name: 'MailItem' }) (和 Day4 keep-alive 那周 InboxView 必须声明 name 才能被 include 匹配是同一个道理)。
firstItem.vm.$emit(...) 和之前测 MailItem.vue 用 trigger('click') 的区别 :测 MailItem.vue 本身,要模拟真实用户点击,从 DOM 事件开始触发;但测 MailList.vue 有没有正确"转发"子组件事件,不需要真的模拟点击,直接在子组件实例上手动 $emit,更聚焦地验证"父组件监听到子组件事件后有没有正确继续往上传"这一件事。这是组件测试常见的分工原则:每一层组件的测试只负责验证这一层自己的逻辑,不重复测试子组件内部已经测过的行为。
补充到 mailStore.test.ts:覆盖 watch 副作用和 clearInbox/$reset
typescript
import { nextTick } from 'vue'
it('选中邮件变化时应该记录到 viewHistory', async () => {
const store = useMailStore()
await store.fetchMails()
store.selectMail(store.mails[0])
await nextTick()
store.selectMail(store.mails[1])
await nextTick()
store.selectMail(store.mails[1])
await nextTick()
expect(store.viewHistory).toEqual([store.mails[0].id, store.mails[1].id])
})
it('未读数量变化时应该更新浏览器标签页标题', async () => {
const store = useMailStore()
await store.fetchMails()
await nextTick()
expect(document.title).toBe('(2)邮件客户端')
store.markAllAsRead()
await nextTick()
expect(document.title).toBe('邮件客户端')
})
it('clearInbox 应该清空邮件列表和选中状态', async () => {
const store = useMailStore()
await store.fetchMails()
store.selectMail(store.mails[0])
store.clearInbox()
expect(store.mails).toEqual([])
expect(store.selectedMail).toBeNull()
})
it('$reset 应该把所有状态恢复到初始值', async () => {
const store = useMailStore()
await store.fetchMails()
store.selectMail(store.mails[0])
store.$reset()
expect(store.mails).toEqual([])
expect(store.selectedMail).toBeNull()
expect(store.loading).toBe(false)
})
Day4 踩坑记录:watch 回调是异步执行的,断言前要 nextTick
typescript
store.selectMail(store.mails[1])
expect(store.viewHistory).toEqual([...]) // ❌ 断言执行时,watch 回调可能还没跑
watch 默认不是同步触发的:数据变化后,Vue 把回调函数放进一个待执行队列,等到下一个微任务(microtask)才真正执行------这是为了避免同一个 tick 里数据改了好几次,watch 跟着触发好几次,Vue 内部会合并成一次。这意味着 store.selectMail(...) 执行完,viewHistory.value.push(...) 不一定已经跑完,必须显式等一次 nextTick,才能保证 watch 回调真的执行完了。
typescript
// ✅ 改成这样
store.selectMail(store.mails[1])
await nextTick()
expect(store.viewHistory).toEqual([...])
这和 Day2 学过的 nextTick(当时用在"改了 isExpanded 之后 DOM 还没更新")是同一个底层原理:响应式系统的副作用(不管是触发 DOM 更新,还是触发 watch 回调)都不是同步的,凡是"改完数据立刻断言副作用有没有生效"的测试,都要在中间插一次 await nextTick()。
为什么之前测 markAsRead、deleteMail 没遇到这个问题:那些用例断言的是 store.mails 本身,mails.value = mails.value.map(...) 是同步赋值 ,.value 一改完就能立刻读到新值。而这次新加的用例断言的是 watch 回调执行之后产生的副作用 ,这是异步的------同步数据变化 vs 异步副作用,测试写法本质不同。
最终覆盖率结果
arduino
File | % Stmts | % Branch | % Funcs | % Lines |
All files | 95.65 | 83.33 | 91.42 | 98.79 |
MailItem.vue | 100 | 70 | 100 | 100 |
MailList.vue | 87.5 | 71.42 | 100 | 100 |
MailSearch.vue | 100 | 90 | 100 | 100 |
mailStore.ts | 94.11 | 100 | 86.36 | 97.77 |
从补充测试前的 86.95% 提到 95.65%,mailStore.ts 的分支覆盖率做到了 100%。
常用测试 API 对比
| API | 用途 | 典型场景 |
|---|---|---|
toBe |
全等比较(===) |
基本类型(数字、字符串、布尔) |
toEqual |
深度比较 | 对象、数组 |
trigger('click') |
模拟真实 DOM 事件 | 测试组件自身对交互的响应 |
setValue(val) |
模拟输入框内容变化 | 测试 v-model 绑定的输入框 |
emitted('xxx') |
检查组件对外触发的事件 | 验证子组件有没有正确 emit |
wrapper.vm.xxx() |
直接调用组件实例方法 | 验证 defineExpose 暴露的方法 |
vi.fn() |
创建可追踪调用记录的假函数 | 验证某个函数被不被调用、传了什么参数 |
vi.mock(...) |
替换整个模块 | 隔离网络请求、路由跳转等外部依赖 |
本周总结
最大的收获:测试暴露了"看起来没问题,实际有隐患"的地方
手动点浏览器验证功能时,watch(selectedMail)、watch(unreadCount) 这些副作用逻辑一直显示"正常工作",因为浏览器里用户操作和 Vue 内部的微任务调度速度差远远大于代码执行速度,感知不到"异步"这件事。但写成测试代码后,store.selectMail(...) 后面不加 nextTick 直接断言就立刻报错------测试把这类"平时感觉不到,但本质上是异步"的隐患精确地揪出来了。这也是这周覆盖率报告最大的价值:不是数字好不好看,而是它诚实地指出了哪些代码路径从来没被验证过。
掌握的知识点自检:
-
describe/it/expect测试三件套,describe可嵌套分组 -
toBe(全等)vstoEqual(深度比较)的适用场景 -
beforeEach重建 Pinia 实例,保证测试用例互相隔离 - 异步测试必须
async/await,不等待会产生"假通过" -
mount+props挂载组件,text()/find()查询渲染结果 -
trigger模拟 DOM 事件,setValue模拟输入,emitted()检查触发的事件 -
global.provide模拟inject依赖的注入上下文 -
wrapper.vm调用defineExpose暴露的方法,attachTo让focus()等浏览器行为生效 -
vi.fn()/vi.mock()的 Mock 思路:隔离不可控的外部依赖 - 覆盖率四个指标(语句/分支/函数/行)分别衡量什么,70-80% 是健康水平
-
jsdom会把十六进制颜色统一转换成rgb()格式 -
watch回调是异步执行的,断言副作用前要await nextTick()
下周计划
第一阶段(TypeScript + Vue3 核心)目前已经完成 TS 基础、Pinia/Vite/Router/axios、组件化开发、响应式性能优化与缓存、单元测试这五周内容。第9周开始进入全栈项目阶段(Vue3 + Node.js/Express + PostgreSQL + Prisma),中间还有几周内容待定,下周开始前会重新确认具体安排,不在这里提前写死。
「Vue前端转全栈实战」系列持续更新,欢迎关注。 有问题欢迎评论区交流,遇到的问题和解决过程都会记录下来。