前端单元测试

前端单元测试

背景

  • 一直以来,单元测试并不是前端工程师必须具备的一项技能,在国内的开发环境下,普遍都要求快,因此往往会忽略了项目的代码质量,从而影响了项目的可维护性,可扩展性。随着前端日趋工程化的发展,项目慢慢变得复杂,代码越来越追求高复用性,这更加促使我们提高代码质量,熟悉单元测试就显得愈发重要了,它是保证我们代码高质量运行的一个关键。
  • 本文旨在探索单元测试的编写思路,它对项目的影响,以及对日常开发习惯的一些思考。会涉及 jest 库,详细环境准备,及API使用规则可以参考 jest官网,这里不做赘述。

概念

  • 黑盒测试:不管程序内部实现机制,只看最外层的输入输出是否符合预期。
  • E2E测试:(End To End)即端对端测试,属于黑盒测试。 比如有一个加法的功能函数,有入参,有返回值,那么通过编写多个测试用例,自动去模拟用户的输入操作,来验证这个功能函数的正确性,这种就叫E2E测试。
  • 白盒测试:通过程序的源代码进行测试,而不是简单的使用用户界面观察测试。本质上就是通过代码检查的方式进行测试。
  • 单元测试:针对⼀些内部核心实现逻辑编写测试代码,对程序中的最小可测试单元进行检查和验证。也可以叫做集成测试,即集合多个测试过的单元⼀起测试。它们都属于白盒测试。

如何编写单元测试

  • 第一步,先找到测试单元的输入与输出

    如何着手写单元测试呢,首先要知道怎么抓住程序单元的头和尾,即测试临界点。例如现在有个求和函数add,现在要给它写单元测试,那么它的关键节点是什么呢?

    // add.js
    // 求和函数
    module.exports = {
      add(a, b) {
        return a + b;
      },
    };
    

    ​ 当我们调用add函数时,先会给它传入两个参数,函数执行完,会得到一个结果,所以我们可以以传入参数作为起点(输入),输出值作为终点(输出)去编写测试用例。

    输入

    将我们日常开发中的场景可以大致总结如下图所示:

    常用案例

  • 第二步,测试模型,理清程序的输入输出后,再按如下三步骤编写单元测试

    1. 准备测试数据(given)。
    2. 模拟测试动作(when)。
    3. 验证结果(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模式则是将开发过程中的关注点剥离出来,一次只做一件事:

    1. 需求
    2. 实现
    3. 设计
  • TDD模式编写测试用例,实现需求步骤

    1. 根据需求,假设需求功能已实现,先写一个运行失败的测试。(只关注需求)
    2. 编写真实功能代码,让测试代码运行成功。(只关注实现)
    3. 基于测试代码运行成功的基础上,重构功能代码。(只关注设计)
  • 示例-火星探测器

    假想现在有这么个需求:

    ​ 你在火星探索团队中负责软件开发。现在你要编写控制程序,根据地球发送的控制指令来控制火星车的行动。火星探测器会收到以下指令:

    1. 初始位置信息:火星车的着落点(x, y)和火星车的朝向(N, S, E, W)。

    2. 转向指令:火星车接受向左,向右指令,调转车头,朝向对应的方向(N, S, E, W)。

    3. 移动指令:火星车接受移动指令,前进或后退。

    因篇幅关系,只展示通过TDD模式实现初始化信息位置和左转向指令的功能,首先将需求进行拆解:

    1. 获取初始化车的位置(坐标postition 和方向direction)

    2. 实现左转指令:

      • 输入 input - turnLeft

      • 输出 output, 传入一个朝向,返回它左转后的方向:

        • North --- West

        • West --- South

        • South --- East

        • East --- North

指南针1

火星探测器功能实现:

  1. 安装环境(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"
  }
}

文件目录

  1. 测试一下环境是否搭建好了

    执行npm run test 将下面的测试代码跑起来,查看控制台信息是否通过,通过则可以开始编写测试用例。

    // car.spec.js
    test('jest', () => {
    	expect(1).toBe(1)
    })
    
  2. 按需求编写对应的测试用例。

// 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'
        })
    })
})
测试01
  1. 根据测试用例实现功能,让红色错误信息 变为绿色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
        }
    }
    
    测试用例2
  2. 获取初始化信息就算实现了,接下来按同样的套路,去实现左转指令

    // 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
            }
        }
        
    }
    
  3. 功能实现了,但是代码并不优雅,比如上面这些常量这样写很危险,一不小心就会报错。还有 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

    lcov-report

总结

  • 单元测试的好处:
    1. 充分理解需求,拆解需求。
    2. 代码结构设计更简练,易调试,代码更健壮。
    3. 易重构。
    4. 调试快。
    5. 实时文档,关键功能点,都有对应用例,哪里不会看哪里。
    6. 开源项目检验代码必备。
  • 透过单元测试,对目前项目及开发习惯的思考:
    1. 我们平时开发是否充分理解了需求。
    2. 是不是可以按照单元测试的规则去设计组件,减少层级嵌套深等引发的难维护,不易扩展问题。
    3. 针对复用性高的逻辑抽离,是不是可以适当的加上单元测试。
    4. 如何做到重构代码时,影响最小。
福禄·研发中心 福小凯
posted @ 2021-11-02 09:13  福禄网络研发团队  阅读(606)  评论(0编辑  收藏  举报