单元测试实战:从入门到项目落地的完整指南

司徒子武 前端 阅读 1,281
赞 20 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

我写单元测试已经好几年了,从一开始的“为了覆盖率而写”,到后来被 bug 教做人,现在终于摸出一套自己用着顺手的写法。核心就一点:测试要能真实反映业务逻辑,而不是为了跑通而写

单元测试实战:从入门到项目落地的完整指南

比如一个常见的工具函数 formatPrice,很多人会这么写测试:

// 错误示范:只测了 happy path
test('formats price correctly', () => {
  expect(formatPrice(100)).toBe('¥100.00');
});

这看着没问题,但实际项目中用户可能传 nullundefined、字符串甚至对象进来。我吃过亏——上线后因为后端返回了个 null,页面直接白屏。所以现在我一定会覆盖边界情况:

// 我的写法
describe('formatPrice', () => {
  test('formats number correctly', () => {
    expect(formatPrice(100)).toBe('¥100.00');
    expect(formatPrice(0)).toBe('¥0.00');
    expect(formatPrice(123.456)).toBe('¥123.46'); // 注意四舍五入
  });

  test('handles invalid inputs', () => {
    expect(formatPrice(null)).toBe('¥0.00');
    expect(formatPrice(undefined)).toBe('¥0.00');
    expect(formatPrice('')).toBe('¥0.00');
    expect(formatPrice('abc')).toBe('¥0.00');
  });
});

这种写法的好处是,当后端接口突然返回异常数据时,你的 UI 不会崩,而且测试能立刻暴露问题。别小看这些边界 case,它们占了线上 bug 的 70% 以上。

这几种错误写法,别再踩坑了

我见过太多人把单元测试写成“形式主义”,下面这些坑我都踩过,你最好绕开:

  • 测试里写业务逻辑:比如在 expect 里调用一堆函数拼结果,这等于用错误的实现去验证错误的实现。测试代码必须比被测代码更简单、更确定。
  • 过度依赖 mock:把所有依赖都 mock 掉,最后测的只是“函数有没有被调用”,而不是“逻辑对不对”。比如测一个 API 调用,mock 掉 fetch 后只检查参数,却不验证返回数据是否正确处理。
  • 测试名字起得像“test1”“should work”:几个月后你自己都看不懂这测的是啥。测试名一定要描述清楚场景,比如“当用户未登录时,点击按钮应跳转到登录页”。

最离谱的一次,我看到同事这样写:

// 反面教材:测试名毫无意义 + 逻辑混乱
test('test utils', () => {
  const result = someUtil({ a: 1, b: 2 });
  expect(result).toEqual(someOtherUtil({ a: 1, b: 2 })); // 用另一个工具函数验证?
});

这种测试不仅没用,还会给你虚假的安全感。删掉它反而更安全。

实际项目中的坑

在真实项目里,单元测试的难点往往不在语法,而在怎么组织测试结构如何平衡速度与覆盖率

比如异步操作,很多人一上来就用 async/await,但没处理超时或错误情况:

// 危险写法:没处理网络失败
test('fetches user data', async () => {
  const user = await fetchUser(123);
  expect(user.name).toBe('John');
});

如果 API 挂了,这个测试会卡住或者报错,但你不知道是代码问题还是网络问题。我现在的做法是明确 mock 网络层,并分别测试成功和失败路径:

// 我的方案
import { fetchUser } from './api';
import { fetchData } from './utils';

jest.mock('./utils');

test('fetches user data on success', async () => {
  fetchData.mockResolvedValue({ id: 123, name: 'John' });
  const user = await fetchUser(123);
  expect(user.name).toBe('John');
});

test('handles API error gracefully', async () => {
  fetchData.mockRejectedValue(new Error('Network error'));
  await expect(fetchUser(123)).rejects.toThrow('Network error');
});

另外,**不要追求 100% 覆盖率**。我之前在一个项目里硬怼到 95%,结果发现很多测试只是机械地覆盖每行代码,对业务毫无帮助。比如一个简单的 getter 方法,测试它返回 this.value 其实意义不大。把精力放在核心逻辑和复杂分支上更划算。

还有个小细节:测试文件命名。我建议和源文件同名,比如 cart.js 对应 cart.test.js,放同一个目录下。这样找起来快,重构时也不容易漏掉测试。

核心代码就这几行

其实大部分单元测试的套路就那么几个。以 React 组件为例,很多人一上来就用 React Testing Library 渲染整个组件树,结果测试又慢又脆弱。我的经验是:能测纯函数就别测组件

比如一个购物车组件,里面有计算总价的逻辑。别在组件测试里算,先把计算逻辑抽成独立函数:

// cartUtils.js
export const calculateTotal = (items) => {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
};

然后单独测这个函数:

// cartUtils.test.js
test('calculates total with multiple items', () => {
  const items = [
    { price: 10, quantity: 2 },
    { price: 20, quantity: 1 }
  ];
  expect(calculateTotal(items)).toBe(40);
});

组件测试只关注渲染和交互,比如“当数量为 0 时显示‘空购物车’”。这样测试更快,也更容易维护。组件一改样式,之前的快照测试全挂,但纯函数测试完全不受影响。

再分享个提速技巧:用 describe.each 批量测试相同逻辑不同输入的情况,比写一堆重复 test 块清爽多了:

describe.each([
  [100, '¥100.00'],
  [0, '¥0.00'],
  [123.456, '¥123.46']
])('formatPrice(%i)', (input, expected) => {
  test(returns ${expected}, () => {
    expect(formatPrice(input)).toBe(expected);
  });
});

踩坑提醒:这三点一定注意

最后说三个我反复栽跟头的点:

  1. 别在测试里用真实 API。曾经有个测试直接调 fetch('https://jztheme.com/api/data'),结果对方接口一变,CI 全挂。所有外部依赖必须 mock。
  2. 清理副作用。比如测试里修改了全局变量或 localStorage,一定要在 afterEach 里还原,否则测试之间会互相污染。我有次因为没清 localStorage,凌晨三点被叫起来查“偶发性失败”的测试。
  3. 别测第三方库。比如你用了 date-fns,就别写测试验证 format(new Date(), 'yyyy-MM-dd') 的结果。相信它们的测试,专注测你自己的逻辑。

对了,还有一个心态问题:**测试不是写完就扔的**。每次改代码前,先看相关测试能不能跑通;改完后,确保测试仍然通过。把它当成开发流程的一部分,而不是额外负担。

以上是我踩坑后的总结,希望对你有帮助。单元测试没有银弹,但只要避开这些常见陷阱,它真的能大幅减少线上事故。有更好的方案欢迎评论区交流!

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论