7

Jest 单元测试疑难点入门

 2 years ago
source link: https://segmentfault.com/a/1190000041386455
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Jest 单元测试疑难点入门

  • 介绍概念及思考的过程,不提供代码(具体代码写法可参考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()
    
  • 近期文章产出少,事情太多,也懒了
  • 感谢网友的牵挂和督促,被人挂念的感觉真好

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK