告别代码焦虑,单元测试让你代码自信力一路飙升!

本文由体验技术团队董福俊原创。

背景

一次偶然,我看到了 Kent C. Dodds 的文章中的一个观点:写测试代码的原因,是为了获得对自己代码的信心。我觉得深有感触,于是翻看了kent的所有文章,结合我自己的开发体会,总结了一些关于前端单元测试的观点。

认识单元测试

单元测试是什么?

单元测试(UT)是测试系统中的一环,测试系统还包含很多其它环,例如:端到端测试E2E、集成测试Integration、静态检查Lint。

日常工作中,每个业务版本都在进行着这样的一个流程:需求分析 > 代码设计 > 开发 > 测试 > 上线。其中,测试环节是为了检查代码是否符合预期、是否能正常工作。对于前端而言,测试手段至少有这4种:

  1. 端到端测试(End to End,即E2E):

用一个机器人/脚本,完全模仿真实用户去访问整个系统。(前后端都被覆盖)

  1. 集成测试(Integration):

Mock掉后端、可能也Mock掉前端部分耗时操作(eg:动画),测试前端部分的输入/输出是否正确。(前端被覆盖)

  1. 单元测试(Unit,即UT):

针对一个代码模块、代码函数,测试输入/输出是否正确。(某个模块被覆盖)

  1. 静态检查(Static):

语法检查,TypeScript、ESLint等。

这4种测试手段的成本不同,收益也不同。越往上信心越大。但同时,实施开销也越大,写用例和维护用例的时间越长,用例越容易崩,且崩了的定位和修复精力耗费越多,用例执行速度也越慢。

那么,什么时候应该使用 UT呢?这取决于我们要针对的场景。这种选择,本质上是在做成本和收益的权衡

不同的手段有不同的擅长点,选择正确的测试策略是大前提。这就像刷墙,面对一面凹凸不平的墙,如果只用滚筒刷,那么细节将得不到覆盖;如果只用小刷子,那么人会被累死。

而我们搭建测试系统,也是这个道理:

  1. 如果想测试商品购买页中,组合商品的价格是否正确,我们应该选择 E2E
  2. 如果想验证参数配置页中,参数间的关联关系是否正确,我们应该选择 Integration
  3. 如果想验证 i18n模块,在不同语种下取词是否正确,我们应该选择UT
  4. 如果想检查 format函数的所有调用点,是不是都传入了一个string,我们应该选择Typescript

所以,在决定使用UT之前,梳理一下自己的业务,列出最重视的功能,思考最痛的痛点,给它们找一个合适的测试手段。不要企图用一种手段去解决所有的问题,而应该组合使用这些手段来编织一张防护网:通过E2E去覆盖核心功能点,通过Integration去覆盖前端逻辑,通过UT去覆盖核心模块,通过静态检查去守护每一行代码

(PS: 或许不同的人会有不同的分类观点,但怎么分类并不是关键,关键是我们应该组合使用各种手段,用UT去做UT最擅长的事儿,而不是一招鲜吃遍天的哪哪都靠UT)

单元测试应该测什么?

单元测试不应该测细节,搞清楚我们的"用户"是谁,"用户"怎么用,我们就怎么测。

由于代码覆盖率指标的驱动,我们很容易走进测细节的死胡同。例如,针对下面这段代码写UT,可能会写出这样的测试用例

js 复制代码
// 这个函数用来过滤undefined和null
function filterEmpty(arr) {
  if (Array.isArray(arr)) {
    return arr.filter(item => item);
  } else {
    return [arr].filter(item => item);
  }
}
// 用例1:要想进if,arr必须是个数组
expect(filterEmpty([1, 2, 3])).toEqual([1, 2, 3]);
// 用例2:要想进else,arr必须是非数组
expect(filterEmpty(1)).toEqual([1]);

这里,虽然2个用例覆盖了被测代码的所有行,但arr中如果有0、空字符串、false 也会被过滤掉。所以,针对代码细节写测试,即使覆盖率100%,也无法给我们提供足够的信心。这是因为,测代码细节,无法避免假正确(用例跑过了,但功能不通)和假错误(用例没通过,但功能是好的) 。这种用例没法给我们带来代码信心,只会徒增工作量,而这可能是很多人不喜欢写UT的原因。

那么,如何避免陷入测细节的陷阱呢?答案是:搞清楚我们的"用户"是谁。如果被测模块是个交互组件,那么用户可能是真实的界面使用者;如果被测的是一个工具模块,那么用户可能是模块调用者;更多时候,是二者的混合情况。对于真实用户场景,想想他们可能会输入什么?可能会点击什么?再想想此刻我们的组件应该作何表现。对于模块调用场景,想想调用点可能输入什么?预期获得什么样的返回?再想想我们的模块应该作何表现。

例如,上面的函数,从用户的视角,可以这样写

js 复制代码
// 这个函数用来过滤undefined和null
function filterEmpty(arr) {
  if (Array.isArray(arr)) {
    return arr.filter(item => item);
  } else {
    return [arr].filter(item => item);
  }
}
// 用例1:用户输入的数组可能包含各种基本类型的数据,其中应该只有undefined和null被过滤掉
expect(filterEmpty([0, 1, 'abc', '', undefined, null, false, true, NaN])).toEqual([0, 1, 'abc', '', false, true, NaN]);
// 用例2:输入非数组时,如果是undefined 或 null,则应被过滤掉,其它类型的值应该通过
expect(filterEmpty(undefined)).toEqual([]);
expect(filterEmpty(null)).toEqual([]);
expect(filterEmpty(0)).toEqual([0]);
// ...

始终记住:不要追求代码覆盖率,而应该追求用例覆盖率。

但很可惜,当前没有一个 用例覆盖率 统计工具,有的往往是行覆盖率、分支覆盖率,这就很容易导致我们陷入测细节的陷阱。但转变一下思维:如果从用户视角出发来写用例,最终结果应该就是100%的行覆盖率&分支覆盖率。倘若用例已经做到了100%的覆盖使用场景,而行覆盖率还没达到100%,只能说明这里面有冗余代码&分支!

如何看待TDD?

仅在当我们觉得TDD(测试驱动开发)能提升我们的效率时,才使用它。

我们在前面的讨论,都是希望单元测试能帮我们拦截代码问题,这是从防守方的视角来看待单元测试。但TDD(测试驱动开发)的观点认为,业务开发应该先写用例再写代码,通过用例去指导开发。

TDD一般包含上图的这3个步骤:

  1. 先写测试用例,此时用例会执行失败(红圈)。因为业务代码还没写呢
  2. 再写业务代码,让用例能通过(绿圈)
  3. 审视刚写的代码,看是否能优化重构(蓝圈)

如此往复循环,直到需求开发完成,用例也覆盖完成。

其实TDD的好处是:强迫我们必须从用户视角出发来写用例。因为在写用例时,代码还不存在呢!但是TDD循环要能顺利完成,是有前提的:

  1. 首先,要能根据业务需求,设计出合理的代码结构(eg:拆分哪些单元 module/class/function?它们分别承载什么功能?它们的输入输出是怎么样的?)
  2. 其次,根据各个单元的功能和输入输出,设计UT用例
  3. 第三,对代码架构和UT框架足够了解,用例失败时能迅速搞清楚,是代码的问题还是用例本身写错了
  4. 最后,也是最重要的,要有足够的试错时间

所以,我的观点是:只要我们明白单元测试用例应该是从用户视角出发来写就可以了。

TDD固然是好,但前置条件也比较多,不要硬上。我们可以从简单的bugfix开始,尝试使用TDD,等我们变的熟练了,并且发现自己喜欢这个模式,再投入到需求开发。

(PS:TDD作为一种方法论,其实施效果是因人而异的,我们应该去了解它,但不应该无脑硬上。我们可以先找简单场景尝试一下,看看是否合自己的口味,再决定是否扩大使用)

单元测试框架的组成

前面的讨论中,我们直到了UT是测试系统的一环,应该在合适的地方使用它。写UT时应该瞄准用户,而不是瞄准代码细节。下面我们了解一下UT大概长什么样,UT框架大概是什么样。

一个典型的UT代码

一个前端单元测试脚本,本质还是一个js文件,只不过多了一些全局变量而已:describe、beforeAll、afterAll、beforeEach、afterEach、test、expect ...

