接手过不少被测试代码追着跑的项目之后,我对单元测试的态度从"交差"变成了"救命"。团队里很多人一开始问我单元测试到底怎么下笔,尤其是面前摆着 Vue 全家桶、或者嵌入式 C 代码、又或者公司强制要求的 Testbed 工具时,往往一脸懵。有人说这不是写几个断言跑一下的事吗,真上手后才发现坑多得很。这篇算是我这几年的压箱底整理,从框架选型到 Vue 项目实战,再到嵌入式 C 的 Testbed 流程,最后是高频报错的完整排查思路,尽量做到够详细、够直接。
1. 动手之前,先搞清楚单元测试到底在测什么
很多人对单元测试的误解从第一句就开始了:单元测试不是"程序跑通了没",而是"当输入满足特定条件时,这个函数、组件、模块的输出是否符合约定"。这俩看着相近,实际差着一条鸿沟。
1.1 单元测试的边界:测的是逻辑,不是系统
单元测试的测法是"隔离"——把代码拆到最小的可独立运行的单元,通常是函数、类、组件,然后单独验证它。它不关心数据库里有没有数据,不关心后端是否返回 200,不关心网络通不通,也不关心 UI 上点了按钮之后整个页面跳转对不对。那些属于集成测试、E2E 测试的范畴。
我经常用一个例子说明:
- 单元测试:
formatPrice(1234.5)传入参数后返回了"1,234.50"字符串,对不对? - 集成测试:调用订单服务后,确认金额数据流经转化函数后能正常写入数据库。
- E2E 测试:用户打开下单页,输入金额,点击按钮,看到最终的格式化结果。
所以很多人项目里明明写了"单元测试",却把路由跳转、HTTP 请求、localStorage 读写全塞进一个单测里,跑一次慢如蜗牛,还动不动因为环境影响挂掉。这不是单元测试,这是拿单元测试的筐装集成测试的西瓜。
1.2 好测试的四条硬标准
我自己判断一个测试写得好不好,不看它数量多不多,而是看四条:
- 快:一个测试跑完应该在毫秒级,跑完整个单测集合理想情况不超过几分钟。如果出现 sleep、网络超时、磁盘 IO 等待,那一定不是单元测试。
- 隔离:测试之间互相独立,跑的顺序打乱、单跑一个用例或全量跑,结果一致。
- 确定性:同一份代码同一份用例,跑一百次结果都一样。任何随机数、时间戳、环境差异都要想办法固定或注入。
- 可读性:测试代码本身就是文档。三个月后你回来看测试,应该能一秒读懂被测单元的预期行为,而不是得像破案一样猜。
这四条里最容易被忽略的是"确定性"。我在实际项目里见过太多的测试是"偶尔挂一次",用户一反馈,第一反应是"重跑一下看它过没过"。这种测试早晚失去团队信任,最后沦为 CI 里的摆设。
1.3 覆盖率不是 KPI,但也不是没用
代码覆盖率常被当成流程指标。XX% 以下不让合入分支、不允许发版。覆盖率有参考价值,但它衡量的是"哪些代码被执行了",不是"哪些行为被验证了"。
if (a > 0) { return 1 } else { return -1 }只有一行测试只覆盖a > 0时,行覆盖率 100%,但分支覆盖率 50%。真正危险的往往不是没执行到的行,而是没验证到的行为路径。
所以我建议团队定指标时不要只看行覆盖,至少同时看分支覆盖或函数覆盖。覆盖率可以设门槛,但不能当最终考核标准。宁可少写几个用例,也要把关键分支和异常分支补全。
2. 测试框架选型:不同技术栈的答案完全不一样
选框架这事儿,只看热度和别人推荐没用。同一个团队里,前端、后端、嵌入式三个方向可能要用三套完全不同的方案。我见过因为前端项目用了 Jest,后端的同事也硬搬过来测 C 代码的例子,那真是互相折磨。
2.1 前端 Vue 项目:Vitest、Jest、Mocha 怎么选
先说结论:Vue 3 + Vite 项目我无脑推荐 Vitest。Vitest 和 Vite 共享同一套配置体系,跑测试时不需要再起一个单独的编译流程,速度明显快过 Jest。尤其是碰到大量组件的项目,这个速度差非常直观。
Jest 仍然是个好框架,但它和 Vite 项目结合时要面对的额外配置太多了:ESM 转换、路径别名解析、Vue SFC 编译、tsconfig 映射,每一样都是时间黑洞。如果你的项目还在用 Vue CLI,那 Jest 是老搭档,稳定成熟。但新项目只要用了 Vite,省心路线就是 Vitest。
Mocha 我不太推荐作为主框架,它太"裸"了,断言库要另装 Chai,Mock 要另装 Sinon,spy、stub、mock 三个概念分别在两个库里维护,心智负担重。它更像一个底层执行器,适合做二次封装,不适合直接当团队的测试框架。
2.2 后端与跨语言场景:JUnit 全家桶与 pytest
Java 后端,不用想,JUnit 5 + Mockito(或 MockK 处理 Kotlin)基本是事实标准。Spring Boot 项目里还可以配合spring-boot-starter-test,它把 JUnit、AssertJ、Mockito、Hamcrest 全打进去了。
Python 这边 pytest 是更现代、更好用的选择。原生 unittest 能干活,但写起来啰嗦,fixture 机制、参数化、插件生态都比不上 pytest。不过说实话,如果你的团队没什么历史包袱,直接从 pytest 上手就行。
2.3 专业嵌入式工具:Testbed 和轻量方案对比
嵌入式这块比较特殊。如果公司流程过了 CMMI 或汽车行业功能安全认证,基本绕不开 Testbed。Testbed(原 LDRA Testbed)是专业测试工具,能对 C/C++/Ada 代码做静态分析、复杂度分析、覆盖率分析、基于插桩的动态测试,还能自动生成测试用例的驱动程序,是很多军工、汽车、轨交行业的标配。
但 Testbed 不是唯一选择。如果只是想让嵌入式代码有基本的单测保障,轻量方案也能用:在 PC 上用 GCC 编译测试文件,配合一个轻量的断言库或手写简单断言宏,对被测函数直接做白盒测试。这种方式胜在零成本、启动快、极易落地,适合中小团队或早期验证。Testbed 的好处是体系完整、报告规范、认证友好,坏处是价格贵、学习成本高、用例管理偏重。选哪个,看你项目的质量和合规要求。
为了方便对比,我把常用方案的适用场景列一张表:
| 技术栈 | 推荐框架 | 适用场景 | 不足 |
|---|---|---|---|
| Vue 3 + Vite | Vitest | 新项目、组件与组合式函数测试 | 生态相对 Jest 略小 |
| Vue CLI / 老项目 | Jest | 存量项目、已复用 Jest 生态 | 与 Vite 混用配置麻烦 |
| Java | JUnit 5 + Mockito | Spring Boot 等后端 | 多线程/异步测试需要额外处理 |
| Python | pytest | 脚本、服务端、算法 | 大型项目合理组织 fixture 需要经验 |
| C/C++ 嵌入式 | Testbed 或轻量断言宏 | 汽车/军工/工业,或无工具依赖验证 | Testbed 成本高,轻量方案报告不完整 |
3. 从零写第一个有说服力的测试:断言、Mock 与参数化
框架只是皮,真正决定测试价值的是用例怎么设计。选好框架之后,得先掌握最核心的三个动作:写断言、做隔离、用参数化覆盖分支。
3.1 先从一个纯函数练手:断言怎么写才有意义
拿最常见的业务函数来演示,比如一个计算订单折扣的函数。代码用 TypeScript 写:
// src/utils/order.ts export function calcDiscount(price: number, vipLevel: 'normal' | 'silver' | 'gold'): number { if (price <= 0) throw new Error('price must be positive') const discountMap = { normal: 1, silver: 0.95, gold: 0.88 } return Number((price * discountMap[vipLevel]).toFixed(2)) }对应的测试:
// src/utils/order.test.ts import { describe, it, expect } from 'vitest' import { calcDiscount } from './order' describe('calcDiscount', () => { it('普通用户不打折', () => { expect(calcDiscount(100, 'normal')).toBe(100) }) it('银卡用户享受95折', () => { expect(calcDiscount(100, 'silver')).toBe(95) }) it('金卡用户享受88折', () => { expect(calcDiscount(100, 'gold')).toBe(88) }) it('价格为负数时抛出异常', () => { expect(() => calcDiscount(-1, 'normal')).toThrow('price must be positive') }) it('价格小数点后的金额按两位舍入', () => { expect(calcDiscount(10.456, 'silver')).toBe(9.93) }) })这里"有说服力"的关键是:
- 正常路径、边界路径、异常路径都覆盖了。
- 断言的是对外行为的精确结果,不是内部实现。
- 读测试的人能一眼看出函数要满足的规格。
如果只是写expect(calcDiscount(100, 'gold')).not.toBeUndefined()这种测试,跑了等于没跑。
顺便说一个常见坑:浮点数断言。如果你在断言里写expect(a + b).toBe(0.3)而实际是0.30000000000000004,测试就会莫名奇妙挂掉。JS 的浮点运算天然有精度问题,建议比较金额时用toBeCloseTo(9.93, 2),或者直接在业务函数里做toFixed后再用于展示,写进数据库也是存字符串或整数分。
3.2 Mock、Stub 与 Spy:隔离外部依赖的唯一手段
单元测试最核心的难点不是写断言,而是"让被测代码别碰真实的外部依赖"。比如前端组件里调用了axios,后端服务里访问了数据库,嵌入式代码读了一个 ADC 寄存器。测试环境里不能真发请求、不能真连库、不能真等硬件就绪。
这里要分清三个概念:
- Stub(桩):替换依赖实现,固定返回预定义数据。比如把
fetchUser()替换成固定返回{ name: '张三' }的函数。 - Spy(间谍):包一层监听原函数有没有被调用、被调了几次、传了什么参数,可以通过
vi.spyOn实现。 - Mock(模拟):不仅替换实现,还能验证行为。在 Vitest 中用
vi.mock()可以整体模拟一个模块。
拿 Vivitest 举例,最常见的 mock 外部模块方式:
// src/api/user.ts export const fetchUser = async (id: string) => { const res = await fetch(`/api/users/${id}`) return res.json() } // src/stores/user.ts 里的某个 action import { fetchUser } from '@/api/user' export const useUserStore = defineStore('user', { actions: { async loadUser(id: string) { this.user = await fetchUser(id) } } }) // 测试里只 mock 掉 fetchUser import { vi } from 'vitest' vi.mock('@/api/user', () => ({ fetchUser: vi.fn() }))Mock 的原则是"我只 mock 自己不拥有的东西"——网络、时间、随机数、系统时钟、文件系统、第三方 SDK。自己写的纯函数尽量不要 mock,mock 自己的逻辑会让测试失去意义,最后变成"用自己的假数据验证自己的假实现",就是自欺欺人。
3.3 参数化测试:用一张表跑完所有分支
写测试最怕的不是多,而是重复。同一个函数的不同输入输出,如果挨个复制粘贴用例,维护起来极度恶心。Vitest 和 Jest 都支持参数化:
import { describe, it, expect } from 'vitest' import { calcDiscount } from './order' describe('calcDiscount 参数化测试', () => { it.each([ [100, 'normal', 100], [100, 'silver', 95], [100, 'gold', 88], [10.456, 'silver', 9.93], [0, 'normal', 0], ] as const)('calcDiscount(%i, %s) 应该返回 %i', (price, level, expected) => { expect(calcDiscount(price, level)).toBe(expected) }) })it.each里的每一行就是一组输入输出。以后价格策略变了、折扣率变了,只改数据行就行,不用新增一坨用例代码。这也是测试代码可读性的体现——你要表达的是"规格",不是"一堆 if else"。
4. Vue 项目里的单元测试:组件、Router、Pinia 一起上
很多 Vue 新手写单测,跑起来第一步就挂在环境上。这里只谈 Vitest 路线,整套配置直接抄即可,再把 Router 和 Pinia 的测法讲透。
4.1 一套能直接落地的最小 Vite + Vitest 配置
要在 Vue 3 项目里跑组件测试,光装vitest不够,还需要@vue/test-utils和jsdom。安装命令示意:
npm install -D vitest @vue/test-utils jsdom然后是vitest.config.ts:
import { defineConfig } from 'vitest/config' import vue from '@vitejs/plugin-vue' import { fileURLToPath, URL } from 'node:url' export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, test: { environment: 'jsdom', globals: true, setupFiles: ['./src/setupTests.ts'], coverage: { provider: 'v8', include: ['src/**/*.{ts,vue}'], exclude: ['src/main.ts', 'src/env.d.ts'], }, }, })必须手动做两件事:设置environment: 'jsdom',否则组件里的document、window全是不存在的;设置globals: true并配合tsconfig.json里"types": ["vitest/globals"],否则每个文件都要显式import { describe, it, expect } from 'vitest',极烦。
测试环境里我还会补一个简单的 setup 文件,用来 mock 掉window.matchMedia、ResizeObserver这类浏览器原生 API,因为 jsdom 不支持它们而组件又在初始化时调用了它们:
// src/setupTests.ts import { vi } from 'vitest' Object.defineProperty(window, 'matchMedia', { writable: true, value: vi.fn().mockImplementation(query => ({ matches: false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), }) class ResizeObserverMock { observe() {} unobserve() {} disconnect() {} } window.ResizeObserver = ResizeObserverMock4.2 组件测试:挂载、触发、断言
组件测试我现在几乎不用shallowMount,除非被测组件枝条实在太长、子组件干扰太严重。大多数场景下mount更贴近真实,断言更可靠。
先看一个计数器组件:
<!-- src/components/Counter.vue --> <template> <div class="counter"> <p>// src/components/Counter.test.ts import { mount } from '@vue/test-utils' import Counter from './Counter.vue' describe('Counter', () => { it('初始渲染为 0', () => { const wrapper = mount(Counter) expect(wrapper.get('[data-test="count"]').text()).toBe('0') }) it('点击按钮后 count 变为 1', async () => { const wrapper = mount(Counter) await wrapper.get('[data-test="increment"]').trigger('click') expect(wrapper.get('[data-test="count"]').text()).toBe('1') }) })两个细节值得注意:
第一,await不能省。trigger返回的是一个 Promise,Vue 的 DOM 更新是异步的。不await就立刻断言,拿到的还是旧视图。这是新人最常见的"测试写对了但跑不过"的原因之一。
第二,测试的选择器。我习惯用>// src/router/index.ts import { createRouter, createMemoryHistory } from 'vue-router' const routes = [ { path: '/', name: 'home', component: { template: '<div>Home</div>' } }, { path: '/detail/:id', name: 'detail', component: { template: '<div>Detail</div>' } }, ] export const router = createRouter({ history: createMemoryHistory(), routes, })
测试里操作跳转:
// src/router/router.test.ts import { describe, it, expect } from 'vitest' import { router } from './index' describe('router 基础跳转', () => { it('跳转详情页后 currentRoute 匹配正确', async () => { router.push({ name: 'detail', params: { id: '123' } }) await router.isReady() expect(router.currentRoute.value.params.id).toBe('123') expect(router.currentRoute.value.name).toBe('detail') }) })如果是测试组件内部调用了router.push,可以用vi.mock('vue-router')把useRouter全局 mock 掉:
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn(), }), useRoute: () => ({ params: { id: '123' }, }), }))4.4 Pinia 测试:仓库内部逻辑与组件绑定分开测
Pinia 官方推荐单测时先setActivePinia(createPinia()),再获取 store 实例直接调用 action。这样测的是 store 自身的逻辑,跟组件一点关系都没有。
// src/stores/user.test.ts import { setActivePinia, createPinia } from 'pinia' import { beforeEach, describe, it, expect, vi } from 'vitest' import { useUserStore } from './user' import { loginApi } from '@/api/auth' vi.mock('@/api/auth', () => ({ loginApi: vi.fn(), })) describe('user store', () => { beforeEach(() => { setActivePinia(createPinia()) localStorage.clear() }) it('login 成功后保存 token 与用户信息', async () => { vi.mocked(loginApi).mockResolvedValue({ token: 'token-123', user: { name: '张三' } }) const store = useUserStore() await store.login('zhangsan', 'password') expect(store.token).toBe('token-123') expect(localStorage.getItem('token')).toBe('token-123') }) it('login 失败时不写 token', async () => { vi.mocked(loginApi).mockRejectedValue(new Error('invalid password')) const store = useUserStore() await expect(store.login('zhangsan', 'wrong')).rejects.toThrow('invalid password') expect(store.token).toBe('') }) })4.5 ESLint 与 Prettier 怎么配合 Vitest 不吵架
热词里有 "eslint + prettier vitest单元测试",这个组合确实容易在工程化阶段反复折腾。最常碰到的问题有两个:
一是 ESLint 报describe is not defined或it is not defined。这是 ESLint 不认识globals。解决方式是前台在.eslintrc.cjs里加:
module.exports = { root: true, env: { node: true, es2022: true, }, extends: [ 'eslint:recommended', 'plugin:vue/vue3-recommended', 'plugin:vitest/recommended', 'prettier', ], globals: { describe: 'readonly', it: 'readonly', expect: 'readonly', vi: 'readonly', }, }二是 Prettier 对测试文件中长链式断言的缩进有不同偏好,最常见的是expect(...).toHaveBeenCalledWith(...)超过行长后自动换行,导致与团队格式不一致。这种问题不需要在 ESLint 里写复杂规则,统一交给eslint-config-prettier关闭所有与 Prettier 冲突的规则就好,别再让 ESLint 管格式,它只管逻辑。
5. 嵌入式软件单元测试怎么做:从轻量方案到 Testbed 全流程
嵌入式软件的单测一直是很多团队头痛的点。没有操作系统、依赖硬件寄存器、交叉编译链复杂,很多人一听就头大。但嵌入式 C 代码其实非常适合做单元测试——因为函数大多接受参数、返回结果,只要你把硬件依赖挡在外面,测试起来比 UI 组件还简单。
5.1 嵌入式单测的核心难点与桩函数设计
嵌入式代码和普通 C 代码最大的区别是:被测函数经常直接调用 HAL 函数、寄存器宏、外部全局变量。比如一个read_temperature()函数,内部可能调用了HAL_ADC_Read(),测试环境里根本没这块硬件。
思路很朴素:写一个同名桩函数替换掉它,让桩函数返回一个你在测试里可控的数据。
/* src/adc.h */ #ifndef ADC_H #define ADC_H #include <stdint.h> int16_t HAL_ADC_Read(void); #endif /* src/temperature.c */ #include "temperature.h" float convert_temperature(void) { int16_t adc_value = HAL_ADC_Read(); /* 假设温度 = ADC值 * 0.01 度 */ return adc_value * 0.01f; }测试文件里,你不想真的链接 HAL 库,就自己定义一个同名桩:
/* tests/stubs/stub_adc.c */ int16_t fake_adc_value = 0; int16_t HAL_ADC_Read(void) { return fake_adc_value; }然后测试函数里直接改fake_adc_value,来模拟不同的 ADC 采样值:
/* tests/test_temperature.c */ #include "minunit.h" #include "temperature.h" extern int16_t fake_adc_value; MU_TEST(test_convert_temperature_at_zero) { fake_adc_value = 0; mu_assert(convert_temperature() == 0.0f, "0 度时转换错误"); } MU_TEST(test_convert_temperature_at_25c) { fake_adc_value = 2500; mu_assert(convert_temperature() == 25.0f, "25 度时转换错误"); } MU_SUITE_CONST(suite_temperature) { MU_RUN_TEST(test_convert_temperature_at_zero); MU_RUN_TEST(test_convert_temperature_at_25c); } int main(void) { MU_RUN_SUITE(suite_temperature); MU_REPORT(); return MU_EXIT_CODE; }这个示例用的minunit是个单头文件的极简测试库,只要把minunit.h放进测试目录,直接用gcc tests/test_temperature.c src/temperature.c tests/stubs/stub_adc.c -I src -I tests -lm -o test_temperature就能编译运行。整个过程不依赖硬件、不依赖板子上的调试器,纯 PC 上跑。
实际项目里,如果被测函数内部有大量寄存器读写宏,例如:
#define ADC_DATA_REG (*((volatile uint32_t *)0x40012000))这种情况没法靠桩函数解决,只能做两件事:把寄存器操作提取成可替换的函数,或者通过预处理器将其重定向。在测试构建里,可以用-DADC_DATA_REG=fake_adc_data把宏重定向到一个测试可读写的全局变量。不过长期看,更健康的方式是代码里尽量封装抽象的硬件访问层,测试替身才容易插入。
5.2 Testbed 的实际落地流程(比想象中平滑)
Testbed 门槛高在公司选型、许可证、流程规范上,工具本身的使用流程其实很线性。标准流程大致六步:
- 导入工程:把被测源码目录导入 Testbed,它能识别主流嵌入式 IDE 工程,比如 IAR、Keil、GCC Makefile。
- 静态分析:先做静态检查,包括复杂度、规则、数据流。这一步会先暴露不少隐患,比如未初始化变量、越界索引,提前修掉有意义。
- 插桩与构建:Testbed 会自动生成插桩代码,在函数入口、出口、分支处做标记,用来收集执行状态数据。这个过程完全图形化配置,不用手改源码。
- 生成测试驱动代码:手动设定正常路径、边界路径、异常路径的输入,或者用它的自动用例生成器,工具会生成
main测试入口和被测函数的调用代码。 - 执行测试与覆盖率统计:在 PC 或者目标板上执行,Testbed 会收集语句覆盖、分支覆盖、MC/DC 覆盖。这是过认证和出报告时最看重的指标。
- 导出报告:能直接导出结构化覆盖率报告和测试报告,供审核和归档。
对已经跑过流程的团队来说,Testbed 最值钱的部分是 MC/DC 覆盖率的自动化。功能安全标准里对条件组合覆盖有硬性要求,比如说一个if (a && b),你要证明a和b真假组合里,每个条件独立决定结果。手工算这些组合极其痛苦,Testbed 能在插桩后自动分析并帮助你补充用例,这是轻量方案替代不了的。
5.3 轻量方案与 Testbed 的边界:什么时候别硬上
既然轻量方案也能测,为什么还要 Testbed?我自己的判断标准有三条:
- 团队对代码覆盖率有没有硬性合规要求(如 ISO 26262、DO-178C),有就上 Testbed,因为它生成的覆盖率报告和追溯矩阵是被认证机构认可的。
- 被测代码量级有多大,上万行纯 C 逻辑时,手写 stub 和测试驱动容易漏掉;Testbed 的全自动用例管理在量级大时能节省大量人天。
- 项目周期和成本评估,Testbed 有较高的授权费用,对一个小团队做一两个月的原型验证来说,轻量方案更务实。
所以结论不是"嵌入式必须 Testbed",而是"合规和规模决定工具,逻辑和桩函数决定一切"。不管用哪套,桩函数和用例设计能力都是通用的。
6. vue+单元测试报错排查:最常见的五个坑的完整定位链路
热词里"vue+单元测试报错"这组词说明一个问题:Vue 项目配 Vitest 报错的频率真的不低。我团队里新人上手时,十个里有七个会卡在配置或者异步问题上。下面把最常出现的五种报错按排查链路完整走一遍。
6.1 "Error: window is not defined"
这个报错发生在你测试文件里用了document、window,或者组件模板里访问了浏览器 API,但测试环境匹配的还是默认的node。
排查链路:
- 打开
vitest.config.ts,看test.environment是不是node。 - 改成
jsdom,重启测试。 - 如果改了还在报,可能是某个第三方库在 import 时就访问了
window,此时要在 setup 文件里给globalThis.window做 shim,或者用vi.stubGlobal来模拟。
6.2 "SyntaxError: Cannot use import statement outside a module"
这个报错通常出现在组件测试中 import 了一个 CommonJS 格式的第三方库时。
排查链路:
- 确认该库是不是
require()导出的。 - Vitest 默认会用 Vite 的转换管道处理所有模块,但如果测试跑的是
.ts直接调ts-node而非 Vite,就会出现这个错。确保你用的是vitest命令,不是自定义的ts-node脚本。 - 如果第三方包在
node_modules里没被 Vite 处理,在配置中执行test.server.deps.inline把该包名加进去,或者用resolve.alias指向源码而不是编译产物。
6.3 "Cannot find module '@components/Button.vue'"
Vite 项目里@别名默认是配在vite.config.ts里的。测试启动会读vitest.config.ts,如果你单独建的 Vitest 配置没有继承 Vite 的resolve.alias,它就找不到路径了。
排查链路:
- 确认
vitest.config.ts里是否配置了resolve.alias,最好直接读取并复用vite.config里的别名映射。 - 更省事的方式是不单独建
vitest.config.ts,直接在vite.config.ts里添加test字段,并用/// <reference types="vitest" />声明。Vite 和 Vitest 共用同一个配置文件时,别名、插件都天然一致,少一半的路径坑。
6.4 "ResizeObserver is not defined"
组件里用到了图表库、虚拟滚动库、抽屉组件时太常见了。jsdom 不支持ResizeObserver。
排查链路:
- 直接在 setup 文件里塞一个空实现,这是最通用的做法。
- 如果组件不支持把它 mock 掉,考虑在单个测试中
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })。
6.5 异步断言狂报 "[ERR_HTTP_INVALID_HEADER_VALUE]" 或请求超时
这个错误的核心原因,往往是测试代码里调用了真实的 HTTP 请求:
const res = await fetch('/api/xxx') // 测试环境实际发出去了排查链路:
- 检查被测模块顶部是否 mock 了请求模块。如果没 mock,直接新增
vi.mock('@/api/xxx')。 - 如果 mock 了但仍然发出请求,说明你 mock 的模块名和被测模块里 import 的模块名不一致。Vite 模块路径解析对大小写、扩展名很敏感,
@/api/xxx和../api/xxx是两回事。 - 追不到就直接全局 mock fetch:
vi.stubGlobal('fetch', vi.fn()),把网络请求从源头挡掉。
排查这类异步问题有个通用技巧:在测试失败时console.log一下当前的 call stack 或者把vi.mock放到文件顶部而不是beforeEach里。Vitest 会做模块提升,vi.mock会自动提升到文件最上方,如果你把它藏在beforeEach里,mock 时机不对就会导致依赖没生效,行为完全是迷。记住一句话:vi.mock永远放在文件顶部,不要放在生命周期钩子里;需要改变 mock 返回值,用mockImplementation,不要重复调用vi.mock。
7. 最后分享一个提升团队测试意识的小技巧
讲了很多工程细节,最后说个在团队协作上非常管用的做法:每次修复一个 bug 时,先写一个会失败的测试,再让修复后的代码通过它。一次两次可能觉得多此一举,坚持三个月之后你回头看,那些最刁钻的回归问题,绝大多数都被当初的测试挡在了门外。
还有个小点,测试代码和人一样也会腐烂。跑一次测试要 10 分钟、断言全是宽松匹配、失败的用例没人愿意修,这时候要先停下来清理测试本身,而不是继续往上堆用例。把测试当代码一样评审、重构,它才能真正成为项目的护城河,而不是 CI 里的背景噪音。