单元测试工具JEST入门——纯函数的测试

单元测试工具JEST入门——纯函数的测试

  • 什么是测试❓
  • 我只是开发而已?
  • 常见单元测试工具
  • jest的使用
    • 首先你得知道
    • 一个简单的例子
    • Oops!出现了一些问题
    • 高效的持续监听!
    • 生成测试覆盖率报告
    • Jest 中的 mock
    • Jest 中的钩子函数
    • 常用的断言方法
    • Mock Timer
    • 什么是快照测试?
  • 前端单元测试策略与原则
  • ⭕ 你可能会遇到的问题
  • Demo

什么是测试❓

对于前端来说,测试主要是对HTML、CSS、JavaScript进行测试,以确保代码的正常运行。

常见的测试:单元测试、集成测试、端到端的测试(e2e)

  • 单元测试:对程序中最小可测试单元进行测试。——零件测试
  • 集成测试:对多个可执行单元组成的整体进行测试。——零件组装成的“发动机”测试
  • 端到端的测试:从服务端到客户端的测试,是对整体系统进行测试。——执行完整流程。

测试方式:人工测试、自动测试

  • 人工测试:测试同学根据业务流程进行操作,人工检查哪一步会出现问题。它只能测试出看得见的问题,对于看不见的部分,例如内部函数、逻辑代码等,都有可能会出现问题。
  • 自动测试:利用写好的代码对代码进行测试。能够弥补人工测试的不足,它的颗粒度是代码级别的,能够准确识别某个方法的错误。
    在实际的开发过程中一般采用人工测试+自动测试的方式,力求100%覆盖测试目标。

我只是开发而已?

bug发现在开发阶段成本很低,如果发现在生产环境成本很高。

技术的角度:有效的提高代码的健壮性,有效的增加代码的可维护性,对于后期的代码重构是必要条件。
团队的角度:可以有效的减少测试成本,维护成本。

单元测试是前期慢,后期快的工作,做好单元测试可以大大缩短后期的测试和改 bug 的时间。

单元测试的好处

  1. 更少的调试:经过测试的代码在提交时有较少的缺陷
  2. 增加对变化的信心:可以自信地审查和接受项目的变化
  3. 更简单的审查:CR中验证代码是否符合预期的精力就会减少
  4. 深思熟虑的设计:编写测试是锻炼代码本身的一种实用手段
  5. 快速、高质量地发布:可以放心地发布新版本的应用程序

❌ 因为单元测试有这些好处,所以要做单元测试??

✅ 如果不做单元测试,会有什么样的问题?
单元测试工具JEST入门——纯函数的测试_第1张图片

瀑布流开发:项目的每个阶段都会带来种种需求不匹配的情况,导致交付的最终价值可能不是客户所需要的。

敏捷开发:迭代式的软件开发流程,在每一个小的迭代周期内,走过完整的流程,并及时发布一个可用版本给用户,风险提前暴露,用户及时反馈,快速进入下一个迭代,降低需求不匹配带来的风险。

敏捷是为了更快交付有价值的、可工作的软件。

市场变化多端,我们需要及时推出产品,从而验证产品在市场上的用户反馈。

想法付诸行动 → 业务取得成效:决策、设计、开发、测试、发布、反馈… …

戴明环:是全面质量管理的思想基础和方法,将质量管理分为四个阶段,即Plan(计划)、Do(执行)、Check(检查)和 Action(处理)。

不断缩短反馈周期,提高反馈速度,才能减少不必要的浪费。

Q:“敏捷交付最重要的是什么?” A:“快。快速迭代、持续交付用户价值。”
Q:“对我们开发者有什么影响?” A:“如果我们不写单元测试,我们就快不起来。”

每次开发上线,团队都要投入人力来进行手工测试,其中也包括你自己
因为没有测试,不敢随意重构,从而导致代码逐渐腐化,随着项目的进展,代码越来越复杂,复杂的代码会导致开发速度降低,从而陷入死循环。代码越来越烂、开发越来越慢、BUG越来越多。
除此之外,我们整个项目的人员会流动,应用会变得越来越复杂,功能会变得越来越多,那么人员一定会流动,需求一定会增加,直到整个项目没有一个人可以了解到应用的所有功能,那么对软件进行修改的成本就会非常高。
如果试图依赖人工方式来应对快速变化的市场,挑战是非常高的。而单元测试是自动化的,能够给我们极快的反馈速度。