一个单元测试脚本,写出来大概会是这种形态:

js 复制代码
// userService.test.js - 测试套件
import { UserService } from './userService';
describe('UserService 类测试', () => {
  let userService;
  let testUser;
  // 整个测试套件前执行一次
  beforeAll(() => {
    console.log('===== 启动用户服务测试 =====');
  });
  // 整个测试套件后执行一次
  afterAll(() => {
    console.log('===== 完成用户服务测试 =====');
  });
  // 每个测试用例前执行
  beforeEach(() => {
    userService = new UserService();
    testUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
    userService.addUser(testUser);
  });
  // 每个测试用例后执行
  afterEach(() => {
    console.log(`测试完成,当前日志条目: ${userService.getLogCount()}`);
  });
  // BDD 风格测试组
  describe('BDD 风格测试 (行为驱动开发)', () => {
    test('应该正确添加用户', () => {
      // BDD 断言风格
      expect(userService.addUser({...})).toMatchObject({...});
    });
  });
  // TDD 风格测试组
  describe('TDD 风格测试 (测试驱动开发)', () => {
    test('添加用户后用户数量应增加', () => {
      // 初始状态
      const initialCount = userService.users.length;
      // 执行操作
      userService.addUser({...});
      // TDD 断言风格
      expect(userService.users.length).toBe(initialCount + 1);
    });
  });
});

这个脚本里面,调用了被测模块UserService,组装了一些模拟数据投喂给它,然后观察它的返回结果是否符合预期,从而判断其功能是否正常。其实可以想象,最原始的方式实现一个单元测试脚本,大概是这样的:

js 复制代码
import { UserService } from './userService';
function test() {
  const userService = new UserService();
  const result = userService.addUser({...});
  if (isEqual(result, {...})) {
    console.log('测试通过');
  } else {
    console.err('测试失败!');
  }
}
function isEqual(obj1, obj2) {
  // 判断两个对象是否值相等
}
// 执行测试
test();

但这种做法在实际使用中会遇到很多麻烦,比如:要测试DOM相关的界面操作怎么办?有很多个用例,但其中某个执行失败了怎么办?执行结果的比对可能还涉及到异步情况,怎么办?于是,UT框架出现了,它帮我们搞定了这些问题,让我们可以专心写用例。

UT框架的作用&选型

UT框架负责构建测试环境、组织测试用例、提供用例执行引擎、并搜集执行结果。

构建测试环境

UT用例往往是在Nodejs环境下执行的。但有一些被测模块或用例的执行,需要用到DOM、window、document等浏览器特有环境的资源。所以UT框架往往会提供浏览器环境的模拟能力。甚至,提供代码编译能力(例如支持ts写用例,但nodejs本身并不支持直接运行ts);甚至,提供模块打桩能力(也就是mock掉某个特定模块)。

组织测试用例

UT框架往往用describe表示一套用例,用it/test表示一个具体的用例。同时提供beforeAll、afterAll、beforeEach、afterEach等声明周期钩子。

提供用例执行引擎

真实项目中,用例数往往成百上千。这些用例以什么顺序(或并行)执行,异步用例如何执行,如果一个用例跑崩了,其它用例要能正常执行。这些也都是UT框架提供的能力。

搜集执行结果

当用例执行完成之后,整体成功了多少,失败了多少,覆盖率如何。生成不同格式的报告,来对接不同的分析工具/平台。这些也都是UT框架提供的能力。

常见前端单元测试框架对比

前端单测框架有很多种,能力各异。这里选常见的几种,就上述4个方面进行对比,如下:

一般来说,Jest是大而全且快的框架,适合大多数场景。Mocka是小而精的框架,可定制性比较强。我们可以根据自己的项目实际情况,来选择合适的测试框架。

断言库的作用&选型

相对于自己写if-else、console.log而言,断言库提供更语义化、更简洁的表达方式,并能跟UT框架协作,易于生成测试报告。

断言库的本质是一种测试专用DSL(domain-specific language领域特定语言),它让我们写出来的测试代码更易懂。它有两种语言风格:

  1. BDD风格:更贴近自然语言,例如 expect(user).to.be.loggedIn
  2. TDD风格:更贴近编程语言,例如 assert.isTrue(user.isLoggedIn)

