zoukankan      html  css  js  c++  java
  • 前端单元测试

    前端单元测试

    背景

    • 一直以来,单元测试并不是前端工程师必须具备的一项技能,在国内的开发环境下,普遍都要求快,因此往往会忽略了项目的代码质量,从而影响了项目的可维护性,可扩展性。随着前端日趋工程化的发展,项目慢慢变得复杂,代码越来越追求高复用性,这更加促使我们提高代码质量,熟悉单元测试就显得愈发重要了,它是保证我们代码高质量运行的一个关键。
    • 本文旨在探索单元测试的编写思路,它对项目的影响,以及对日常开发习惯的一些思考。会涉及 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. 如何做到重构代码时,影响最小。
    福禄·研发中心 福小凯
  • 相关阅读:
    (一)主动学习概念与技术
    mybatis 分页插件PageHelper 使用方法
    单例模式-Singleton
    解决tomcat启动报 java.lang.IllegalArgumentException: Invalid <url-pattern> login in servlet mapping
    如何在MSDN上获取Win7镜像
    解决 Could not find resource com/baidou/dao/UserMapper.xml
    4、XML 配置
    3、使用Map传参 & 模糊查询
    图解python环境搭建
    2、CRUD
  • 原文地址:https://www.cnblogs.com/fulu/p/15497264.html
Copyright © 2011-2022 走看看