
在前端圈,单元测试 几乎是一个非常头疼的话题。简历上不写熟悉单元测试,都不好意思跟人打招呼了。CI/CD流程里,要是没有一个test
的阶段,就好像这个项目不够专业。而那绿色的"Coverage: 95%
",也常常被当作项目质量的黄金标准,成为许多团队KPI的一部分。
但在我带团队的这几年,尤其是在经历了几个项目因为高覆盖率的单元测试而举步维艰之后,我越来越倾向于一个结论:
我们前端领域里,大部分团队写的大部分所谓单元测试,纯属自欺欺人。
在你说我反测试之前,请允许我加一个前提:我反对的,不是测试本身,而是那种为了覆盖率而写的、脱离用户实际场景的、脆弱不堪的、以测试实现细节为乐的单元测试 。
单元测试,长什么样?
我们先来看几个我从过往项目中挖出来的、非常典型的测试案例。
假设我们有一个简单的计数器组件:
JavaScript
// Counter.jsx
class Counter extends React.Component {
state = { count: 0 };
increment = () => this.setState({ count: this.state.count + 1 });
render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={this.increment}>Increment</button>
</div>
);
}
}
然后,一份看起来正确的单元测试:
JavaScript
// Counter.test.js
it('should increment count when button is clicked', () => {
const wrapper = shallow(<Counter />);
wrapper.find('button').simulate('click');
// 重点:测试组件的 state
expect(wrapper.state('count')).toBe(1);
});
为什么说这是自欺欺人?
这个测试,和组件的内部实现(this.state.count)牢牢地绑在了一起。明天,我如果用Hooks重构这个组件,把state换成了useState,组件给用户看的功能完全没变,但这个测试百分之百会挂掉。
Mock测试

我们经常会写一些依赖其他模块或Hook的组件。于是,测试代码就变成了:
JavaScript
// 一个依赖了 useAuth 和 useApi 的组件
jest.mock('./hooks/useAuth');
jest.mock('./api/user');
it('should display user name when logged in', () => {
// 模拟 useAuth 返回已登录
useAuth.mockReturnValue({ isLoggedIn: true, user: { name: 'John' } });
// 模拟 api 调用
userApi.getInfo.mockResolvedValue({ success: true, data: ... });
render(<UserProfile />);
// ...断言
});
当你的测试文件里,jest.mock的代码比断言的代码还多时,你就应该警惕了。我们到底是在测试我们的组件逻辑,还是在测试我们Mock的能力?这种测试,一旦组件的依赖关系发生微小的变化,整个测试文件可能都要重写。
这些写法带来了什么恶果?
这些测试累积起来,会给团队带来灾难性的后果。
团队看着CI上那95%的覆盖率,觉得高枕无忧。但实际上,这些测试只保证了"当A函数的输入是1时,返回值是2",却完全保证不了"当用户在页面上完成A、B、C三个操作后,页面能正确跳转"。高覆盖率,不等于高质量。
对重构的巨大阻力
这才是最致命的。
开发者不敢动那些老代码,不是因为怕改出Bug,而是因为怕改完之后,要花双倍的时间去修复那些因为实现细节变更而大面积失败的、脆弱的单元测试。这极大地扼杀了项目的迭代和优化动力。
浪费宝贵的开发时间
团队投入大量时间,去编写和维护这些弱鸡😒的测试,仅仅是为了让CI上的那个数字好看一点。这些时间,本可以用来做更有价值的事情,比如写更重要的集成测试,或者直接去优化产品功能。
我的方法论:像用户一样去测试
聊了这么多问题,那到底什么样的测试,才不是自欺欺人?
我的核心观点是:前端测试的重心,应该从单元测试,向集成测试和端到端测试倾斜。
也就是说:测试你的软件,而不是你的实现细节。
我们把第一个计数器的例子,用这种思想重新写一遍(使用React Testing Library):
JavaScript
// Counter.test.js
import { render, screen, fireEvent } from '@testing-library/react';
it('should display the updated count after clicking the button', () => {
render(<Counter />);
// 找到屏幕上的按钮并点击它,模拟用户的真实操作
const button = screen.getByRole('button', { name: /increment/i });
fireEvent.click(button);
// 验证用户在屏幕上看到的结果,而不是内部状态
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
这才是一个有价值的测试!
你看,这个测试,完全不关心 count
这个状态是存在class
的state
里,还是useState
里。它只关心一件事:"当用户点击了那个叫Increment
的按钮后,他是不是能在屏幕上看到Count: 1
这段文字? "
无论你未来怎么重构Counter
组件,只要它的用户行为没有变,这个测试就不会失败。这才是我们需要的、健壮的、能给予我们信心的测试。
最后的最后
我们应该把大部分精力,投入到模拟用户真实操作路径的测试上,确保应用的功能 是正确的。而对于那些纯粹的、无副作用的工具函数(比如一个formatDate
函数),单元测试依然是必要的,但这在我们的代码库里,只占很小一部分。
测试不是目的,保证软件能正常工作才是。
是时候审视一下我们项目里的*.test.js
文件了,然后问问自己:
我写的这些测试,到底是在保证质量? 还是在自我安慰,看起来很牛皮?
你们怎么看?🤔