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

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

背景

一次偶然,我看到了 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标签,一起参与开源贡献~

相关推荐
brzhang2 小时前
我操,终于有人把 AI 大佬们 PUA 程序员的套路给讲明白了!
前端·后端·架构
进击的杨厂长3 小时前
本地代码上传github的具体操作步骤
github
止观止3 小时前
React虚拟DOM的进化之路
前端·react.js·前端框架·reactjs·react
goms3 小时前
前端项目集成lint-staged
前端·vue·lint-staged
谢尔登3 小时前
【React Natve】NetworkError 和 TouchableOpacity 组件
前端·react.js·前端框架
Lin Hsüeh-ch'in3 小时前
如何彻底禁用 Chrome 自动更新
前端·chrome
augenstern4165 小时前
HTML面试题
前端·html
张可5 小时前
一个KMP/CMP项目的组织结构和集成方式
android·前端·kotlin
mCell5 小时前
Webhook:连接、自动化与系统集成的新范式
ci/cd·go·github
G等你下课5 小时前
React 路由懒加载入门:提升首屏性能的第一步
前端·react.js·前端框架