Vue 3 + Vite 单元测试实战:从零搭建 Vitest 环境与组件测试
单元测试这个东西圈里聊了很久了但真正愿意动手往项目里铺的人其实还是少数。每次一提到“写测试”我听到最多的反馈就是“项目进度都赶不完哪有时间写测试”“Mock 太麻烦了写起来比业务代码还累”还有那种更直接的——“配置了大半天连第一个用例都跑不起来放弃了”。但如果你正在用 Vue 3 Vite 这套技术栈天天在 Vue Router 里跳页面在 Pinia 里管理状态又被 ESLint 和 Prettier 的报错折磨得焦头烂额我特别建议你把单元测试这件事重新捡起来。原因很简单现在的工具链已经不是五六年前那套了。Vitest 的出现让 Vue 单测的起步成本降到了“初始化一个组件、写一段断言、npm run test 看到一个绿色勾”这么简单。这篇内容我会从零开始结合 Vue Router、Pinia、ESLint Prettier 这套常见的工程组合把单元测试从框架选型、环境搭建、组件测试、路由 mock、状态管理测试到常见报错排查和 testbed 这个概念的补充说明完整过一遍。不写空话每一步都是我实际跑过的踩过的坑也会直接标出来。1. 单元测试到底在干什么为什么 Vue 项目尤其需要它1.1 先搞清楚单元测试覆盖的是什么很多人对单元测试的理解就是“给函数写几个断言”这话对了一半。单元测试的核心对象是一个最小可验证的代码单元。放到 Vue 项目里这个单元可以是一个工具函数比如日期格式化、金额千分位转换一个 composable比如 usePagination、useDebounce一个 Pinia store比如购物车 store 里的 addItem、removeItem一个 Vue 组件比如按钮的 loading 状态渲染、表单的校验提示一个路由守卫比如登录态校验逻辑。换句话说凡是能独立抽出来、有明确输入输出、有确定行为逻辑的代码都值得写单元测试。它跟集成测试和端到端测试的区别在于单元测试关注“这一个零件好不好用”集成测试关注“几个零件拼起来对不对”端到端测试关注“整台机器跑起来用户体验行不行”。我见过不少团队一上来就搞 Cypress 做端到端结果用例数量一多光执行时间就吃掉半小时还因为各种环境问题动不动就红。相比之下单元测试的执行速度是毫秒级的几千个用例跑完也就几十秒非常适合在代码提交阶段和 CI 里高频执行。所以我的建议一直是先把单元测试的底子打好再谈端到端。1.2 Vue 组件测试和普通函数测试的差异函数测试简单传参、断言返回值就完事了。组件测试不一样它多出了几个关键环节第一组件有生命周期。mount 之后 created、mounted 会依次执行如果组件里发起了接口请求、监听了事件测试都需要处理。第二组件有 DOM 渲染。断言一个按钮的文案、一个列表的长度、一个元素的 class需要通过工具去查询 DOM。第三组件之间有交互。点击一个按钮触发某个事件输入框改变 v-model 的值这一步需要真实地模拟用户操作。因为这些差异Vue 组件测试必须依赖一个类似浏览器环境的运行载体。Vitest 默认用 jsdom 来模拟 DOM 环境也可以通过 happy-dom 来跑。jsdom 本质上是一个最小化的 DOM 实现它没有真实的布局和渲染但对断言文本、类名、重定向这些完全够用。记住一点测试的目的不是测浏览器渲染而是测组件的逻辑和结构是否符合预期所以 jsdom 是够用的别再纠结要不要上 Playwright。2. 框架选型Vue 3 Vite 项目为什么优先选 Vitest2.1 Jest、Vitest、Mocha 三选一怎么定这是很多新手第一个卡住的问题。如果你在搜索引擎搜“vue 单元测试 选什么”看到的结果会一个字乱。有人推 Jest有人推 Vitest还有人提 Mocha Chai。但从 Vue 3 Vite 的技术栈角度出发我的答案非常明确用 Vitest。为什么看这个对比维度VitestJestMocha Chai与 Vite 配置的关系直接复用 vite.config零额外配置需要 jest.config.js moduleNameMapper需要 babel 或 ts-node 转发运行速度基于 Vite 的 esbuild秒级冷启动需要整个模块图转换偏慢一般TS 支持原生支持需要 ts-jest 或 babel 类型声明插件需要额外配置Vue 组件测试支持vue/test-utils 官方推荐搭配可用但配置复杂需要手动拼搭建热更新HMR支持不支持不支持Mock 能力vi.fn / vi.mock / vi.spyOnjest.fn / jest.mock / jest.spyOn需要 Sinon这里最核心的一点Vitest 和 Vite 共享同一套配置和模块解析机制。你的项目如果用 Vite 启动那么 Vitest 几乎就是天生适配的。Jest 的问题是它有一套自己的模块解析规则在 Vite 项目里经常出现“组件引入了但 Jest 不认识别名”“SVG 文件解析失败”“CSS 导入报错”这种兼容问题。虽然都能通过配置绕过去但折腾成本摆在那里。再补一句关于 “vitest 单元测试 这个是选什么”的疑惑。其实问题本身就在答案里当你已经把 Vue Router、Pinia、ESLint Prettier 这些都集成进项目时Vitest 是唯一能和它们无缝合作、不需要额外适配层的方案。选它几乎是零思考成本的选择。2.2 开发依赖清单和版本说明在开始前先把要安装的东西列清楚避免以后缺一个包才发现跑不起来。以一个标准的 Vue 3 Vite TS 项目为例package.json 的开发依赖里至少要有这些{ devDependencies: { vue/test-utils: ^2.4.0, vitest/coverage-v8: ^1.6.0, jsdom: ^24.0.0, vitest: ^1.6.0, eslint: ^8.57.0, eslint-plugin-vue: ^9.23.0, prettier: ^3.2.0 } }注意几个版本选择上的实际经验Vitest 的 1.x 和 2.x 在 API 上没有太大变化但如果你用的 Vite 是 4.x建议锁 Vitest 1.x如果 Vite 是 5.x可以直接上 2.x。jsdom 是单独安装的因为 Vitest 自己不内置 DOM 环境不加 jsdom 的话任何涉及 DOM 的组件测试都会报 “document is not defined”。我试过 happy-dom在大多数场景下也还行但遇到 select 元素和多层嵌套事件时稳定程度还是 jsdom 更好。如果项目里已经配了 ESLint Prettier记得在 .eslintrc 里给测试文件单独开环境不然你会被 no-undef 这个报错烦死。这个后面在报错排查部分我会详细说。3. 从零手动搭一个能跑的 Vitest 测试环境3.1 初始化一个 Vue 3 Vite TypeScript 项目为了把整个过程讲得完整我不用现成的脚手架直接手动创建项目文件这样每一步都能解释清楚为什么这么配。首先创建项目目录mkdir vitest-demo cd vitest-demo npm init -y然后安装 Vue 3 和 Vitenpm install vue^3.4.0 npm install -D vite^5.0.0 vitejs/plugin-vue接着创建最基础的 Vite 配置 vite.config.tsimport { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()] })这里先不急着加别的。等一会儿配置 Vitest 的时候你会发现这个文件会被直接复用这就是 Vite 生态最舒服的地方。项目里有别名需求的话在 vite.config.ts 里配 resolve.alias后面写测试时/components/xx就都能正常解析。再补一个 tsconfig.json不然 vite.config.ts 在 node 环境跑 TS 会提示一堆类型问题{ compilerOptions: { target: ES2020, module: ESNext, moduleResolution: bundler, strict: true, jsx: preserve, resolveJsonModule: true, esModuleInterop: true, lib: [ES2020, DOM, DOM.Iterable], types: [vitest/globals] }, include: [src/**/*.ts, src/**/*.vue, vite.config.ts] }里面types: [vitest/globals]这行很关键。加上之后测试文件里就可以直接写describe、it、expect不用每个文件都显式import { describe, it, expect } from vitest。如果你不想开 globals也可以不用这行写测试时手动 import两种风格都能跑。3.2 安装并配置 Vitest 和测试辅助库接下来安装 Vitest、测试工具、jsdomnpm install -D vitest jsdom vue/test-utils然后在 vite.config.ts 里加上 test 配置import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], test: { environment: jsdom, globals: true, include: [src/**/*.{test,spec}.{ts,tsx}], coverage: { provider: v8, reporter: [text, html] } } })这里说明一下几个配置项的作用environment: jsdom告诉 Vitest 用 jsdom 模拟浏览器环境。如果你测的是纯函数或 store其实用 node 环境就够了但项目里既然是组件测试为主的场景直接全局设 jsdom 省心。globals: true配合上面 tsconfig 里的 types让 describe、it、expect 成为全局可用对象。include指定测试文件放在 src 目录下按xx.test.ts或xx.spec.ts的命名规则识别。coverage覆盖率工具用 v8这个是 Vitest 官方和 V8 引擎直接集成的跑一次就能看到语句、分支、函数、行四个维度的覆盖百分比。最后在 package.json 里加上执行脚本{ scripts: { test: vitest run, test:watch: vitest, test:coverage: vitest run --coverage } }注意vitest run和vitest的区别run是一次性执行完退出适合 CI不带 run 时默认进入 watch 模式文件变更会自动重新跑测试适合本地开发。我自己习惯本地用test:watch提交前用npm run test全量跑一遍。3.3 写第一个用例一个工具函数的测试配置完了先别急着测组件写一个最简单的工具函数用例验证环境是否真的通了。创建一个src/utils/math.tsexport function add(a: number, b: number): number { return a b } export function isEven(n: number): boolean { return n % 2 0 } export function formatPrice(value: number, currency ¥): string { return ${currency}${value.toFixed(2)} }再创建src/utils/math.test.tsimport { describe, it, expect } from vitest import { add, isEven, formatPrice } from ./math describe(utils/math, () { it(add 函数正确计算两个数字之和, () { expect(add(1, 2)).toBe(3) expect(add(-1, 1)).toBe(0) expect(add(0.1, 0.2)).toBeCloseTo(0.3) }) it(isEven 判断奇偶数, () { expect(isEven(4)).toBe(true) expect(isEven(7)).toBe(false) }) it(formatPrice 拼接货币符号并保留两位小数, () { expect(formatPrice(12.345)).toBe(¥12.35) expect(formatPrice(12.3, $)).toBe($12.30) }) })这个用例覆盖了三个很常见的断言细节第一浮点数不要用 toBe要用 toBeCloseTo因为 0.1 0.2 在 JS 里不等于 0.3第二toFixed 会执行四舍五入12.345 会被处理成 12.35这里实际结果是 12.35因为浮点数的精度表现注意用真实值验证第三函数默认参数在测试里可以直接通过第二个参数改变行为。跑一下npm run test如果看到绿色的 “Test Files 1 passed”说明整个测试链路已经通了。这时候的去 STS 配置到了这一步其实已经完成了 50%后面就是在这个基础上拓展组件测试和各种 mock 场景。4. Vue 组件测试实操挂载、断言与事件交互4.1 mount 和 shallowMount 怎么选组件测试最常用到的两个 API 就是 mount 和 shallowMount。它们的区别在于mount 会完全挂载组件包括所有子组件shallowMount 只挂载当前组件本身子组件一律被替换成占位节点。实际操作中我推荐一个优先级判断如果你的组件很简单子组件不多用 mount如果你的组件是一个很大的业务组件挂载一堆子组件会导致性能下降或依赖外部数据用 shallowMount如果你测的组件有插槽且断言内容就是插槽节点用 mount 更直观因为 shallowMount 会把插槽内容也替换掉如果子组件内部有副作用比如 mounted 里发请求shallowMount 可以直接避开。注意 shallowMount 的本质是“当前组件自身逻辑测试”它无法验证子组件之间的交互所以并不能替代 mount。测父子通信时还是得用 mount。下面用实际例子说明。创建一个src/components/Counter.vuescript setup langts import { ref, computed } from vue const count ref(0) const doubleCount computed(() count.value * 2) function increment() { count.value } function decrement() { if (count.value 0) { count.value-- } } /script template div classcounter p classcount-text当前数量: {{ count }}/p p classdouble-text双倍数量: {{ doubleCount }}/p button classinc-btn clickincrement增加/button button classdec-btn clickdecrement减少/button /div /template对应的测试文件src/components/Counter.test.tsimport { describe, it, expect } from vitest import { mount } from vue/test-utils import Counter from ./Counter.vue describe(Counter.vue, () { it(初始渲染数量为 0双倍数量为 0, () { const wrapper mount(Counter) expect(wrapper.find(.count-text).text()).toContain(0) expect(wrapper.find(.double-text).text()).toContain(0) }) it(点击增加按钮后数量变 1双倍数量变 2, async () { const wrapper mount(Counter) await wrapper.find(.inc-btn).trigger(click) expect(wrapper.find(.count-text).text()).toContain(1) expect(wrapper.find(.double-text).text()).toContain(2) }) it(点击减少按钮时数量不会小于 0, async () { const wrapper mount(Counter) await wrapper.find(.dec-btn).trigger(click) await wrapper.find(.dec-btn).trigger(click) expect(wrapper.find(.count-text).text()).toContain(0) }) })这里两个细节值得新手注意第一trigger 必须 await。虽然 trigger 返回 Promise如果你不写 awaitVue 的 DOM 更新可能还没完成下一个断言就已经执行了导致测试间歇性失败。这是新手最容易踩的坑之一。第二text() 断言我用的是 toContain 而不是 toBe。因为text()返回的是整个 p 标签里的文本内容包含“当前数量: ”这个前缀用 toBe(1) 必然失败。如果不想依赖这种前缀可以在组件模板里单独加一个带>script setup langts const props defineProps{ name: string role?: string }() const emit defineEmits{ (e: select, payload: { id: number }): void }() function handleClick() { emit(select, { id: props.name.length }) } /script template div classuser-card clickhandleClick h3{{ name }}/h3 p v-ifrole{{ role }}/p p v-else暂无角色/p /div /template测试import { describe, it, expect } from vitest import { mount } from vue/test-utils import UserCard from ./UserCard.vue describe(UserCard.vue, () { it(正确渲染必传的 name prop, () { const wrapper mount(UserCard, { props: { name: 张三 } }) expect(wrapper.find(h3).text()).toBe(张三) }) it(role 存在时显示角色不存在时显示暂无角色, () { const withRole mount(UserCard, { props: { name: 张三, role: 管理员 } }) const withoutRole mount(UserCard, { props: { name: 李四 } }) expect(withRole.text()).toContain(管理员) expect(withoutRole.text()).toContain(暂无角色) }) it(点击卡片时触发 select 事件并携带 id, async () { const wrapper mount(UserCard, { props: { name: Tom } }) await wrapper.trigger(click) const selectEvents wrapper.emitted(select) expect(selectEvents).toHaveLength(1) expect(selectEvents![0]).toEqual([{ id: 3 }]) }) })第三个用例里的wrapper.emitted(select)是 vue/test-utils 的核心 API 之一。它会返回一个数组每个元素代表一次 emit 调用参数组成的数组。断言selectEvents![0]等于[{ id: 3 }]就是验证第一次 emit 传出的参数。注意这里用 toEqual 而不是 toBe因为对象是引用类型toBe 只比较引用地址。这种断言方式还能用来驱动“父组件正确响应子组件事件”的测试挂载父组件然后通过findComponent(ChildComponent)找到子组件实例直接用childWrapper.emitted(xxx)来断言事件是否触发。如果你对子组件是 shallowMount 的要调用到子组件内部方法可以用wrapper.findComponent(ChildComponent).vm.$emit(select, payload)来模拟子组件发事件这也是表单类组件测试的常用手法。5. 结合 Vue Router 和 Pinia 的测试方案5.1 Vue Router 的 mock 和真实路由测试在组件测试中会遇到两类路由相关场景一类是组件内部用useRoute()读取当前路由参数或者用useRouter()做编程式跳转另一类是直接测试某个页面组件在路由配置下是否渲染正确。先说第一类。以导航守卫里的登录判断为例创建一个src/router/guard.tsimport type { Router, RouteLocationNormalized } from vue-router export function setupAuthGuard(router: Router) { router.beforeEach((to: RouteLocationNormalized) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { return { name: login } } return true }) }对应的测试import { describe, it, expect, vi, beforeEach } from vitest import { createRouter, createMemoryHistory } from vue-router import { setupAuthGuard } from ./guard const routes [ { path: /, component: { template: divhome/div } }, { path: /login, component: { template: divlogin/div } }, { path: /admin, component: { template: divadmin/div }, meta: { requiresAuth: true } } ] function makeRouter() { const router createRouter({ history: createMemoryHistory(), routes }) setupAuthGuard(router) return router } describe(setupAuthGuard, () { beforeEach(() { localStorage.clear() }) it(未登录时访问需要鉴权的页面重定向到登录页, async () { const router makeRouter() await router.push(/admin) expect(router.currentRoute.value.name).toBe(login) }) it(已登录时访问需要鉴权的页面正常进入, async () { localStorage.setItem(token, fake-token) const router makeRouter() await router.push(/admin) expect(router.currentRoute.value.name).toBe(admin) }) it(不需要鉴权的页面未登录也可以直接进入, async () { const router makeRouter() await router.push(/) expect(router.currentRoute.value.name).toBe(home) }) })这里用了一个很关键的能力createMemoryHistory。这个模式不会真的改动浏览器 URL而是在内存里记录路由栈非常适合单元测试。每次测试里调用router.push后router.currentRoute 会被更新守卫也会按真实路由配置执行。如果你只是想在组件里 mock 掉 useRouter 和 useRoute可以用 Vitest 的 vi.mock 配合 vue/test-utils 的 global.mocks像这样vi.mock(vue-router, () ({ useRoute: () ({ params: { id: 123 } }), useRouter: () ({ push: vi.fn() }) }))这种方式适合那些路由只是被动读取、不参与真实路由跳转的组件。但如果你的测试需要覆盖“跳转这个行为本身”建议用 createMemoryHistory 建一个真实 router 实例再注入组件。5.2 Pinia store 的测试模式Pinia 的 store 测试分两类纯 store 测试和组件内 store 集成测试。先看纯 store。创建一个src/stores/cart.tsimport { defineStore } from pinia export interface CartItem { id: number name: string price: number quantity: number } export const useCartStore defineStore(cart, { state: () ({ items: [] as CartItem[] }), getters: { totalCount: (state) state.items.reduce((sum, item) sum item.quantity, 0), totalPrice: (state) state.items.reduce((sum, item) sum item.price * item.quantity, 0) }, actions: { addItem(item: OmitCartItem, quantity) { const existing this.items.find((i) i.id item.id) if (existing) { existing.quantity } else { this.items.push({ ...item, quantity: 1 }) } }, removeItem(id: number) { const index this.items.findIndex((i) i.id id) if (index ! -1) { this.items.splice(index, 1) } }, clear() { this.items [] } } })对应测试import { describe, it, expect, beforeEach } from vitest import { setActivePinia, createPinia } from pinia import { useCartStore } from ./cart describe(stores/cart, () { beforeEach(() { setActivePinia(createPinia()) }) it(addItem 添加到购物车并累加数量, () { const store useCartStore() store.addItem({ id: 1, name: 苹果, price: 5 }) store.addItem({ id: 1, name: 苹果, price: 5 }) expect(store.items).toHaveLength(1) expect(store.items[0].quantity).toBe(2) expect(store.totalCount).toBe(2) expect(store.totalPrice).toBe(10) }) it(removeItem 删除指定商品, () { const store useCartStore() store.addItem({ id: 1, name: 苹果, price: 5 }) store.addItem({ id: 2, name: 香蕉, price: 3 }) store.removeItem(1) expect(store.items).toHaveLength(1) expect(store.items[0].name).toBe(香蕉) }) it(clear 清空购物车, () { const store useCartStore() store.addItem({ id: 1, name: 苹果, price: 5 }) store.clear() expect(store.items).toHaveLength(0) expect(store.totalPrice).toBe(0) }) })这里唯一的关键点就是setActivePinia(createPinia())。它必须在每个测试用例执行前调用确保每个用例里拿到的 store 都是独立、干净的。如果你忘记调用会直接报 “getActivePinia() was called but there was no active Pinia” 的错误这个问题在社区里出现频率极高。再看组件内如何集成测试。创建一个src/components/CartPanel.vuescript setup langts import { useCartStore } from ../stores/cart const cart useCartStore() /script template div classcart-panel p classtotal-price总计: {{ cart.totalPrice.toFixed(2) }}/p button classadd-btn clickcart.addItem({ id: 1, name: 测试商品, price: 9.9 }) 添加商品 /button /div /template测试时只需要在 mount 的 global.plugins 里传入一个真实的 pinia 实例import { describe, it, expect } from vitest import { mount } from vue/test-utils import { createPinia } from pinia import CartPanel from ./CartPanel.vue describe(CartPanel.vue, () { it(点击按钮后总价更新, async () { const wrapper mount(CartPanel, { global: { plugins: [createPinia()] } }) expect(wrapper.find(.total-price).text()).toContain(0.00) await wrapper.find(.add-btn).trigger(click) expect(wrapper.find(.total-price).text()).toContain(9.90) }) })这种模式不用 mock store 方法因为 store 本身已经在跑真实逻辑组件也只是去调用 store 的 action。好处是覆盖面广坏处是如果 store 里依赖了接口请求就需要为这些请求做 mock。真遇到这种情况我会给 store 的 action 函数传入依赖参数把请求函数作为参数注入而不是在 action 内部直接 import api 模块。这样测试时传一个假函数既保持测试独立性又不会破坏 store 的设计。6. ESLint Prettier 与 Vitest 的整合以及常见报错排查实录6.1 配置 ESLint 识别测试环境和代码格式项目里装了 ESLint Prettier 之后最大的问题不是代码写得不规范而是 ESLint 把测试文件里的 describe、it、expect 都当成了未定义变量满屏红色波浪线。这个问题的根源是 ESLint 默认只认浏览器和 Node 的全局变量不认 Vitest 注入的全局 API。如果 tsconfig 里开了types: [vitest/globals]且 vite.config 里也开了globals: true那只需要在 .eslintrc 的 env 中加入{ env: { browser: true, es2021: true, node: true, vitest/globals: true }, extends: [ eslint:recommended, plugin:vue/vue3-essential, plugin:typescript-eslint/recommended, plugin:vitest/recommended, prettier ], plugins: [typescript-eslint, vitest], parserOptions: { ecmaVersion: latest, parser: typescript-eslint/parser, sourceType: module }, ignorePatterns: [dist, coverage] }关键点是安装eslint-plugin-vitest并把plugin:vitest/recommended放进 extends。这个插件会替 Vitest 相关文件开启正确的全局变量和规则比如vitest/prefer-expect-assertions这种插件自带的检查规则。全英文变量名检查倒是没差但插件对测试文件的识别能直接在编辑器里消除大量红色波浪线。Prettier 的整合相对简单只要保证 .prettierrc 和 ESLint 的 rules 没有冲突。常规做法是在 extends 里最后一个位置放prettier它负责关闭 ESLint 中所有与格式化冲突的规则。如果你用 pnpm 或 npm 的 script 同时跑 lint 和 format顺序上我建议先 format 再 lint因为 Prettier 会把代码改成统一格式之后 ESLint 检查逻辑类的规则会更干净。6.2 高频报错整理从 “document is not defined” 到 “Cannot use import statement”我把自己实际跑项目和帮别人看问题时遇到的高频报错整理成了一张表很多问题都可以直接对号入座。报错信息出现原因解决方案document is not defined测试环境没有配 jsdom 或 happy-domvite.config 的 test.environment 设为 jsdomdescribe is not defined / it is not defined全局 globals 没开或 ESLint 没有配置全局变量开globals: true加types: [vitest/globals]getActivePinia() was called but there was no active Piniastore 测试没调用 setActivePiniabeforeEach 里setActivePinia(createPinia())Cannot use import statement outside a module项目配置里的 module 是 CommonJS或者没有把测试文件加入 Vite 的 include检查 tsconfig 的 module 是否是 ESNext检查 vite test include 是否正确[Vue warn]: Failed to resolve component: RouterLink组件测试里没有提供路由环境mount 时在 global.plugins 传入真实 routerwrapper.emitted is not a function误在原生元素 Wrapper 上调用 emitted先const child wrapper.findComponent(Child)再用 child.emittedVitest 启动后监听文件递归过深include 匹配到了 node_modules 或 distinclude 明确指向 src补充 exclude 配置TestingLibraryElementError: Unable to find an element by选择器写错或组件是异步渲染使用 await nextTick / flushPromises 后再断言这里挑两个最典型的补充说明。第一个是最常见的 “Cannot use import statement outside a module”。这个报错大多发生在一个 Vite 项目里引入了旧的 CommonJS 模块或者上一个配置残留了 CommonJS 的 node_modules 依赖。Vitest 内部会尝试转换但遇到某些老包还是可能失败。解决思路是在 vite.config 的 test 配置里加server.deps.inline或test.server.deps.inline把那些报错的包名强制交给 esbuild 转换。比如test: { environment: jsdom, server: { deps: { inline: [some-old-lib] } } }第二个是异步渲染导致的断言失败。组件里用了await nextTick()或请求返回后数据才更新如果测试里没有等待下一次渲染DOM 还没来得及变化断言就会提前失败。vue/test-utils 里提供了flushPromises工具import { flushPromises } from vue/test-utils it(等待 Promise 执行完成后再断言, async () { const wrapper mount(AsyncComponent) await flushPromises() expect(wrapper.text()).toContain(加载完成) })trigger虽然自带nextTick但如果组件内部有 setTimeout、Promise 链还是需要 flushPromises 或真实等待。这个坑在测试接口请求场景时几乎必踩。6.3 补充说明testbed 单元测试是个什么概念热词里出现的“testbed单元测试”跟 Vitest 这套前端方案不是一回事但它对应的是单元测试在地面嵌入式领域的一个典型变体。Testbed测试台/测试环境指一套完整的硬件或软件测试平台单元测试在其中负责验证一个函数、一个模块在目标芯片上的行为是否符合预期。如果你在嵌入式开发环境里听说过 Unity、CMock、Ceedling 这些工具它们就是专门做 C 语言单元测试的。Testbed 作为一个商业工具通常提供覆盖率分析、静态分析、运行时动态插桩等能力。它和前端单元测试的共同点是都期望在隔离条件下验证最小代码单元。不同点是嵌入式测试需要交叉编译、目标板交互跑一次用例的耗时远高于前端。这个概念对前端开发者来说不必深究但如果你的项目是全栈或涉及底层知道 testbed 的存在能帮你和嵌入式同事对话时不至于完全听不懂。测试的本质思想是通用的隔离、验证、反馈、回归。7. 我实际用下来的一些体会写单元测试这件事最大的体验变化不是“测试覆盖率从 0 变成 80%”而是你开始换一种方式写业务代码。以前写一个组件脑子里只想着“功能能跑”现在会不自觉地把组件拆成更小的单元把获取数据和渲染逻辑分开因为这样才好测。这其实就是单测带来的正向影响——它逼着你把依赖边界划清楚反而让代码结构更清晰了。另外一个感受是测试代码也是代码不要为了覆盖率而写一堆无效用例。我见过有人为了把分支覆盖率的数字凑上去给一个三行的方法写了十个用例。覆盖率数字好看但实际保护效果几乎没有。我更推荐把精力放在核心业务逻辑、复杂分支、跨模块交互这些容易出问题的位置。少而精的用例比多而泛的用例更能防止回归。如果你想在项目里推进单元测试可以从一个最小切口开始先给工具函数和 store 写测试这部分成本低、收益立竿见影再把高频使用的公共组件逐步补上最后才考虑页面级组件的路由集成测试。步子别迈太大先让团队体感到测试带来的安全感后面推起来自然顺畅。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →