前端到底该写哪些测试?单测、组件测试、E2E 的分工

· 10 分钟阅读
系列:前端测试 #前端 #测试 #工程化
前端到底该写哪些测试?单测、组件测试、E2E 的分工

我刚开始给前端项目补测试的时候,其实有一个很幼稚的想法:覆盖率越高,项目就越稳。

后来我发现这句话只对了一半。覆盖率确实能说明一些问题,但它说明不了最关键的问题:这些测试到底有没有保护业务。

有一段时间,我写过不少“看起来很勤奋”的测试。组件渲染一下,快照拍一下;按钮点一下,断言某个 mock 函数被调用;工具函数随便补几个 case,覆盖率就涨上去了。当时代码覆盖率很好看,但我心里并没有更踏实。因为真正改业务的时候,该怕还是怕。

最典型的一次是我改了一个表单组件的 DOM 结构。业务逻辑没变,用户看到的交互也没变,只是把几个 div 换成了更合理的结构。结果一堆快照测试全红。我花了半天看 diff,最后基本都只是更新快照。那一刻我有点崩溃:这些测试没有帮我发现 bug,只是在提醒我“你改了 HTML”。

也是从那以后,我开始重新看待前端测试。测试不是写得越多越好,也不是越接近真实用户越好。真正有用的测试,应该把不同层级的风险交给不同层级的测试去兜。

我现在更喜欢用这句话总结:

单测保证逻辑对,组件测试保证交互对,E2E 保证流程对。

这句话不复杂,但真正写项目的时候很容易忘。

测试最怕的是职责错位

前端测试里最容易踩的坑,不是工具不会用,而是测试类型选错了。

比如一个很简单的邮箱校验规则:

export function validateEmail(email: string) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

这种逻辑明明一个单测就能说清楚,但如果非要打开浏览器、进入登录页、输入邮箱、点击提交,再通过 E2E 去验证邮箱格式,那就太重了。

反过来,如果一个“发布文章”的完整流程,涉及登录、权限、路由跳转、接口请求、列表刷新,你只测其中一个 formatPostData 函数,也很难证明流程真的没断。

以前我很容易犯这个错误:能写单测的地方写成组件测试,应该写组件测试的地方又偷懒写成 mock 函数断言,真正该用 E2E 兜住的核心链路反而没测。

后来我给自己定了一个判断标准:

  • 逻辑规则清楚,就先想单测
  • 用户交互复杂,就先想组件测试
  • 业务链路不能断,才上 E2E

这个顺序能帮我少写很多没必要的测试。

单测:适合那些“不需要打开页面也能讲清楚”的逻辑

单测最适合测纯逻辑。

比如权限判断、表单校验、金额计算、日期格式化、接口数据转换、状态 reducer,这些代码都有一个共同点:输入是什么、输出应该是什么,基本能讲清楚。

我比较喜欢给这类代码写单测,因为它反馈快,也不太容易被 UI 重构影响。你今天用 React,明天换 Vue,或者只是把页面结构改了,只要规则没变,测试就不应该变。

举个权限判断的例子:

type User = {
  id: string;
  role: 'admin' | 'editor' | 'viewer';
};

type Post = {
  authorId: string;
  status: 'draft' | 'published';
};

export function canEditPost(user: User, post: Post) {
  if (user.role === 'admin') return true;
  if (post.status === 'published') return false;
  return user.id === post.authorId && user.role === 'editor';
}

这段代码就不需要组件测试,更不需要 E2E。它的规则很明确:

  • admin 可以编辑任何文章
  • 已发布文章不能被普通 editor 编辑
  • editor 只能编辑自己的草稿

对应的测试也应该直接表达这些业务规则:

import { describe, expect, it } from 'vitest';
import { canEditPost } from './permission';

describe('canEditPost', () => {
  it('allows admin to edit any post', () => {
    expect(canEditPost({ id: 'u1', role: 'admin' }, { authorId: 'u2', status: 'published' })).toBe(true);
  });

  it('prevents editor from editing published post', () => {
    expect(canEditPost({ id: 'u1', role: 'editor' }, { authorId: 'u1', status: 'published' })).toBe(false);
  });

  it('allows editor to edit own draft', () => {
    expect(canEditPost({ id: 'u1', role: 'editor' }, { authorId: 'u1', status: 'draft' })).toBe(true);
  });
});

我现在写单测时会尽量避免一个问题:为了覆盖率去测没有意义的代码。

比如一个函数只是简单地把参数传给第三方库,或者只是返回一个固定配置,我一般不会强行写测试。这样的测试就算写了,失败时也很难告诉我什么有价值的信息。

我判断一个单测值不值得写,通常会问自己:

如果这个逻辑错了,业务会不会真的出问题?

如果答案是会,那就写。如果只是为了覆盖率数字好看,我会先忍住。

组件测试:别测内部实现,要测用户能感知的结果

组件测试是我以前最容易写错的一层。

我曾经写过这样的测试:点击按钮后,断言 onSubmit 被调用了一次。这个测试本身没错,但它太弱了。它只能证明按钮点击触发了一个函数,不能证明用户真的看到了 loading,不能证明错误提示展示出来了,也不能证明成功后页面状态正确。

后来我更倾向于这样写组件测试:不关心组件内部怎么组织,只关心用户做了什么,以及页面给了什么反馈。

比如登录表单:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

it('shows validation message when email is invalid', async () => {
  const user = userEvent.setup();

  render(<LoginForm />);

  await user.type(screen.getByLabelText('邮箱'), 'wrong-email');
  await user.click(screen.getByRole('button', { name: '登录' }));

  expect(screen.getByText('请输入正确的邮箱地址')).toBeInTheDocument();
});

这个测试的重点不是 validateEmail 有没有被调用,而是用户输入错误邮箱之后,页面有没有给出正确反馈。

我踩过一个很典型的坑:测试里大量使用 .querySelector('.error-text') 或者直接找 className。后来 UI 改版,className 变了,测试就全挂。但用户其实仍然能看到错误提示,业务没有坏。

从那以后,我尽量使用更接近用户视角的查询方式,比如:

  • getByRole
  • getByLabelText
  • getByText
  • getByPlaceholderText

这些选择器不只是测试更稳定,也会倒逼组件写得更可访问。比如一个输入框如果没有 label,getByLabelText 就找不到,这其实是在提醒我组件本身也不够好。

组件测试很适合覆盖这些场景:

  • 表单输入和错误提示
  • loading、empty、error 状态
  • 弹窗打开和关闭
  • 列表筛选、排序、分页
  • 批量操作按钮的禁用状态
  • 接口失败后的错误提示

不过组件测试也不能写太细。比如组件内部有几个 useState,某个 hook 返回了什么中间状态,这些我一般不会测。因为这些都是实现细节,重构时很容易变化。

我现在对组件测试的要求很简单:

像用户一样操作页面,像用户一样判断结果。

这句话听起来像口号,但真能少踩很多坑。

E2E:不要贪多,只保护最不能断的链路

E2E 是最接近真实用户的测试,也是最容易让人又爱又恨的测试。

我以前做过一个错误决定:给后台系统的很多细节功能都写 E2E。新增、编辑、删除、筛选、排序、导出、权限弹窗,几乎每个功能都走浏览器测一遍。刚开始很爽,感觉系统被保护得很完整。

问题很快就来了。

测试变慢了,本地没人愿意跑。CI 上偶发失败变多,有时是测试数据没清理干净,有时是接口慢了一点,有时是页面动画还没结束。最难受的是,E2E 一旦失败,排查成本很高。你要判断是前端坏了、接口坏了、测试数据坏了,还是测试本身写得太脆。

后来我才意识到,E2E 不适合拿来覆盖所有细节。它应该只保护那些“真的不能断”的关键路径。

比如:

  • 用户能不能登录
  • 用户能不能完成下单
  • 用户能不能发布文章
  • 管理员能不能创建核心资源
  • 权限错误时是否真的进不去页面

以发布文章为例,一条 E2E 可以这样设计:

import { expect, test } from '@playwright/test';

test('user can publish a post', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('邮箱').fill('editor@example.com');
  await page.getByLabel('密码').fill('password');
  await page.getByRole('button', { name: '登录' }).click();

  await page.getByRole('link', { name: '写文章' }).click();
  await page.getByLabel('标题').fill('前端测试分层实践');
  await page.getByLabel('正文').fill('这是一篇关于前端测试策略的文章正文。');
  await page.getByLabel('标签').fill('前端,测试');
  await page.getByRole('button', { name: '发布' }).click();

  await expect(page.getByText('发布成功')).toBeVisible();
  await expect(page.getByRole('link', { name: '前端测试分层实践' })).toBeVisible();
});

这条测试的目标不是把所有校验规则都测一遍。标题为空、正文太短、标签过多,这些更适合单测和组件测试。

这条 E2E 只回答一个问题:用户能不能从登录开始,真的发布一篇文章,并在列表里看到它。

我现在写 E2E 时会非常克制。每加一条 E2E,我都会问自己:

如果这条测试失败,是否说明一个关键业务流程真的可能坏了?

如果答案不是很明确,我一般不会把它放进 E2E。

一个功能应该怎么拆测试

还是拿“发布文章”举例。

这个功能看起来只是一个表单,但里面其实有三层风险。

第一层是规则风险:

  • 标题不能为空
  • 正文不能少于 20 个字
  • 标签最多 5 个
  • 草稿和发布可能有不同校验规则

这些适合单测。

export function validatePost(input: { title: string; content: string; tags: string[] }) {
  if (!input.title.trim()) return '标题不能为空';
  if (input.content.trim().length < 20) return '正文不能少于 20 个字';
  if (input.tags.length > 5) return '标签不能超过 5 个';
  return null;
}

第二层是交互风险:

  • 用户点发布后是否显示 loading
  • 校验失败是否展示错误
  • 接口失败是否提示重试
  • 提交成功后按钮是否恢复
  • 用户修改输入后错误是否消失

这些适合组件测试。

it('shows error message when title is empty', async () => {
  const user = userEvent.setup();

  render(<PostEditor />);

  await user.type(screen.getByLabelText('正文'), '这是一段足够长的正文内容,用来通过正文长度校验。');
  await user.click(screen.getByRole('button', { name: '发布' }));

  expect(screen.getByText('标题不能为空')).toBeInTheDocument();
});

第三层是流程风险:

  • 用户是否能登录
  • 是否能进入发布页
  • 是否能提交成功
  • 是否能在列表页看到新文章

这些适合用一条 E2E 兜住。

如果这三层都用 E2E 测,会很重。如果三层都只写单测,又很虚。比较舒服的分工是:

  • 校验规则用单测
  • 编辑器状态和错误提示用组件测试
  • 发布成功的完整闭环用 E2E

这样测试不会太多,但关键风险基本都有人负责。

我现在会避开的几类测试

踩坑之后,我对一些测试会比较谨慎。

第一类是纯展示组件的测试。比如一个没有逻辑的标题组件、布局组件,如果只是把 props 渲染出来,我一般不会专门测。除非它承担了很重要的语义或可访问性职责。

第二类是脆弱快照。快照不是不能用,但我不会把它当作业务组件的主要测试方式。因为它太容易变成“看不懂 diff,然后直接更新”的流程。

第三类是过度 mock 的测试。如果一个测试要 mock 十几个模块才能跑起来,我会警惕。因为这通常说明测试离真实使用方式太远,或者代码本身耦合太重。

第四类是测试框架本身已经保证的东西。比如 React 能不能正常渲染一个文本,路由库能不能跳转,UI 库按钮能不能点击,这些一般不需要我重复证明。

第五类是只验证内部实现的测试。比如某个内部函数调用几次、某个临时变量是什么值、组件内部 state 如何变化。只要用户结果没变,这些细节就不应该限制重构。

测试不是越多越专业。测试越多,维护成本也越高。真正专业的是知道哪些地方值得测,哪些地方应该放过。

一个简单的分工表

类型主要测试目标优点缺点适合例子
单测纯逻辑是否正确快、稳定、便宜不覆盖真实 UI权限判断、表单校验、数据转换
组件测试单个组件的交互是否正确接近用户行为,反馈较快需要 mock 上下文和接口表单、弹窗、列表、筛选器
E2E完整业务流程是否跑通最接近真实用户慢、贵、容易受环境影响登录、下单、发布、后台核心流程

我自己的实践倾向是:

  • 单测可以多写,尤其是核心业务规则
  • 组件测试适量写,重点覆盖复杂交互
  • E2E 一定要少,只测关键链路

这其实是一种成本选择。越底层的测试越快、越稳定、越便宜,可以多写。越接近真实用户的测试越可信,但也越慢、越贵、越难维护,所以要精挑细选。

最后:测试不是为了证明代码没问题

我以前总觉得测试是为了证明代码没问题。现在我觉得这个理解不太准确。

测试更像是一套反馈系统。它不能证明系统完全正确,但它能在你改动代码时,尽快告诉你有没有碰坏重要的东西。

单测让我敢改业务规则。组件测试让我敢改 UI 结构。E2E 让我敢改关键流程。

如果一套测试在业务出问题时不报警,在正常重构时却天天报警,那它就是在消耗团队耐心。反过来,如果一套测试数量不多,但每次失败都能指向真实风险,它就很有价值。

所以前端到底该写哪些测试?

我的答案是:不要从覆盖率出发,要从风险出发。逻辑复杂就写单测,交互复杂就写组件测试,流程关键就写 E2E。剩下那些只是为了显得“测试很完整”的部分,可以先放一放。

测试的目标不是让项目看起来严谨,而是让我们真的敢改代码。