本文由体验技术团队董福俊原创。
背景
一次偶然,我看到了 Kent C. Dodds 的文章中的一个观点:写测试代码的原因,是为了获得对自己代码的信心。我觉得深有感触,于是翻看了kent的所有文章,结合我自己的开发体会,总结了一些关于前端单元测试的观点。
认识单元测试
单元测试是什么?
单元测试(UT)是测试系统中的一环,测试系统还包含很多其它环,例如:端到端测试E2E、集成测试Integration、静态检查Lint。
日常工作中,每个业务版本都在进行着这样的一个流程:需求分析 > 代码设计 > 开发 > 测试 > 上线。其中,测试环节是为了检查代码是否符合预期、是否能正常工作。对于前端而言,测试手段至少有这4种:
- 端到端测试(End to End,即E2E):
用一个机器人/脚本,完全模仿真实用户去访问整个系统。(前后端都被覆盖)
- 集成测试(Integration):
Mock掉后端、可能也Mock掉前端部分耗时操作(eg:动画),测试前端部分的输入/输出是否正确。(前端被覆盖)
- 单元测试(Unit,即UT):
针对一个代码模块、代码函数,测试输入/输出是否正确。(某个模块被覆盖)
- 静态检查(Static):
语法检查,TypeScript、ESLint等。
这4种测试手段的成本不同,收益也不同。越往上信心越大。但同时,实施开销也越大,写用例和维护用例的时间越长,用例越容易崩,且崩了的定位和修复精力耗费越多,用例执行速度也越慢。

那么,什么时候应该使用 UT呢?这取决于我们要针对的场景。这种选择,本质上是在做成本和收益的权衡。
不同的手段有不同的擅长点,选择正确的测试策略是大前提。这就像刷墙,面对一面凹凸不平的墙,如果只用滚筒刷,那么细节将得不到覆盖;如果只用小刷子,那么人会被累死。

而我们搭建测试系统,也是这个道理:
- 如果想测试商品购买页中,组合商品的价格是否正确,我们应该选择 E2E
- 如果想验证参数配置页中,参数间的关联关系是否正确,我们应该选择 Integration
- 如果想验证 i18n模块,在不同语种下取词是否正确,我们应该选择UT
- 如果想检查 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个步骤:
- 先写测试用例,此时用例会执行失败(红圈)。因为业务代码还没写呢
- 再写业务代码,让用例能通过(绿圈)
- 审视刚写的代码,看是否能优化重构(蓝圈)
如此往复循环,直到需求开发完成,用例也覆盖完成。
其实TDD的好处是:强迫我们必须从用户视角出发来写用例。因为在写用例时,代码还不存在呢!但是TDD循环要能顺利完成,是有前提的:
- 首先,要能根据业务需求,设计出合理的代码结构(eg:拆分哪些单元 module/class/function?它们分别承载什么功能?它们的输入输出是怎么样的?)
- 其次,根据各个单元的功能和输入输出,设计UT用例
- 第三,对代码架构和UT框架足够了解,用例失败时能迅速搞清楚,是代码的问题还是用例本身写错了
- 最后,也是最重要的,要有足够的试错时间。
所以,我的观点是:只要我们明白单元测试用例应该是从用户视角出发来写就可以了。
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领域特定语言),它让我们写出来的测试代码更易懂。它有两种语言风格:
- BDD风格:更贴近自然语言,例如 expect(user).to.be.loggedIn
- 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在小数计算方面的精度能力较弱。这里需要一些辅助工具才能达到业务精度要求
总结
合理且正确的使用单元测试,能帮我们有效的提升我们对自己代码的信心,而不是产生累赘。尝试将我们的思维转型一下:
- 从 "这段代码如何实现?" → "这段代码该如何被使用?"
- 从 "它能做什么?" → "它不该做什么?"
- 从 "功能完成" → "变更安全"
这将给我们带来别样的体会。
关于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标签,一起参与开源贡献~