随着前端实现的业务逻辑愈发复杂,前端单元测试的重要性也水涨船高。
而好的单元测试,不仅能够保证代码质量,还能够提高开发效率。
本文就来谈谈,如何写好前端单元测试。
背景
简单介绍下背景,笔者在工作中负责开发前端 SDK,测试框架使用 jest。
我们项目对代码测试覆盖率有一定的要求,需要满足:
{
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
比如我们需要测试这个函数:
export const getConfig = (name) => {
return {
foo: () => {},
name: name || "Anonymous",
};
};
对应的测试用例一般是这样的:
describe("getConfig", () => {
test("should return name if have the paramter", () => {
const name = "zxf4399";
const config = getConfig(name);
expect(config.name).toBe(name);
});
test("should return default name if doesn't have the paramter", () => {
const config = getConfig();
expect(config.name).toBe("Anonymous");
});
});
执行结果如下:
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 50 | 100 |
index.js | 100 | 100 | 50 | 100 |
----------|---------|----------|---------|---------|-------------------
Jest: "global" coverage threshold for functions (80%) not met: 50%
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 0.444 s, estimated 1 s
Ran all test suites.
ELIFECYCLE Test failed. See above for more details.
由于 functions 的覆盖率没有达到 80%,所以测试失败了。
解决问题-Phase1
之前的解决方案:
test("should test foo function", () => {
const config = getConfig();
config.foo && config.foo();
});
结果:
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
index.js | 100 | 100 | 100 | 100 |
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 0.448 s, estimated 1 s
Ran all test suites.
我们发现 functions 的覆盖率居然达到了 100%,但是我并没有在 should test foo function
测试用例中调用 expect 函数,这合理吗?
答案是合理的。
虽然我们没有在测试用例中调用 expect 函数,但是我们在测试用例中调用了 config.foo 函数,而 config.foo 函数是在 getConfig 函数中定义的,所以 jest 会认为我们测试了这个函数。
但实际上这个测试行为是没有任何意义的,我们只是为了达到测试覆盖率的要求而这样写。
解决问题-Phase2
解决 Phase1 问题我们需要知道 jest 的测试覆盖率是如何计算的。
jest 的 coverage provider 有两种,分别是:
由于我们项目中使用的是 coverageProvider
是 babel, 所以我们只要关注 istanbuljs 就可以了。
针对这种 noop funciton 的测试用例,我们可以使用 istanbul ignore next
来忽略这个函数的测试覆盖率。
export const getConfig = (name) => {
return {
foo: /* istanbul ignore next */ () => {},
name: name || "Anonymous",
};
};
当然更加推荐的做法是抽象出一个 noop function,然后在测试用例中调用这个函数。
/* istanbul ignore next */
export const noop = () => {};
export const getConfig = (name) => {
return {
foo: noop,
name: name || "Anonymous",
};
};
最终的结果也是令人满意的:
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
index.js | 100 | 100 | 100 | 100 |
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 0.352 s, estimated 1 s
Ran all test suites.
本小节 Demo 传送门
如何写好单元测试
Phase1 与 Phase2 都是解决问题的方法,从技术上讲 Phase2 更合理一些(本身 noop function 就不应该是一个考核的点)但你也不能说 Phase1 的解决方案就是错误的。
所以我们需要思考如何写好单元测试 🤔
首先,我们来看个简单的例子,一个最简单的单元测试:
export const sum = (a, b) => a + b;
// unit test
import { sum } from ".";
describe("sum", () => {
test("adds 1 + 2 to equal 3", () => {
expect(sum(1, 2)).toBe(3);
});
});
从这个例子中,我们可以看到,单元测试的基本结构:
- 准备输入数据
- 调用被测函数
- 断言输出结果
任何单元测试都可以遵循这一个框架:given-when-then。
遵循这个框架是否就一定能写好单元测试呢?其实不然,我们还要知道,单元测试的特征:
- 失败原因明确
- 表现力强
- 执行速度快
失败原因明确
当输入不变,当且仅当业务代码功能变化,测试才会失败。
这个特征是我们重构代码的保障,当我们重构代码时,我们可以放心的修改代码,因为我们知道,只要测试通过,那么我们的代码就是正确的。
表现力极强
两方面:
- 看到测试 case 的描述,就知道这个 case 的意图
- 测试失败时,能够快速定位到问题
执行速度快
单元测试的执行速度是非常重要的,因为我们希望在开发过程中,能够快速的得到反馈,而不是等待很长时间。
为了达到这个目标,我们需要:
- Mock 外部依赖,比如 WebSocket 连接,数据库连接等
- 测试代码不包含逻辑,不然测试失败时,我们不知道是测试代码的问题还是业务代码的问题(滑稽.jpg)
一个好的单元测试
我们来看一个较好的单元测试的例子:
export class EventEmitter {
constructor() {
this.events = {};
}
on(event, listener) {
this.events[event] = this.events[event] || [];
this.events[event].push(listener);
return () => this.off(event, listener);
}
once(event, listener) {
const remove = this.on(event, (...args) => {
remove();
listener(...args);
});
}
emit(event, ...args) {
this.events[event]?.forEach((listener) => listener(...args));
}
off(event, listener) {
this.events[event] = this.events[event]?.filter((l) => l !== listener);
}
}
单元测试:
import { EventEmitter } from ".";
let eventEmitter;
beforeEach(() => {
eventEmitter = new EventEmitter();
});
describe("EventEmitter", () => {
describe("on", () => {
test("should add a listener for an event", () => {
const listener = jest.fn();
eventEmitter.on("event", listener);
eventEmitter.emit("event");
expect(listener).toHaveBeenCalled();
});
test('should remove listener when returned "remove" function is called', () => {
const listener = jest.fn();
const remove = eventEmitter.on("event", listener);
remove();
eventEmitter.emit("event");
expect(listener).not.toHaveBeenCalled();
});
});
describe("once", () => {
test("should add a one-time listener for an event", () => {
const listener = jest.fn();
eventEmitter.once("event", listener);
eventEmitter.emit("event");
eventEmitter.emit("event");
expect(listener).toHaveBeenCalledTimes(1);
});
});
describe("emit", () => {
test("should call all listeners for an event", () => {
const listener1 = jest.fn();
const listener2 = jest.fn();
eventEmitter.on("event", listener1);
eventEmitter.on("event", listener2);
eventEmitter.emit("event");
expect(listener1).toHaveBeenCalled();
expect(listener2).toHaveBeenCalled();
});
test("should pass arguments to listeners", () => {
const listener = jest.fn();
eventEmitter.on("event", listener);
eventEmitter.emit("event", "arg1", "arg2");
expect(listener).toHaveBeenCalledWith("arg1", "arg2");
});
});
describe("off", () => {
test("should remove a listener for an event", () => {
const listener = jest.fn();
eventEmitter.on("event", listener);
eventEmitter.off("event", listener);
eventEmitter.emit("event");
expect(listener).not.toHaveBeenCalled();
});
});
});
这个测试代码的特点:
- 代码简洁,没有多余的逻辑
- 测试 case 的描述非常清晰,能够很快的知道这个 case 的意图
- 测试 case 的执行速度非常快,因为没有外部依赖
总结
一个好的单元测试,应该符合 given-when-then 的原则,能够清晰的描述测试的意图,执行速度快,不包含逻辑。
本作品系原创,采用《署名-非商业性使用-禁止演绎 4.0 国际》许可协议