前端的单元测试,大部分都是在自欺欺人

在前端圈,单元测试 几乎是一个非常头疼的话题。简历上不写熟悉单元测试,都不好意思跟人打招呼了。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这个状态是存在classstate里,还是useState里。它只关心一件事:"当用户点击了那个叫Increment的按钮后,他是不是能在屏幕上看到Count: 1这段文字? "

无论你未来怎么重构Counter组件,只要它的用户行为没有变,这个测试就不会失败。这才是我们需要的、健壮的、能给予我们信心的测试。


最后的最后

我们应该把大部分精力,投入到模拟用户真实操作路径的测试上,确保应用的功能 是正确的。而对于那些纯粹的、无副作用的工具函数(比如一个formatDate函数),单元测试依然是必要的,但这在我们的代码库里,只占很小一部分。

测试不是目的,保证软件能正常工作才是。

是时候审视一下我们项目里的*.test.js文件了,然后问问自己:

我写的这些测试,到底是在保证质量? 还是在自我安慰,看起来很牛皮?

你们怎么看?🤔

相关推荐
一枚前端小能手3 小时前
🔥 字符串处理又踩坑了?JavaScript字符串方法全攻略,这些技巧让你效率翻倍
前端·javascript
windliang3 小时前
一文入门 agent:从理论到代码实战
前端·算法
4z333 小时前
Jetpack Compose重组原理(一):快照系统如何精准追踪状态变化
前端·android jetpack
三十_3 小时前
私有 npm 仓库实践:Verdaccio 保姆级搭建教程与最佳实践
前端·npm
叫我詹躲躲3 小时前
别再手写正则了!20 + 证件 / 手机号 / 邮箱验证函数,直接复制能用
前端·javascript·正则表达式
猪哥帅过吴彦祖3 小时前
第 4 篇:赋予表面生命 - WebGL 纹理贴图
前端·javascript·webgl
猪哥帅过吴彦祖3 小时前
Flutter 系列教程:核心概念 - StatelessWidget vs. StatefulWidget
前端·javascript·flutter
郝学胜-神的一滴3 小时前
解析前端框架 Axios 的设计理念与源码
开发语言·前端·javascript·设计模式·前端框架·软件工程
aixfe3 小时前
Ant Design V5 Token 体系颜色处理最佳实践
前端