所以,单元测试是非常有必要的!

常见单元测试工具

目前用的最多的前端单元测试框架主要有 Mocha、Jest,但推荐使用 Jest,因为 Jest 和 Mocha 相比,无论从 github stats & issues 量,npm下载量相比,都有明显优势。
单元测试工具JEST入门——纯函数的测试_第2张图片

详见:github stats 以及 npm 下载量的实时数据

jest的使用

Jest 是 Facebook 开发的一款 JavaScript 测试框架,在 Facebook 内部广泛用来测试各种 JavaScript 代码。

  • 轻松上手
  • 高性能支持测试的并发运行
  • 内置强大的断言与 mock 功能
  • 内置测试覆盖率统计功能
  • 内置 Snapshot(快照)机制
npm install --save-dev jest

首先你得知道

Jest 的测试脚本名形如*.test.js,不论 Jest 是全局运行还是通过 npm test 运行,它都会执行当前目录下所有的*.test.js 或 *.spec.js 文件完成测试

  • test(name, fn, timeout)是将运行测试的方法。也叫 it(name, fn, timeout)
  • describe(name, fn)是一个将多个相关的测试组合在一起的块。
// add.js
function add(a, b) {
  return a + b
}

// add.test.js
describe('add function', () => {
  test('adds 1 + 2 to equal 3', () => {
    const result = add(1, 2)
    expect(result).toBe(3)
  })

  it('adds 5 + 7 to equal 12', () => {
    const result = add(5, 7)
    expect(result).toBe(12)
  })
})

一个简单的例子

// sum.js
function sum(a, b) {
  return a + b
}
module.exports = sum

// sum.test.js
import { expect, test } from '@jest/globals'
const sum = require('./sum')

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3)
})

// package.json
{
  "scripts": {
    "test": "jest"
  },
}

运行yarn test 或者 npm test

Oops!出现了一些问题

当我想用 import 来引入时,出现了这样的问题:

SyntaxError: Cannot use import statement outside a module

原因:nodejs 采用的是 CommonJS 的模块化规范,使用 require 引入模块;而 import 是 ES6 的模块化规范关键字。想要使用 import,必须引入 babel 转义支持,通过 babel 进行编译,使其变成 node 的模块化代码:

npm install --save-dev @babel/core @babel/preset-env

项目的根目录 .babelrc.js

module.exports = {
  presets: ['@babel/preset-env']
}

问题解决!
原因:jest 运行时内部先执行( jest-babel ),检测是否安装 babel-core,然后取 .babelrc 中的配置运行测试之前结合 babel 先把测试用例代码转换一遍然后再进行测试

如果我想测试ts?
jest只能测试 js 文件, 要对其他类型的文件进行测试,则需要使用其他的扩展,在typescript项目中,我们可以使用babel或者ts-jest来实现项目对ts测试的支持。

第一种:使用 babel:

// 安装依赖
npm install --save-dev @babel/preset-typescript

// 项目的根目录 .babelrc.js
presets: ['@babel/preset-typescript', '@babel/preset-env']

第二种:使用 ts-jest:

// 安装依赖
npm add --save-dev jest ts-jest @types/jest

区别:在对 Typescript 的测试中,因为Babel对 Typescrip 的支持是纯编译形式(无类型校验),所以@babel/preset-typescript 并不能 ts 类型进行检查,所以如果需要类型校验,你就需要使用 ts-jest 来进行 Typescrip的支持。
详见:使用Typescript

高效的持续监听!

为了提高效率,可以通过加启动参数的方式让 jest 持续监听文件的修改,而不需要每次修改完再重新执行测试用例,在package.json中:

"test": "jest --watchAll"

生成测试覆盖率报告

什么是单元测试覆盖率?
指在所有功能代码中,完成了单元测试的代码所占的比例。
单元测试覆盖率 = 被测代码行数 / 参测代码总行数 * 100%

两种方法:

  1. 命令执行:
npm test --coverage
  1. 在 jest.config.js 中配置:
