前端测试选型:单元测试、组件测试和 Cypress 各自该守住什么

什么情况该写单元测试,什么情况非端到端测试不可,这是我们团队这半年一直没彻底说清楚的问题。中后台项目大大小小十几个仓库,测试覆盖参差不齐:有的模块权限判断改一次坏一次,有的表单错误映射总有人漏,还有的发布前必须人工把登录、下单、审核流程点一遍,谁都不敢跳过。

测试工具这边这两年选择也在变多。Jest 仍然是团队默认的单测框架,配置成熟、生态齐全,ts-jestbabel-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})

这是很典型的低成本高收益测试。一旦错误结构变了,测试会立刻提醒你。

这类测试尤其适合中后台项目。因为中后台的大量体验问题都不在"页面打不开",而在失败时反馈不一致:列表空白、表单字段错误不显示、权限错误被当成系统异常。请求层测试能把这些底层协议固定住。

排查这类错误转换问题时,我最近开始顺手用上 Errorcause 选项——这是今年才写进 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 盯着就是了。