前端单元测试
前端单元测试
背景
- 一直以来,单元测试并不是前端工程师必须具备的一项技能,在国内的开发环境下,普遍都要求快,因此往往会忽略了项目的代码质量,从而影响了项目的可维护性,可扩展性。随着前端日趋工程化的发展,项目慢慢变得复杂,代码越来越追求高复用性,这更加促使我们提高代码质量,熟悉单元测试就显得愈发重要了,它是保证我们代码高质量运行的一个关键。
- 本文旨在探索单元测试的编写思路,它对项目的影响,以及对日常开发习惯的一些思考。会涉及 jest 库,详细环境准备,及API使用规则可以参考 jest官网,这里不做赘述。
概念
- 黑盒测试:不管程序内部实现机制,只看最外层的输入输出是否符合预期。
- E2E测试:(End To End)即端对端测试,属于黑盒测试。 比如有一个加法的功能函数,有入参,有返回值,那么通过编写多个测试用例,自动去模拟用户的输入操作,来验证这个功能函数的正确性,这种就叫E2E测试。
- 白盒测试:通过程序的源代码进行测试,而不是简单的使用用户界面观察测试。本质上就是通过代码检查的方式进行测试。
- 单元测试:针对⼀些内部核心实现逻辑编写测试代码,对程序中的最小可测试单元进行检查和验证。也可以叫做集成测试,即集合多个测试过的单元⼀起测试。它们都属于白盒测试。
如何编写单元测试
-
第一步,先找到测试单元的输入与输出
如何着手写单元测试呢,首先要知道怎么抓住程序单元的头和尾,即测试临界点。例如现在有个求和函数add,现在要给它写单元测试,那么它的关键节点是什么呢?
// add.js // 求和函数 module.exports = { add(a, b) { return a + b; }, };
当我们调用add函数时,先会给它传入两个参数,函数执行完,会得到一个结果,所以我们可以以传入参数作为起点(输入),输出值作为终点(输出)去编写测试用例。
将我们日常开发中的场景可以大致总结如下图所示:
-
第二步,测试模型,理清程序的输入输出后,再按如下三步骤编写单元测试
- 准备测试数据(given)。
- 模拟测试动作(when)。
- 验证结果(then)。
还是以求和函数 add 为例子编写测试套件:
// add.spec.js const { add } = require("./add"); it("测试add求和函数", () => { // given -> 准备测试数据 const a = 1; const b = 1; // when -> 模拟测试动作 const result = add(a, b); // then -> 验证结果 expect(result).toBe(2); });
-
小结
以上的操作,实际上可以想象为把我们要测试的函数或组件当作成一个冰箱,往冰箱里放一瓶水,过一段时间,会得到一瓶冰水。那么往冰箱放一瓶水是输入,拿出一瓶冰水是输出。我们的程序不管多复杂,也可以按上面这样先找到临界点。这样我们就知道从哪里开始测试,到哪里结束,从而按照测试步骤,模拟程序,论证得到的结果。
TDD模式
上面我们已经了解了如何编写单元测试用例,那我们如何利用单元测试帮助我们合理产出呢?就像上面 add函数的例子,我们是先实现了功能,再去测试功能的。如果单元测试仅仅是用来这样去产出的话,那也未免太鸡肋了。回想一下,我们目前的常规开发模式是拿到需求,实现需求,再去测试我们程序是否达到了交付要求。而TDD模式,则完全颠覆了这个过程,它是先写单元测试用例,通过单元测试用例来确定编写什么样的代码,实现什么样的功能,即测试驱动开发(Test Driven Development)。
-
核心思想
开发功能代码前,先编写测试代码。
-
本质
我们常用的开发模式是先实现功能,再测试。在实现过程中,我们可能需要考虑需求是什么,如何去实现它,代码该如何设计,扩展性更好,更易维护等等问题,每次当我们实现某个功能时,都要考虑这些问题,有时会感觉不知道怎么写才合适。而TDD模式则是将开发过程中的关注点剥离出来,一次只做一件事:
- 需求
- 实现
- 设计
-
TDD模式编写测试用例,实现需求步骤
- 根据需求,假设需求功能已实现,先写一个运行失败的测试。(只关注需求)
- 编写真实功能代码,让测试代码运行成功。(只关注实现)
- 基于测试代码运行成功的基础上,重构功能代码。(只关注设计)
-
示例-火星探测器
假想现在有这么个需求:
你在火星探索团队中负责软件开发。现在你要编写控制程序,根据地球发送的控制指令来控制火星车的行动。火星探测器会收到以下指令:
-
初始位置信息:火星车的着落点(x, y)和火星车的朝向(N, S, E, W)。
-
转向指令:火星车接受向左,向右指令,调转车头,朝向对应的方向(N, S, E, W)。
-
移动指令:火星车接受移动指令,前进或后退。
因篇幅关系,只展示通过TDD模式实现初始化信息位置和左转向指令的功能,首先将需求进行拆解:
-
获取初始化车的位置(坐标postition 和方向direction)
-
实现左转指令:
-
输入 input - turnLeft
-
输出 output, 传入一个朝向,返回它左转后的方向:
-
North --- West
-
West --- South
-
South --- East
-
East --- North
-
-
-
火星探测器功能实现:
- 安装环境(package.json及文件目录):
{
"name": "car",
"version": "1.0.0",
"description": "",
"main": "car.js",
"scripts": {
"test": "jest --watchAll"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@types/jest": "^27.0.1",
"jest": "^27.1.0"
}
}
-
测试一下环境是否搭建好了
执行npm run test 将下面的测试代码跑起来,查看控制台信息是否通过,通过则可以开始编写测试用例。
// car.spec.js test('jest', () => { expect(1).toBe(1) })
-
按需求编写对应的测试用例。
// car.spec.js
// 假设获取火星车初始着陆坐标和朝向功能已实现,直接编写测试用例,假设初始坐标为(0,0),朝向north。
// Position是一个类,它用来设置火星车的坐标。
// Car是一个类,他含有需求要求的两个指令功能:获取初始位置,发出左转指令让火星车正确转向。
// 此时的 car.js 和 position.js 文件还什么都没有写,实际功能并未实现,此时控制台显示红色错误信息,测试未通过。
const Position = require('../position')
const Car = require('../car')
describe('car', () => {
it('init position and directon', () => {
const position = new Position(0, 0)
const car = new Car(position, 'north')
expect(car.getState()).toEqual({
position: {
x: 0,
y: 0
},
direction: 'north'
})
})
})
-
根据测试用例实现功能,让红色错误信息 变为绿色pass。
// car.js module.exports = class Car{ constructor(position, direction) { this.position = position this.direction = direction } getState() { return { position: this.position, direction: this.direction } } }
// position.js module.exports = class Position{ constructor(x, y) { this.x = x this.y = y } }
-
获取初始化信息就算实现了,接下来按同样的套路,去实现左转指令
// car.spec.js const Position = require('../position') const Car = require('../car') describe('car', () => { it('init position and directon', () => { const position = new Position(0, 0) const car = new Car(position, "north") expect(car.getState()).toEqual({ position: { x: 0, y: 0 }, direction: "north" }) }) describe('turnLeft', () => { it('North --- West', () => { const car = new Car(new Position(0, 0), "north") car.turnLeft() expect(car.getState()).toEqual({ position: { x: 0, y: 0, }, direction: "west", }) }) it('West --- South', () => { const car = new Car(new Position(0, 0), "west") car.turnLeft() expect(car.getState()).toEqual({ position: { x: 0, y: 0, }, direction: "south", }) }) it('South --- East', () => { const car = new Car(new Position(0, 0), "south") car.turnLeft() expect(car.getState()).toEqual({ position: { x: 0, y: 0, }, direction: "east", }) }) it('East --- North', () => { const car = new Car(new Position(0, 0), "east") car.turnLeft() expect(car.getState()).toEqual({ position: { x: 0, y: 0, }, direction: "north", }) }) }) })
// car.js module.exports = class Car{ constructor(position, direction) { this.position = position this.direction = direction } getState() { return { position: this.position, direction: this.direction } } // 左转 turnLeft() { if(this.direction === "north"){ this.direction = "west" return } if(this.direction === "west"){ this.direction = "south" return } if(this.direction === "south"){ this.direction = "east" return } if(this.direction === "east"){ this.direction = "north" return } } }
-
功能实现了,但是代码并不优雅,比如上面这些常量这样写很危险,一不小心就会报错。还有 turnLeft 函数,里面的流程完全一样,可以进行公共逻辑抽离。因为我们现在有单元测试了,所以我们可以放心大胆的对功能进行改造,单元测试会实时的告诉我们程序哪里会有问题,我们不需要像以前那样调整一下代码,就去console.log一下,或者在页面进行调试,现在只需要保证将控制台输出的error调整为 pass 状态即可,改造后的代码如下:
// ../constant/direction // 常量提取 module.exports={ N: "north", W: "west", S: "south", E: "east", }
// ../constant/directionMap const Direction = require('./direction') const map = { [Direction.N]: { left: Direction.W }, [Direction.W]: { left: Direction.S }, [Direction.S]: { left: Direction.E }, [Direction.E]: { left: Direction.N } } // 流程抽离,当我们传入一个方向时,返回他左转后的方向 module.exports = { turnLeft: direction => map[direction].left }
// car.spec.js const Direction = require('../constant/direction') const Position = require('../position') const Car = require('../car') describe('car', () => { it('init position and directon', () => { const position = new Position(0, 0) const car = new Car(position, Direction.N) expect(car.getState()).toEqual({ position: { x: 0, y: 0 }, direction: Direction.N }) }) describe('turnLeft', () => { it('North --- West', () => { const car = new Car(new Position(0, 0), Direction.N) car.turnLeft() expect(car.getState()).toEqual({ position: { x: 0, y: 0, }, direction: Direction.W, }) }) it('West --- South', () => { const car = new Car(new Position(0, 0), Direction.W) car.turnLeft() expect(car.getState()).toEqual({ position: { x: 0, y: 0, }, direction: Direction.S, }) }) it('South --- East', () => { const car = new Car(new Position(0, 0), Direction.S) car.turnLeft() expect(car.getState()).toEqual({ position: { x: 0, y: 0, }, direction: Direction.E, }) }) it('East --- North', () => { const car = new Car(new Position(0, 0), Direction.E) car.turnLeft() expect(car.getState()).toEqual({ position: { x: 0, y: 0, }, direction: Direction.N, }) }) }) })
// car.js const Direction = require('./constant/direction') const { turnLeft } = require('./constant/directionMap') module.exports = class Car{ constructor(position, direction) { this.position = position this.direction = direction } getState() { return { position: this.position, direction: this.direction } } turnLeft() { this.direction = turnLeft(this.direction) } }
测试覆盖率
-
如果项目已经写完了,如何查看项目测试覆盖率,根据测试覆盖率针对性调整代码?修改package.json文件中的 scripts执行脚本,执行npm run test,根目录下会生成一个coverage文件夹,找到该文件夹下 lcov-report文件中的index.html,在浏览器中打开,可以查看各个文件的测试用例覆盖率。
package.json
"scripts": { "test": "jest --coverage" }
coverage/lcov-report/index.html
总结
- 单元测试的好处:
- 充分理解需求,拆解需求。
- 代码结构设计更简练,易调试,代码更健壮。
- 易重构。
- 调试快。
- 实时文档,关键功能点,都有对应用例,哪里不会看哪里。
- 开源项目检验代码必备。
- 透过单元测试,对目前项目及开发习惯的思考:
- 我们平时开发是否充分理解了需求。
- 是不是可以按照单元测试的规则去设计组件,减少层级嵌套深等引发的难维护,不易扩展问题。
- 针对复用性高的逻辑抽离,是不是可以适当的加上单元测试。
- 如何做到重构代码时,影响最小。