module.exports = {
  // 是否显示覆盖率报告
  collectCoverage: true,
  // 告诉 jest 哪些文件需要经过单元测试测试
  collectCoverageFrom: ['src/utils/**/*'],
}

这里我以utils下的所有文件为例:
单元测试工具JEST入门——纯函数的测试_第3张图片

参数名 含义 说明
% Stmts 语句覆盖率 是否每条语句都执行了?
% Branch 分支覆盖率 是否每个情况分支都执行了?(例如 if-else)
% Funcs 函数覆盖率 是否每个函数都调用了?
% Lines 行覆盖率 是否每一行都执行了?
Uncobered Line 未覆盖到的行 未覆盖到的行数

Jest 中的 mock

为什么Jest需要模拟函数?

世界软件开发大师Martin Fowler根据是否依赖其他模块将单元测试分为了社交型测试单元独立型测试单元

在测试中我们特别需要注意不同模块之间的依赖:

  • Database数据库
  • Network Requests网络请求
  • Access to Files存取文件
  • Any External System任何外部系统

这些依赖我们需要“扮演者”,也就是模拟函数

这里我们主要了解Jest 中的三个与 Mock 函数相关的API,分别是 jest.fn()、jest.mock()、jest.spyOn()

  1. 模拟函数 jest.fn()

jest.fn(implementation) :是创建Mock函数最简单的方式,用于模拟特定行为。

我们可以设置该函数的返回值、监听该函数的调用、改变函数的内部实现等等,我们通过 jest.fn() 创建的函数有一个特殊的 .mock 属性,该属性保存了每一次调用情况。

写一个单元测试:

export const myMap = (arr, fn) => {
  return arr.map(fn)
}

如代码中的单元测试所示,只需要判断一下函数的返回结果即可。

import { myMap } from './myMap'
it('测试 map方法', () => {
  // 自定义方法
  const fn = (item) => item * 2
  expect(myMap([1, 2, 3], fn)).toEqual([2, 4, 6])
})

问题:那如果我需要更细致地去判断每一次调用传进去的函数是否是数组中的每一项,或者函数是否被调用了三次,那该怎么写单元测试?

import { myMap } from './myMap'
it('测试 map 方法', () => {
  // 通过jest.fn声明的函数可以被追溯
  const fn = jest.fn((item) => (item *= 2))
  expect(myMap([1, 2, 3], fn)).toEqual([2, 4, 6])
  // 调用3次
  expect(fn.mock.calls.length).toBe(3)
  // 每次函数返回的值是 2,4,6
  expect(fn.mock.results.map((item) => item.value)).toEqual([2, 4, 6])
  // 打印 fn.mock
  console.log(fn.mock)
})

打印结果
单元测试工具JEST入门——纯函数的测试_第4张图片

如果没有定义函数内部的实现,jest.fn() 会返回 undefined 作为返回值。

it('测试返回 undefined', () => {
  const myFn = jest.fn()
  const result = myFn({ a: 1 })
  // undefined
  console.log(result)
})

还可以设置返回值,定义内部实现或返回Promise对象。

describe('test', () => {
  it('测试设置返回值', () => {
    const myFn = jest.fn().mockReturnValue('fffff')
    expect(myFn()).toBe('fffff')
  })

  it('测试定义内部实现', () => {
    const myFn = jest.fn((a, b) => a * b)
    expect(myFn(10, 20)).toBe(200)
  })

  it('测试返回Promise对象', async () => {
    const asyncMock = jest.fn().mockResolvedValue('default')
    const result = await asyncMock()
    expect(Object.prototype.toString.call(asyncMock())).toBe('[object Promise]')
  })
})
  1. 模拟文件jest.mock()

jest.mock(moduleName, factory, options):用来mock一些模块或者文件

// service.js
import { getNames } from '../common/service'
export const searchNames = async (keyword) => {
    // 获取接口数据
    const namesList = await getNames()
    return namesList.filter((item) => item.includes(keyword))
}

// service.test.js
jest.mock('../common/service', () => ({
    getNames: jest.fn(() => ['Jack', 'Rose'])
}))

