文章基调
- 介绍概念及思考的过程,不提供代码(具体代码写法可参考 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 字符串没有区别
- 不支持继承,每个 dom 都是独立的个体,没有样式上继承。
- 仅支持内联样式 ,无法识别 vue 中的样式
- 不太有用的,解析外链样式的示例
- 这里有个 解决方案 ,但没有得到官方合并
- 非内联的样式测试,需要使用 视觉测试库
单元测试需要覆盖那些场景?
-
代码变动
- 直接运行单元测试即可发现,但如何避免开发者忘记了运行单元测试?
- 通过添加 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 报告说明
- 鼠标悬浮对应图表,即可显示对应提示
- 「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
重写了该方法
-
覆盖 axios 函数,避免真正发起接口,定制特定的返回值
-
终极方法,覆盖整个第三方库
- 编写替身文件,在使用 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}`)