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

在前端圈,单元测试 几乎是一个非常头疼的话题。简历上不写熟悉单元测试,都不好意思跟人打招呼了。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文件了,然后问问自己:

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

你们怎么看?🤔

相关推荐
金梦人生13 小时前
Css性能优化
前端·css
Holin_浩霖13 小时前
UI设计的底层逻辑:从组件到系统的跃迁
前端
Holin_浩霖13 小时前
前端开发者的 Web3 全图解实战 二
前端
写代码的皮筏艇13 小时前
CSS属性继承与特殊值
前端·css
kevlin_coder13 小时前
🚀 实现同一个滚动区域包含多个虚拟滚动列表
前端·javascript
金梦人生13 小时前
JS 性能优化
前端·javascript
peachSoda713 小时前
自定义配置小程序tabbar逻辑思路
javascript·vue.js·微信小程序·小程序
hbqjzx13 小时前
记录一个自动学习的脚本开发过程
开发语言·javascript·学习
我有一棵树13 小时前
使用Flex布局实现多行多列,每个列宽度相同
前端·css·html·scss·flex
浪裡遊13 小时前
React开发模式解析:JSX语法与生命周期管理
前端·javascript·react.js·前端框架·ecmascript