test('find target result', () => {
    const keyword = 'Jack'
    const result = searchNames(keyword)
    expect(result).toEqual(['Jack'])
})
  1. jest.spyOn()

jest.spyOn(object, methodName):用来创建一个被监视(spied)的函数,返回一个mock function,和 jest.fn 相似,但是能够追踪object[methodName]的调用信息。

// multiply.js
export const math = {
  multiply: (a, b) => {
    return a + b
  },
}

// multiply.test.js
import { math } from './multiply'

it('should spy on add function', () => {
  // 创建一个被监视的函数
  const spy = jest.spyOn(math, 'multiply')
  // 执行测试逻辑
  const result = math.multiply(2, 3)
  // 验证函数是否被调用
  expect(spy).toHaveBeenCalled()
  // 验证函数的返回值
  expect(result).toBe(5)
  // 清除监视器
  spy.mockRestore()
})

Jest 中的钩子函数

  • beforeAll(fn, timeout):所有测试之前执行。 设置一些在测试用例之间共享的全局状态。
  • afterAll(fn, timeout):所有测试执行完之后。 清理一些在测试用例之间共享的全局状态。
  • beforeEach(fn, timeout):每个测试实例之前执行。 重新设置一些全局状态在每个测试开始前。
  • afterEach(fn, timeout):每个测试实例完成之后执行。 清理一些在每个测试中创建的临时状态。

如果传入的回调函数返回值是 promise 或者 generator,Jest 会等待 promise resolve 再继续执行。
可选地传入第二个参数 timeout(毫秒) 指定函数执行超时时间。 The default timeout is 5 seconds。

// counter.ts
let counter = 0
export function increment() {
  counter++
}
export function decrement() {
  counter--
}
export function getCounter() {
  return counter
}

// counter.test.ts
describe('Counter functions', () => {
  // beforeAll: 在所有测试用例运行之前执行一次
  beforeAll(() => {
    console.log('Before all tests')
  })

  // afterAll: 在所有测试用例运行之后执行一次
  afterAll(() => {
    console.log('After all tests')
  })

  // beforeEach: 在每个测试用例运行之前执行
  beforeEach(() => {
    console.log('Before each test')
    increment() // 在每个测试用例前对计数器进行递增
  })

  // afterEach: 在每个测试用例运行之后执行
  afterEach(() => {
    console.log('After each test')
    decrement() // 在每个测试用例后对计数器进行递减
  })

  it('increments the counter', () => {
    console.log(getCounter())
  })

  it('decrements the counter', () => {
    console.log(getCounter())
  })
})

常用的断言方法

仅列举常用方法,更多内容详见:Jest 官网 API

.not 修饰符允许你测试结果不等于某个值的情况
.toHaveLength 可以很方便的用来测试字符串和数组类型的长度是否满足预期
.toThorw 能够让我们测试被测试方法是否按照预期抛出异常
.toMatch 传入一个正则表达式,它允许我们来进行字符串类型的正则匹配
.toContain 匹配对象中是否包含

检查一些特殊的值(null,undefined 和 boolean)
toBeNull 仅匹配 null
toBeUndefined 仅匹配 undefined
toBeDefined 与…相反 toBeUndefined
toBeTruthy 匹配 if 语句视为 true 的任何内容
toBeFalsy 匹配 if 语句视为 false 的任何内容

检查数字类型(number)
toBeGreaterThan 大于
toBeGreaterThanOrEqual 大于等于
toBeLessThan 小于
toBeLessThanOrEqual 小于等于
toBeCloseTo 用来匹配浮点数(带小数点的相等)

Mock Timer

假如现在有一个函数 afterTime,它的作用是在 2000ms 后执行传入的 callback

export const afterTime = (callback) => {
    console.log('准备计时')
    setTimeout(() => {
        console.log('时间到了')
        callback && callback()
    }, 2000)
}

如果不 Mock 时间,那么我们就得写这样的用例:

test('wait time', (callback) => {
    afterTime(() => {
        callback()
        expect(undefined)
    })
})

这样我们得死等 2000 毫秒才能跑这完这个用例,这非常不合理。

先用 jest.useFakeTimers Mock 定时器,并监听 setTimeout。mock一个 callback 函数,执行 afterTime 后, 对 callback 的调用做了一些断言。

describe('afterTime', () => {
    beforeAll(() => {
        // mock定时器
        jest.useFakeTimers()
    })

    test('fast', () => {
        // 监听setTimeout
        jest.spyOn(global, 'setTimeout')
        // mock一个函数
        const callback = jest.fn()
        // 执行afterTime
        afterTime(callback)
        // 此时断言这个函数没有被调用
        expect(callback).not.toHaveBeenCalled()
        // 快进时间
        jest.runAllTimers()
        // 断言这个函数被调用了
        expect(callback).toHaveBeenCalled()
    })
})

什么是快照测试?

直接上代码:

// searchName.ts
export const sum = (a, b) => a + b

// searchName.test.js
test('sum', () => {
    expect(sum(1, 3)).toMatchInlineSnapshot()
})

运行npm test后,发现:代码中自动出现了 expect(sum(1, 3)).toMatchInlineSnapshot(4)

但是当我们随意更改sum方法的参数时,你会发现报错了:
在这里插入图片描述
它需要我们执行npm test -- -u来更新它。

明明代码没有问题,只是修改了传参,测试却出错了,这就是测试中的“假错误”。虽然普通的单测中也有可能会出现“假错误”,但是快照测试中出现的概率更高,这也是很多人不信任快照测试的主要原因。

当我们改成使用toMatchSnapshot方法后,发现当前文件夹下出现了.snap文件,打开可以看到toMatchSnapshot将结果放在了这个文件里。
单元测试工具JEST入门——纯函数的测试_第5张图片

对于那种输出很复杂,而且不方便用 expect 做断言时,快照测试是一个好方法

快照测试的思想:先执行一次测试,把输出结果记录到 .snap 文件,以后每次测试都会把输出结果和 .snap 文件做对比。

快照失败有两种可能:

  1. 业务代码有问题,要排查 Bug
  2. 新增功能改变了原有结构,要用 npx jest --updateSnapshot 更新当前快照(假错误)

现实中更多的情况是既在重构又要加新需求,如果开发者滥用快照测试,并生成很多大快照, 那么最终的结果是没有人再相信快照测试。一遇到快照测试不通过,都不愿意探究失败的原因,而是选择更新快照来 “糊弄一下”。

要避免这样的情况,需要做好两点:

  • 生成小快照。 只取重要的部分来生成快照,必须保证快照是能让你看懂的
  • 合理使用快照

前端单元测试策略与原则

“你的测试与你的软件使用方式越相似,它们就越能给你带来信心。”

模块依赖和调用时的方法,都应该像软件模块真正被使用的时候一样。

思考:是否可以让测试来驱动开发?
先写测试,再写业务代码,当所有测试用例都通过后,你的业务代码也就实现完成了。
单元测试工具JEST入门——纯函数的测试_第6张图片

:先写一个期望测试,得到“失败”
✅:开发代码,使测试通过
:进行重构,使其可维护性更高

适用场景:

  1. 纯函数。不管逻辑是否简单,我们都很容易想到输入与输出,那么我们可以先写测试用例,覆盖90%的场景。
  2. UI交互。Mock需要的HTTP请求,用测试模拟用户操作,再去实现业务逻辑。
  3. 修BUG。用一个case来模拟复现问题,进行修复。

其实我们平常打的log,不仅log出来后需要删掉,而且最最重要的是只能测试一两种case,我们还需要手动刷新页面,进行肉眼找茬。从执行到肉眼看结果,这就是一种手动测试。

// 用测试用例来描述这个需求:
import objToSearchStr from 'utils/objToSearchStr'
describe('objToSearchStr', () => {
  test('可以将对象转化成查询参数字符串', () => {
    expect(objToSearchStr({ a: '1', b: '2' })).toEqual('a=1&b=2')
  })
})

// 边看业务输入输出边实现代码逻辑:
const objToSearchStr = (obj: Record<string, string | number>) => {
  // ['a=1', 'b=2']
  const strPairs: string[] = []

  Object.entries(obj).forEach((keyValue) => {
    const [key, value] = keyValue // [a, 1]
    const pair = key + '=' + String(value) // a=1
    strPairs.push(pair)
  }, [])

  // a=1&b=2
  return strPairs.join('&')
}
export default objToSearchStr

