Vue3 全栈实战第五周:Vitest 单元测试从零搭建实战记录

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,测试跑在命令行里没有真实浏览器,documentwindow 这些全局对象需要它模拟出来

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

toBetoEqual 的坑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.vueMailSearch.vue 这类组件,需要验证的是"渲染出来的内容对不对"、"点击之后有没有触发正确的事件"------这些必须先把组件真实渲染成 DOM 才能验证,@vue/test-utilsmount 就是干这件事的。

新增文件: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)')
  })
})

知识点:mountprops

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.vueinject 读取主题状态,正常靠组件树的父子关系传递;但测试里单独 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、带详情的 triggerwrapper.vmattachTo

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)')

以后写颜色断言要注意这个转换规律:#fffrgb(255, 255, 255)#1890ffrgb(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.vuetrigger('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()

为什么之前测 markAsReaddeleteMail 没遇到这个问题:那些用例断言的是 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(全等)vs toEqual(深度比较)的适用场景
  • beforeEach 重建 Pinia 实例,保证测试用例互相隔离
  • 异步测试必须 async/await,不等待会产生"假通过"
  • mount + props 挂载组件,text()/find() 查询渲染结果
  • trigger 模拟 DOM 事件,setValue 模拟输入,emitted() 检查触发的事件
  • global.provide 模拟 inject 依赖的注入上下文
  • wrapper.vm 调用 defineExpose 暴露的方法,attachTofocus() 等浏览器行为生效
  • 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前端转全栈实战」系列持续更新,欢迎关注。 有问题欢迎评论区交流,遇到的问题和解决过程都会记录下来。

相关推荐
比特与诗1 小时前
MySQL 如何实现 ACID
全栈
GitLqr3 小时前
Flutter 实战:为你的 App 增加桌面快捷方式 (Quick Actions)
flutter·app·全栈
sugar__salt4 小时前
跟着 Demo 学 Pinia:两种仓库写法 + 完整 TodoList 复现
前端·javascript·vue.js·前端框架·vue
java1234_小锋8 小时前
Vue3专题 - 条件渲染
前端·javascript·vue.js
鸽鸽8 小时前
Vue 3 API 完全指南:从 Options 到 Composition 的进阶之路
前端·vue.js
肉肉不吃 肉8 小时前
无渲染组件和组合式函数
前端·javascript·vue.js
其美杰布-富贵-李8 小时前
01 从项目结构开始认识 Vue 3
javascript·vue.js·ecmascript
星栈9 小时前
我以为 TS7.0 只是换个版本号,结果编译快了 9 倍,也踩了 5 个坑
后端·typescript·node.js