12
头图

文章基调

  • 介绍概念及思考的过程,不提供代码(具体代码写法可参考jest 官网
  • 延伸:

    • 信息大爆炸时代,各类资源很丰富,具体教程网上有很多资料
    • 详细不过官网,不重复制造相同的信息,造成额外的心智负担
    • 大脑只是搜索引擎,知道资源从那里找,不负责记录具体做法,节省内存

测试的几个名称

  • 视觉测试:【测试工具】前端视觉较为多变,故视觉测试的成本较大,普及性不高,但好处在于,可以测试样式信息
  • 单元测试:【测试目标】最小颗粒度的测试,针对单个函数或功能,适合函数库,基础组件库等的测试
  • 集成测试:【测试目标】模拟用户操作,面向交付的最终结果,针对项目的流程
  • TDD(Test Driven Development 测试驱动开发):【方法论】先写测试用例(提出期望值),在写具体的实现方法与函数,运用于单元测试
  • BDD(Behavior Driven Development 行为驱动开发):【方法论】基于集成测试
  • 本文主要介绍 jest(玩笑) 单元测试库

jest 单元测试的原理与局限性

先介绍原理,是希望让大家知道其功能边界,能做什么,不能做什么,了解能力范围

  • jest 运行在 node 端,底层使用实现库是 jsdom,使用 node 模拟一套 dom 环境,模拟的范围仅局限于 dom 层级结构及操作

  • 【dom 操作】只模拟大部分 dom 通用功能,某些特定性的 dom api 并不支持,如 canvas,video 的媒体功能 api

    • 如果要测试 canvas,video 的媒体 API,需要安装对应的扩展库,可以理解为在 node 端实现浏览器的功能,如图片生成,音视频播放等
    • canvas 扩展,video 相关扩展暂时没找到
  • 【css 样式】严格而言,没有 css 样式模拟功能,css 在 jsdom 中只当做纯粹的 dom 属性字符串,与 id,class 字符串没有区别

单元测试需要覆盖那些场景?

  • 代码变动

    • 直接运行单元测试即可发现,但如何避免开发者忘记了运行单元测试?
    • 通过添加 cicd 流程解决,提交 merge request 申请时,触发单元测试,运行失败,则自动拒绝合并请求,并执行 node 命令发送消息提醒
    • gitlab ci 相关配置会在文章末尾介绍
  • 新增代码

    • 新增的函数或者功能,运行旧的单元测试不会覆盖到,如何提醒开发者覆盖新增的这部分代码?
    • 通过配置测试覆盖率行数 100% 解决,达不到目标,则视为测试不通过,避免新增代码的遗漏。实在无法覆盖的分支或函数,怎么解决?
    • 通过配置「忽略备注 / istanbul ignore next / 」,保持某文件的百分百覆盖率测试
    • 后续有时间也可以通过全局搜索这些忽略配置,来逐个覆盖测试,起到标记的作用

      coverageThreshold: {
       './src/common/js/*.js': {
         branches: 80, // 覆盖代码逻辑分支的百分比
         functions: 80, // 覆盖函数的百分比
         lines: 80, // 覆盖代码行的百分比
         statements: -10 // 如果有超过 10 个未覆盖的语句,则 jest 将失败
       }
      },
  • 新增文件,是否遗漏测试

    • 一般情况下,单元测试只会跑单元测试文件,新增的代码文件没有对应的测试文件,会出现漏测的情况
    • 通过collectCoverageFrom参数指定需要覆盖的文件夹,当该文件夹中的文件没有对应的测试用例,会当作覆盖率 0 处理,起到新文件漏测提醒作用

      // 从那些文件夹中生成覆盖率信息,包括未设置对其编写测试用例的文件,解决遗漏新文件的测试覆盖问题
      collectCoverageFrom: [
        './src/common/js/*.{js,jsx}',
        './src/components/**/*.{js,vue}',
      ],
  • 特殊场景(经验的价值)

    • 部分函数,在正常情况下运行是没有问题的,仅在特殊的情况下才会报错,如简单的加法运算,放在小数中就会出现计算误差,0.1 + 0.2 = 0.30000000000000004
    • 这些特殊场景的覆盖,只能靠一线开发人员在实际工作中记录,需要时间的积累
    • 这是程序员经验的价值,也是少有的,值得传承的部分

单元测试忽略原理

jest 收集覆盖率底层使用的是 istanbul 库(istanbul:伊斯坦布尔,胜产地毯,地毯用于覆盖),以下忽略格式都是 istanbul 库的功能

  • 忽略本文件,放在文件顶部 / istanbul ignore file /
  • 忽略一个函数, 一块分支逻辑或者一行代码,放在函数顶部 / istanbul ignore next /
  • 忽略函数参数默认值function getWeekRange(/* istanbul ignore next */ date = new Date()) {
  • 具体忽略规则可查看 istanbul github 介绍

编写测试用例的正确姿势

以对功能的期望及定位作为出发点,而不是代码,一开始应先思考该函数或工具库需要起到的功能,而不应该一开始就看代码

  • 先罗列你期望的,该组件或者函数的功能,用文本写出来,这也是 test('检测点击事件') 中描述的作用,告知他人这个测试用例的目的
  • 编写相应的测试用例
  • 对不满足测试用例的代码进行修改
  • 观察代码覆盖率,覆盖所有代码行

添加 jest 全局自定义函数

  • 如果某测试函数的出现频率比较高,可以考虑对齐进行复用,写成一个预加载文件,在每个测试文件执行前,加载该文件
  • 如获取 dom 样式的原始代码比较繁琐,wrapper.element.style.height,且 element 并没有得到官方暴露,属于内部变量
  • 可以通过添加配置文件,编写 styles 全局方法,通过函数的方式获取 style 数据,与 classes 等方法保持统一

    // jest.config.js 设置前置运行文件,在每个测试文件执行前,会运行该文件,实现添加某些全局方法的作用
    setupFilesAfterEnv: ['./jest.setup.js'],
    // ./jest.setup.js
    import { shallowMount } from '@vue/test-utils'
    
    // 向全局 wrapper 挂载通用函数 styles,返回该元素的内联样式(因为 jsdom 只支持内联样式,不支持检测 class 中的样式),或某内联样式的值
    function addStylesFun() {
      // 生成一个临时组件,获取 vueWrapper 及 domWrapper 实例,挂载 style 方法
      const vueWrapper = shallowMount({ template: '<div>componentForCreateWrapper</div>' })
      const domWrapper = vueWrapper.find('div')
    
      vueWrapper.__proto__.styles = function(styleName) {
        return styleName ? this.element.style[styleName] : this.element.style
      }
    
      domWrapper.__proto__.styles = function(styleName) {
        return styleName ? this.element.style[styleName] : this.element.style
      }
    }
    addStylesFun()
    

钩子函数

类似于 vue router 里面的守卫函数,在进入前后执行钩子函数

  • 解决有状态函数的数据存储问题,避免执行每一个测试用例时,重复编写代码准备数据
  • beforeAll、afterAll

    • 写在单元测试文件最外部,则代表在该函数在文件执行前、后被执行一次
    • 写在测试组 describe 最外层,代表该函数在测试组执行前、后被执行一次
  • beforeEach、afterEach

    • 每个测试用例(test)前后执行一次

快速单元测试技巧

跳过已测试成功且源码没发生过变更的用例,不再多余执行

  • 第一步,jest --watchAll 测试文件发生过变化,则自动执行测试

    • 只能在 package script 命令中添加该参数,在 npm 命令后执行不生效
    • 源码变更,或单元测试文件变更,都会触发
  • 第二步,按下 f(只执行错误的用例)

    • 缺点在于,不能监控已执行成功的单元测试的变化,以及对应源码的变化,(即之前成功过的都会被忽略,不管新的变化,是否发生了错误)
    • 源码变更,或单元测试文件变更,都会触发
    • 可通过反复按下 f 来切换全局遍历
  • 第三步,再按下 o (只执行源码发生过变化的文件的测试用例)

    • 等价于 jest --watch
    • 只监听 git 中,未提交到暂存区的文件,一旦提交了 stash,则不再触发
    • 即使该文件中存在失败的测试用例,也会被忽略
    • 按下 a 来跑全部文件的测试用例,即 a 与 o 的切换
    • 底层是通读取 .git 文件夹的内容进行文件区分,故依赖 git 的存在
  • 按下 w 可以显示菜单,查看 watch 的选项
    一般情况下,集合 o 与 f 使用,先 o(忽略没变化的文件,当我们改动该文件时,将会被监听。再反复按下 f,只监听错误的用例)


jest 报告说明

jest 报告说明

  • 鼠标悬浮对应图表,即可显示对应提示
  • 「5x」表示在测试中这条语句执行了 5 次
  • 「I」是测试用例 if 条件未进入,即没有 if 为真的测试用例
  • 「E」是测试用例没有测试 if 条件为 false 时的情况

    • 即测试用例中 if 条件一直都是 true,得写一个 if 条件为 false 的测试用例,即不走进 if 条件里面的代码,这个 E 才会消失

模拟函数,不是模拟数据的函数

  • 只是模拟函数(Function、jest.fn()),并不是像 mockjs 一样,生成模拟数据的函数
  • 作用:

    • 检测该函数被执行过多少次
    • 检测该函数被执行时的 this 指向
    • 检测执行时的入参
    • 检测执行后的返回值
  • 覆盖模拟第三方函数

    • 覆盖 axios 函数,避免真正发起接口,定制特定的返回值 jest.mock('axios'); axios.get.mockResolvedValue(resp);
    • 里面没有魔法,也没有私下适配,只是单纯的函数重载。相当于 axios.get = ()=> resp 重写了该方法
  • 终极方法,覆盖整个第三方库

    • 编写替身文件,在使用 import 导入时,导入的是替身文件
    • 也可以通过 jest.requireActual('../foo-bar-baz') 来强制设置导入的是真实的文件,不使用替身文件

计时器模拟

  • 复写 setTimeout 计时器,可以跳过指定时长,缩短单元测试运行时长

测试快照

  • 快照,即数据副本,即检测「当前数据」是否与「旧有数据副本」相同,类似于 JSON.stringify(),进行数据的序列化记录
  • 应用场景

    • 限制配置文件的变更
    • 检测 dom 结构的比较,某函数的变更,是否影响 dom 结构
    • 总体而言,用在大数据的比较操作,避免将数据写死在单元测试文件中

其他疑难杂症

  • 别名与等价的方法

    • it 是 test 的别名,两者等价
    • toBeTruthy !== toBe(true)、toBeFalsy !== toBe(false),toBe(true) 更严格,toBeTruthy 是强转为 boolean 后,是否为真
    • skip 跳过某测试用例,比注释更优雅describe.skip('测试自定义指令',xxx)`test.skip('测试自定义指令',xxx)`
  • jest toBe,内部使用 Object.is 进行比较

    • 与 === 的区别是,除了 NaN,+0 和 -0 之外,其行为与三等号于运算符相同
  • 解决小数点浮点数计算误差问题 toBeCloseTo
  • 异步测试,通过 .resolves / .rejects 强制校验 promise 走特定分支

    test('the data is peanut butter', () => {
      return expect(fetchData()).resolves.toBe('peanut butter');
    });
  • 解决默认参数为 new Date 的覆盖问题

    test('当前月,测试参数 new Date 默认值', () => {
      // 覆写 new Date 的值,模拟为 2022/01/23 17:13:03 ,解决默认参数为 new Date 时,无法覆盖的问题
      const mockDate = new Date(1642938133000)
      const spyDate = jest
        .spyOn(global, 'Date') // 即监听 global.Date 变量
        .mockImplementationOnce(() => {
          spyDate.mockRestore() // 需要在第一次执行后,马上消除该 mock,避免后续影响后续 new Date
          return mockDate
        })
      let [starTime, endTime] = getMonthRange()
      expect(starTime).toBe(1640966400000) // 2022/01/01 00:00:00
      expect(endTime).toBe(1643644799000) // 2022/01/31 23:59:59
    })
    • 等价于使用原生语法写

      const OriginDate = global.Date
      Date = jest.fn(() => {
        Date = OriginDate
        return new OriginDate(1642938133000)
      })
    • 使用最新语法

      beforeAll(() => {
          jest.useFakeTimers('modern')
          jest.setSystemTime(new Date(1466424490000)) // 因为 Vue Test Utils 中使用的 jest 是 24.9 的版本,没有该函数
        })
      
        afterEach(() => {
          jest.restoreAllMocks()
        })
  • 匹配测试,及使用多个批次的数据,进行跑同一个测试用例

    describe.each([
      [1, 1, 2], // 每一行是代表运行一次测试用例
      [1, 2, 3], // 每一行中的参数,是运行该次测试用例是用到的数据,前两个是参数,第三个是测试期望值
      [2, 1, 3],
    ])(
      '.add(%i, %i)', // 设置 describe 的标题,%i 是 print 的变量参数
     (a, b, expected) => {
      test(`returns ${expected}`, () => {
        expect(a + b).toBe(expected);
      });
    });

gitlab-ci 单元测试相关配置

  • 在发起 merge 合并请求时,触发 ci 执行单元测试
  • 当单元测试失败,执行 node 文件,发送飞书信息,飞书信息中,包括该次 merge 请求的链接,可以点击该链接,快速定位到单元测试 job,查看问题

    stages:
    - merge-unit-test
    - merge-unit-test-fail-callback
    - other-test
    
    # merge 请求时执行的 job
    step-merge:
    stage: merge-unit-test
    
    # 使用的 gitlab runner
    tags: [front-end]
    
    # 仅在提出代码合并请求时执行
    only: [merge_requests]
    
    # 排除特定分支的代码合并请求,即在特定分支的代码合并请求时,不执行该 job
    except:
      variables:
        - $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "qa"
    
    # 运行的命令
    script:
      - npm install --registry=https://registry.npm.taobao.org # 安装依赖
      # 2>&1 标准错误定向到标准输出
      # Linux tee 命令用于读取标准输入的数据,并将其内容输出成文件。
      - npm run test 2>&1 | tee ci-merge-unit-test.log # 执行单元测试,并将在控制台输出的信息,保存在 ci-merge-unit-test.log 文件中,以便后续分析
      - echo 'merge-unit-test-finish'
    
    # 定义往下一个 job 需要传递的资料
    artifacts:
      when: on_failure # 默认情况下,只会在 success 保存,可以通过这个标识符进行配置
      paths:  # 定义需要传递的文件
        - ci-merge-unit-test.log
    
    # merge 检测失败时执行的 node 命令
    step-merge-unit-test-fail-callback:
    stage: merge-unit-test-fail-callback
    
    # 当上一个 job 执行失败时,才会触发
    when: on_failure
    
    tags: [front-end]
    
    only: [merge_requests]
    
    except:
      variables:
        - $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "qa"
    
    script:
      - node ci-merge-unit-test-fail-callback.js $CI_PROJECT_NAME $CI_JOB_ID # 执行 node 脚本,进行飞书通知,并携带对应链接,进行快速定位
    
  • ci-merge-unit-test-fail-callback.js.js

    const fs = require('fs')
    const path = require('path')
    const https = require('https')
    const projectName = process.argv[2] // 项目名
    const jobsId = process.argv[3] // 执行的 ci 任务 id
    
    const logsMainMsg = fs.readFileSync(path.join(__dirname, 'ci-merge-unit-test.log'))
    .toString()
    .split('\n')
    .filter(line => line[line.length - 1] !== '|' && line.indexOf('PASS ') !== 0) // 过滤不关注的信息
    .join('\n')
    
    const data = JSON.stringify({
    msg_type: 'post',
    content: {
      post: {
        zh_cn: {
          content: [
            [
              {
                tag: 'a',
                text: 'gitlab merge 单元测试',
                href: `https://xxx/fe-team/${projectName}/-/jobs/${Number(jobsId) - 1}`
              },
              {
                tag: 'text',
                text: `运行失败\r\n${logsMainMsg}`
              }
            ]
          ]
        }
      }
    }
    })
    
    const req = https.request({
    hostname: 'open.feishu.cn',
    port: 443,
    path: '/open-apis/bot/v2/hook/xxx',
    method: 'POST',
    headers: { 'Content-Type': 'application/json' }
    }, res => {
    console.log(`statusCode: ${res.statusCode}`)
    res.on('data', d => process.stdout.write(d))
    })
    req.on('error', error => console.error(error))
    req.write(data)
    req.end()
    

感谢

  • 近期文章产出少,事情太多,也懒了
  • 感谢网友的牵挂和督促,被人挂念的感觉真好


momo707577045
2.4k 声望603 粉丝

[链接]