你的代码的易测性也就代表着代码的可维护性。
反思:在保证单元测试独立性的前提下,什么样的模块才是符合【职责单一】原则的?

React 测试库
官网:React Testing Library
下载:npm install --save-dev @testing-library/react
文档:引入 React(纯配置)

⭕ 你可能会遇到的问题

  1. 使用 jest.mock() 时,文件路径找不到
    单元测试工具JEST入门——纯函数的测试_第7张图片

✅ 解决:在 jest.config.js 文件中配置路径中配置模块路径的别名:

{
  "jest": {
    "moduleNameMapper": {
      "^@/(.*)$": "/src/$1"
    }
  }
}
  1. 在使用 jest.spyOn() 时报错:Property xxx does not exist in the provided object
    原因:jest.spyOn() 用于监视对象的属性或方法,但在示例中 multiply 并不是一个对象,而是一个直接导出的函数。
    单元测试工具JEST入门——纯函数的测试_第8张图片

✅ 解决:将 multiply 放置在一个对象上,然后导出这个对象

// 错误代码
export const multiply = (a, b) => {
  return a + b
}

// 改为:
export const math = {
  multiply: (a, b) => {
    return a + b
  },
}
  1. 测试异步函数时报错 regeneratorRuntime is not defined
    原因:这是因为 @babel/preset-env 不支持 async await 导致的。
    单元测试工具JEST入门——纯函数的测试_第9张图片

✅ 解决:要对 babel 配置进行增强,可以安装 @babel/plugin-transform-runtime 这个插件解决

// 安装依赖
npm install --save-dev @babel/plugin-transform-runtime

// .babelrc.js 配置
"plugins": ["@babel/plugin-transform-runtime"]

Demo

// searchName.ts
import { getNames } from "../common/service";

export const searchNames = (keyword) => {
    const results = getNames().filter((item) => item.includes(keyword));
    return results.length > 3 ? results.slice(0, 3) : results;
};

export const sum = (a, b) => a + b

export const afterTime = (callback) => {
    console.log('准备计时')
    setTimeout(() => {
        console.log('时间到了')
        callback && callback()
    }, 2000)
}
// searchName.test.js
import { searchNames, sum, afterTime } from './searchName'
import { getNames } from '../common/service'

jest.mock('../common/service', () => ({
    getNames: jest.fn()
}))

test('sum', () => {
    expect(sum(1, 3)).toMatchInlineSnapshot(`4`)
})

// mockImplementation:接受应该用作模拟实现的函数,当调用模拟时,实现也会被执行

test('should return search empty result', () => {
    const keyword = 'Rose'
    getNames.mockImplementation(() => ['Jack'])
    const result = searchNames(keyword)
    expect(result).toEqual([])
})

test('find target result', () => {
    const keyword = 'Jack'
    getNames.mockImplementation(() => ['Jack', 'Rose'])
    const result = searchNames(keyword)
    expect(result).toEqual(['Jack'])
})

test('not return more than 3 result', () => {
    const keyword = 'Jack'
    getNames.mockImplementation(() => ['Jack1', 'Jack2', 'Jack3', 'Jack4'])
    const result = searchNames(keyword)
    expect(result).toHaveLength(3)
})

test('should handle null or undefined as input', () => {
    expect(searchNames(null)).toEqual([])
    expect(searchNames(undefined)).toEqual([])
})

describe('afterTime', () => {
    test('wait time', (callback) => {
        afterTime(() => {
            callback()
            expect(undefined)
        })
    })

    beforeAll(() => {
        // mock定时器
        jest.useFakeTimers()
    })

    test('fast', () => {
        // 监听setTimeout
        jest.spyOn(global, 'setTimeout')
        // mock一个函数
        const callback = jest.fn()

        afterTime(callback)
        // 断言这个函数没有被调用
        expect(callback).not.toHaveBeenCalled()
        // 快进时间
        jest.runAllTimers()
        // 断言这个函数被调用了
        expect(callback).toHaveBeenCalled()
        expect(setTimeout).toHaveBeenCalledTimes(1)
    })
})

你可能感兴趣的:(单元测试,单元测试,Jest)