语言风格的选择,主要是看个人合团队的喜好。选哪一种风格不重要,重要的是整个团队应该是同一种风格。

不同断言库使用的语言风格不同,但也有一些能同时支持两种风格。常见的几种断言库的特点对比如下:

写UT对开发者有什么好处?

通过写UT,我们能直观看到好/坏代码的差距,能挖掘出对业务更深的理解。

代码中的坏味道越多,我们越会感觉到UT难写。

直观体会代码坏味道

案例:一个订单处理逻辑

js 复制代码
// 被测代码(坏味道示例)
export function processOrder(order) {
  if (!order) return null;
  const tax = order.items.reduce((sum, item) => {
    // 深度嵌套 + 业务耦合
    if (item.type === 'book') return sum + item.price * 0.1;
    else if (item.type === 'food') return sum + item.price * 0.05;
  }, 0);
  return { ...order, tax };
}
// 测试代码(暴露问题)
test('处理空订单应返回null', () => {
  expect(processOrder(null)).toBeNull(); // 通过
});
test('计算图书税率为10%', () => {
  const order = { items: [{ type: 'book', price: 100 }] };
  expect(processOrder(order).tax).toBe(10); // 通过
});
// 新增需求:电子产品税率15%,需修改原函数,违反开闭原则

在写测试代码的过程中,我们会发现这里有职责不单一的问题,这个函数同时包含了 处理订单和计算税率 的逻辑。而且后续如果有新增的类别(很可能发生),则要修改这个重要函数,违反开闭原则。为了解决这些问题,可以用策略模式去重构。并且用例能帮忙保证重构不引入问题。

js 复制代码
const taxRules = {
  book: price => price * 0.1,
  food: price => price * 0.05,
  electronics: price => price * 0.15 // 扩展不修改主函数
};
export function processOrder(order) {
  if (!order) return null;
  const tax = order.items.reduce(
    (sum, item) => sum + (taxRules[item.type]?.(item.price) || 0),
    0
  );
  return { ...order, tax };
}

加深对业务的理解

案例:商品税率处理逻辑

js 复制代码
// 被测代码(坏味道示例)
export function processOrder(order) {
  if (!order) return null;
  const tax = order.items.reduce((sum, item) => {
    // 深度嵌套 + 业务耦合
    if (item.type === 'book') return sum + item.price * 0.1;
    else if (item.type === 'food') return sum + item.price * 0.05;
  }, 0);
  return { ...order, tax };
}
// 测试用例(覆盖未考虑的场景)
test('商品类型不存在时应忽略税额', () => {
  const order = { items: [{ type: 'unknown', price: 100 }] };
  expect(processOrder(order).tax).toBe(0); // 原代码报错,暴露缺陷
});

在写测试代码的过程中,我们会发现代码没有处理未知的商品类型。为了修复这个问题,我们需要在原码中补一个else分支。

案例:金融计算精度问题

js 复制代码
// 金融计算的陷阱
function calculateInterest(principal, rate, days) {
  const dailyRate = rate / 365;
  return principal * Math.pow(1 + dailyRate, days);
}
// 测试暴露的业务漏洞
test('10万元年化5%存30天应得409.58元利息', () => {
  const interest = calculateInterest(100000, 0.05, 30);
  expect(interest).toBeCloseTo(409.58, 2); // 失败!实际411.77
});

在写测试代码的过程中,我们会发现js在小数计算方面的精度能力较弱。这里需要一些辅助工具才能达到业务精度要求

总结

合理且正确的使用单元测试,能帮我们有效的提升我们对自己代码的信心,而不是产生累赘。尝试将我们的思维转型一下:

  1. "这段代码如何实现?""这段代码该如何被使用?"
  2. "它能做什么?""它不该做什么?"
  3. "功能完成""变更安全"

这将给我们带来别样的体会。

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网opentiny.design
OpenTiny 代码仓库github.com/opentiny
TinyVue 源码github.com/opentiny/ti...
TinyEngine 源码: github.com/opentiny/ti...

欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

相关推荐
崔庆才丨静觅5 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60616 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了6 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅6 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅6 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅7 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment7 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅7 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊7 小时前
jwt介绍
前端
爱敲代码的小鱼7 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax