前端测试选型:单元测试、组件测试和 Cypress 各自该守住什么
什么情况该写单元测试,什么情况非端到端测试不可,这是我们团队这半年一直没彻底说清楚的问题。中后台项目大大小小十几个仓库,测试覆盖参差不齐:有的模块权限判断改一次坏一次,有的表单错误映射总有人漏,还有的发布前必须人工把登录、下单、审核流程点一遍,谁都不敢跳过。
测试工具这边这两年选择也在变多。Jest 仍然是团队默认的单测框架,配置成熟、生态齐全,ts-jest、babel-jest 该有的都有;但最近我在一个用 Vite 起的内部工具项目里试了试 Vitest——它复用 Vite 的配置和转换链路,跑起来确实比 Jest 起 ts-jest 转译快不少,只是它现在还是 0.x 版本,API 时不时会有小改动,我暂时只敢在这类内部小项目里用,主仓库还是 Jest 打底。Cypress 这边倒是没有纠结,团队从两年前开始就用它做端到端测试,这次要理清楚的是它和组件测试、单元测试之间该由谁覆盖哪一块,而不是要不要用它。
先把判断标准摆在前面:一段逻辑要不要写测试、写哪一层的测试,我现在看三个维度——它是不是纯逻辑(输入输出确定,没有 DOM、没有网络)、它是不是很难靠人工点击稳定复现(时序、边界值、并发状态)、它改错了影响面有多大(权限、金额、发布流程这类改错就是生产事故的逻辑)。纯逻辑且难以手工验证的,单元测试收益最高;涉及用户交互和渲染结果的,组件测试更合适;只有真正跨页面、跨系统的关键路径,才值得上 Cypress,因为它慢,还依赖真实浏览器环境和网络状态。
分层保护网,而不是覆盖率数字
前端测试大致可以分三层:单元测试测纯函数和工具逻辑,组件测试测组件在特定输入下的渲染和交互,端到端测试模拟用户完整流程。三层解决的问题完全不同,混着用只会互相拖累。
单元测试快、稳定、便宜,适合大量覆盖业务规则;端到端测试最接近真实用户,但慢、维护成本高,不适合覆盖所有分支。所以不要用 Cypress 去测一个金额格式化函数,也不要只靠单元测试去证明登录流程完整可用——反过来想更直观:如果一个用例失败了,你希望它在几秒内告诉你原因,还是可以接受几十秒排查一整条流程? 前者交给单元测试,后者才是端到端测试的活。
1单元测试:保护规则和转换 2组件测试:保护交互和展示 3E2E 测试:保护关键业务路径
哪一层坏了,就用哪一层的工具。不要用最贵的测试去测最小的逻辑,也不要用最便宜的测试假装覆盖了真实流程。
纯逻辑测试:Jest 打底,Vitest 看项目挑着用
纯逻辑这一层,写法在 Jest 和 Vitest 里几乎一样,describe/it/expect 的 API 是共通的,这也是我敢在个别项目上小范围换掉 Jest 的原因之一——迁移成本低,就算用不惯还能随时切回去。
1import { describe, expect, it } from 'vitest' 2 3function formatPrice(value: number) { 4 return `¥${value.toFixed(2)}` 5} 6 7describe('formatPrice', () => { 8 it('formats integer', () => { 9 expect(formatPrice(12)).toBe('¥12.00') 10 }) 11 12 it('formats decimal', () => { 13 expect(formatPrice(12.5)).toBe('¥12.50') 14 }) 15})
同样的用例用 Jest 写,把 import 换成全局注入的 describe/it/expect 基本就够了,团队里还没上 Vite 的老项目继续用 Jest 完全没有切换的压力。这类测试价值很高,因为它稳定、运行快、失败原因明确。
更接近业务的例子是权限判断:
1type User = { 2 role: 'guest' | 'editor' | 'admin' 3} 4 5function canDeletePost(user: User) { 6 return user.role === 'admin' 7} 8 9it('only admin can delete post', () => { 10 expect(canDeletePost({ role: 'guest' })).toBe(false) 11 expect(canDeletePost({ role: 'editor' })).toBe(false) 12 expect(canDeletePost({ role: 'admin' })).toBe(true) 13})
权限、金额、日期、路由守卫、表单校验、请求错误转换,这些都非常适合单元测试。
请求层一定值得测
很多线上问题来自接口错误处理不一致。请求层如果有统一错误转换,就应该补测试。
1type ApiError = { 2 status: number 3 code: string 4 message: string 5} 6 7function normalizeApiError(status: number, body: any): ApiError { 8 return { 9 status, 10 code: body?.error?.code || 'REQUEST_FAILED', 11 message: body?.error?.message || '请求失败', 12 } 13} 14 15it('normalizes api error', () => { 16 expect( 17 normalizeApiError(400, { 18 error: { 19 code: 'INVALID_NAME', 20 message: '名称不合法', 21 }, 22 }) 23 ).toEqual({ 24 status: 400, 25 code: 'INVALID_NAME', 26 message: '名称不合法', 27 }) 28})
这是很典型的低成本高收益测试。一旦错误结构变了,测试会立刻提醒你。
这类测试尤其适合中后台项目。因为中后台的大量体验问题都不在"页面打不开",而在失败时反馈不一致:列表空白、表单字段错误不显示、权限错误被当成系统异常。请求层测试能把这些底层协议固定住。
排查这类错误转换问题时,我最近开始顺手用上 Error 的 cause 选项——这是今年才写进 ES2022 标准的新特性,把原始异常挂在包装后的 Error 上,不用再自己额外拼一个 originalError 字段:
1function toDisplayError(status: number, body: any) { 2 const detail = normalizeApiError(status, body) 3 return new Error(detail.message, { cause: detail }) 4} 5 6it('keeps original error detail on cause', () => { 7 const err = toDisplayError(400, { error: { code: 'INVALID_NAME', message: '名称不合法' } }) 8 expect((err.cause as any).code).toBe('INVALID_NAME') 9})
这个写法目前主要在我们自己维护的几个包里用,用到 cause 的地方还得留意目标运行环境是不是够新,Node 16 是没问题的,但如果这个错误对象要传到某些老旧的日志采集脚本里,得先确认它们不会因为多出来的字段而解析出错。
异步逻辑里还有一类特别适合单测:防抖、节流、超时和重试。它们靠人工点很难稳定验证,靠真实时间跑又会让测试变慢。Jest 的 jest.useFakeTimers() 一直支持这种场景,Vitest 照抄了同一套心智模型,API 名字换成了 vi.useFakeTimers(),其余用法几乎一模一样:
1import { afterEach, expect, it, vi } from 'vitest' 2 3afterEach(() => { 4 vi.useRealTimers() 5}) 6 7function debounce(fn: () => void, delay: number) { 8 let timer: ReturnType<typeof setTimeout> 9 return () => { 10 clearTimeout(timer) 11 timer = setTimeout(fn, delay) 12 } 13} 14 15it('runs debounce callback only once', () => { 16 vi.useFakeTimers() 17 const fn = vi.fn() 18 const run = debounce(fn, 300) 19 20 run() 21 run() 22 run() 23 24 vi.advanceTimersByTime(299) 25 expect(fn).not.toHaveBeenCalled() 26 27 vi.advanceTimersByTime(1) 28 expect(fn).toHaveBeenCalledTimes(1) 29})
这类测试的价值很直接:不等 300ms 真实时间,也不用担心 CI 机器慢导致用例偶发失败。凡是和时间有关的逻辑,我都会优先考虑 fake timers。
组件测试不要测实现细节
组件测试最容易写错的地方,是过度关注实现。
不要测试"组件内部有没有调用某个函数三次",而应该测试用户能看到什么、能做什么。团队大部分项目还是 Vue 2.6 加 @vue/composition-api 插件在写,组件测试我们用的是 @vue/test-utils,配合 Jest 跑,例如一个计数按钮:
1import { mount } from '@vue/test-utils' 2import Counter from './Counter.vue' 3 4it('increments count when clicked', async () => { 5 const wrapper = mount(Counter) 6 7 await wrapper.find('[data-testid="add"]').trigger('click') 8 9 expect(wrapper.text()).toContain('Count: 1') 10})
这里关注的是用户点击按钮后界面变化,而不是组件内部的响应式数据是用 data() 还是 reactive() 声明的。听说 React 18 那边今年 3 月发了正式版本,带了并发渲染这类新特性,隔壁生态的组件测试估计也要跟着适配一阵子,不过那是另一个话题了。
测试越贴近用户行为,重构时越稳定。这一层如果项目本身已经用 Vite 起了 Vue 3,倒是可以直接尝试 Vitest 搭配 @vue/test-utils,两者的渲染断言写法几乎一样,只是运行器换了个名字。
Cypress 适合完整流程
Cypress 适合验证关键业务路径,例如:
- 登录
- 下单
- 发布文章
- 修改资料
- 权限拦截
- 表单提交失败提示
一个登录流程示例:
1describe('login', () => { 2 it('shows error message when password is wrong', () => { 3 cy.visit('/login') 4 5 cy.get('input[name="email"]').type('[email protected]') 6 cy.get('input[name="password"]').type('wrong-password') 7 cy.get('button[type="submit"]').click() 8 9 cy.contains('账号或密码错误').should('be.visible') 10 }) 11})
端到端测试要少而精。重点覆盖"坏了会影响业务"的路径,而不是每个页面都写一条打开测试。
Mock 要有边界
测试里经常需要 mock 接口。mock 的好处是稳定,坏处是可能离真实接口越来越远。
例如:
1cy.intercept('POST', '/api/login', { 2 statusCode: 401, 3 body: { 4 error: { 5 code: 'INVALID_CREDENTIALS', 6 message: '账号或密码错误', 7 }, 8 }, 9})
这种 mock 适合测试错误提示。但如果所有 E2E 都 mock 掉后端,就无法发现前后端接口契约变更。
我一般会拆成两类:
- UI 状态测试:可以 mock 接口
- 关键集成流程:尽量连测试环境真实接口
这样既保证测试稳定,也保留发现集成问题的能力。
E2E 里还有个很现实的稳定性问题:选择器不要绑在样式和文案上。样式类名会因为重构变,按钮文案会因为产品改文案变,测试跟着碎。关键路径我会给元素加稳定的测试属性:
1<button data-cy="submit-order">提交订单</button>
1cy.get('[data-cy="submit-order"]').click()
这不是为了污染业务代码,而是给自动化测试一个稳定锚点。对用户可见文案仍然可以断言,比如错误提示是否出现;但触发动作的定位,最好不要依赖 .btn-primary 这种实现细节。我们有次改 UI 组件库,类名全变,只有用 data-cy 的 E2E 基本没动,那次之后我就把这条写进测试约定了。
还要测接口契约
前端测试不一定只能测 UI。
如果前后端约定了错误结构、分页结构、字典结构,最好有一层契约校验。它可以很轻量,比如对 mock 数据或测试环境响应做 schema 检查:
1function assertPageResult(result: unknown) { 2 expect(result).toEqual({ 3 list: expect.any(Array), 4 total: expect.any(Number), 5 }); 6}
这类测试不复杂,但能提前发现"后端字段改了,前端还不知道"的问题。尤其是多个应用共用同一套接口时,契约测试比单纯 UI 测试更早暴露风险。
monorepo 里测试要分清跑的范围
我们内部工具那几个包最近从 yarn workspace 挪到了 pnpm workspace,顺带把测试脚本也重新理了一遍。pnpm 这两年在团队里明显升温,pnpm -r test 可以一次性把所有子包的测试跑一遍,但子包一多,全量跑的耗时也跟着涨。更实际的做法是配合 pnpm --filter 只跑改动影响到的包:
1pnpm --filter ./packages/shared-utils test 2pnpm --filter ./packages/order-sdk... test
... 这个过滤语法会把依赖这个包的下游也一并带上,避免只测了被改的包、却漏了依赖它的包。CI 上我们目前还是简单地按目录做增量判断,没做到根据依赖图精确裁剪,但至少不用每次都跑全量。
GitHub Actions 这边现在是团队 CI 的标准选择,配矩阵任务同时跑多个 Node 版本也顺手。团队主力还在 Node 16 LTS 上,但 4 月发布的 Node 18 已经进了观察名单,我们在 CI 里加了一条不影响主流程的矩阵任务,先跑着看看兼容性:
1strategy: 2 matrix: 3 node-version: [16, 18] 4steps: 5 - uses: actions/setup-node@v3 6 with: 7 node-version: ${{ matrix.node-version }} 8 - run: pnpm install 9 - run: pnpm test
Node 18 那条任务允许失败不阻塞合并,等它转成 LTS、团队其他基础设施都验证过,再考虑把它转正成必跑项。
从哪里开始补测试
老项目补测试最怕一上来目标太大。更现实的顺序是:
第一,补工具函数和业务规则测试。它们最容易写,也最稳定。
第二,补请求层错误处理测试。确保 API 错误统一转换。
第三,补核心组件测试。比如表单、弹窗、权限按钮。
第四,补 3 到 5 条关键 E2E 流程。不要追求多,先保证主流程不被破坏。
第五,把测试放进 CI。只有本地偶尔跑的测试,长期价值会打折。
我会先把 CI 门槛设得很朴素:
1每次 PR 必跑:lint + unit test + build 2核心分支合并前:补跑关键 E2E 3发布前:跑完整回归
不要一开始就要求所有 E2E 每次提交都跑完。慢到大家都想绕过的测试,最后会失去约束力。
测试失败时要容易定位
好的测试失败后,应该能快速告诉你哪里坏了。
不好的测试经常是:
1expect(wrapper.html()).toMatchSnapshot()
大型快照一旦变化,很难判断是正常改动还是 bug。
更好的断言是具体的:
1expect(wrapper.text()).toContain('保存成功') 2expect(wrapper.find('[data-testid="submit"]').attributes('disabled')).toBeDefined()
断言越具体,失败越容易定位。
我现在补测试,一般不会从"覆盖率还差多少"开始,而是从最烦人的回归问题开始。
权限判断老坏,就给权限规则补一条 Jest 单测;表单错误映射总漏,就测请求层和转换函数;每次发版都要人工点一遍登录、下单、支付,就用 Cypress 把这条关键流程固定下来。测试的价值,是把那些人肉重复检查、又容易漏的风险交给机器。
单元测试适合纯逻辑、请求层、hooks 和轻量组件,工具上 Jest 依然是团队默认项,Vitest 目前还只在 Vite 起步的新项目里小范围试,等它再稳一段时间、生态插件补齐一些,再考虑要不要往更多项目上推;Cypress 适合关键用户流程和集成验证,这个选型已经很稳定,没什么好犹豫的。权限判断改一次坏一次、表单错误映射总有人漏、发版前非要人工把登录下单流程点一遍——这几个我们这半年一直没理清的问题,现在总算能对上号:分别丢给单元测试、请求层测试和 Cypress 盯着就是了。