对于前端来说,测试主要是对HTML、CSS、JavaScript进行测试,以确保代码的正常运行。
常见的测试:单元测试、集成测试、端到端的测试(e2e)
测试方式:人工测试、自动测试
bug发现在开发阶段成本很低,如果发现在生产环境成本很高。
➢ 技术的角度:有效的提高代码的健壮性,有效的增加代码的可维护性,对于后期的代码重构是必要条件。
➢ 团队的角度:可以有效的减少测试成本,维护成本。
单元测试是前期慢,后期快的工作,做好单元测试可以大大缩短后期的测试和改 bug 的时间。
单元测试的好处:
❌ 因为单元测试有这些好处,所以要做单元测试??
瀑布流开发:项目的每个阶段都会带来种种需求不匹配的情况,导致交付的最终价值可能不是客户所需要的。
敏捷开发:迭代式的软件开发流程,在每一个小的迭代周期内,走过完整的流程,并及时发布一个可用版本给用户,风险提前暴露,用户及时反馈,快速进入下一个迭代,降低需求不匹配带来的风险。
敏捷是为了更快交付有价值的、可工作的软件。
市场变化多端,我们需要及时推出产品,从而验证产品在市场上的用户反馈。
想法付诸行动 → 业务取得成效:决策、设计、开发、测试、发布、反馈… …
戴明环:是全面质量管理的思想基础和方法,将质量管理分为四个阶段,即Plan(计划)、Do(执行)、Check(检查)和 Action(处理)。
不断缩短反馈周期,提高反馈速度,才能减少不必要的浪费。
Q:“敏捷交付最重要的是什么?” A:“快。快速迭代、持续交付用户价值。”
Q:“对我们开发者有什么影响?” A:“如果我们不写单元测试,我们就快不起来。”
每次开发上线,团队都要投入人力来进行手工测试,其中也包括你自己
因为没有测试,不敢随意重构,从而导致代码逐渐腐化,随着项目的进展,代码越来越复杂,复杂的代码会导致开发速度降低,从而陷入死循环。代码越来越烂、开发越来越慢、BUG越来越多。
除此之外,我们整个项目的人员会流动,应用会变得越来越复杂,功能会变得越来越多,那么人员一定会流动,需求一定会增加,直到整个项目没有一个人可以了解到应用的所有功能,那么对软件进行修改的成本就会非常高。
如果试图依赖人工方式来应对快速变化的市场,挑战是非常高的。而单元测试是自动化的,能够给我们极快的反馈速度。
所以,单元测试是非常有必要的!
目前用的最多的前端单元测试框架主要有 Mocha、Jest,但推荐使用 Jest,因为 Jest 和 Mocha 相比,无论从 github stats & issues 量,npm下载量相比,都有明显优势。
详见:github stats 以及 npm 下载量的实时数据
Jest 是 Facebook 开发的一款 JavaScript 测试框架,在 Facebook 内部广泛用来测试各种 JavaScript 代码。
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
当我想用 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%
两种方法:
npm test --coverage
module.exports = {
// 是否显示覆盖率报告
collectCoverage: true,
// 告诉 jest 哪些文件需要经过单元测试测试
collectCoverageFrom: ['src/utils/**/*'],
}
参数名 | 含义 | 说明 |
---|---|---|
% Stmts | 语句覆盖率 | 是否每条语句都执行了? |
% Branch | 分支覆盖率 | 是否每个情况分支都执行了?(例如 if-else) |
% Funcs | 函数覆盖率 | 是否每个函数都调用了? |
% Lines | 行覆盖率 | 是否每一行都执行了? |
Uncobered Line | 未覆盖到的行 | 未覆盖到的行数 |
为什么Jest需要模拟函数?
世界软件开发大师Martin Fowler根据是否依赖其他模块将单元测试分为了社交型测试单元和独立型测试单元。
在测试中我们特别需要注意不同模块之间的依赖:
这些依赖我们需要“扮演者”,也就是模拟函数
这里我们主要了解Jest 中的三个与 Mock 函数相关的API,分别是 jest.fn()、jest.mock()、jest.spyOn()。
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.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]')
})
})
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'])
})
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()
})
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 用来匹配浮点数(带小数点的相等)
假如现在有一个函数 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
将结果放在了这个文件里。
对于那种输出很复杂,而且不方便用 expect 做断言时,快照测试是一个好方法
快照测试的思想:先执行一次测试,把输出结果记录到 .snap 文件,以后每次测试都会把输出结果和 .snap 文件做对比。
快照失败有两种可能:
现实中更多的情况是既在重构又要加新需求,如果开发者滥用快照测试,并生成很多大快照, 那么最终的结果是没有人再相信快照测试。一遇到快照测试不通过,都不愿意探究失败的原因,而是选择更新快照来 “糊弄一下”。
要避免这样的情况,需要做好两点:
“你的测试与你的软件使用方式越相似,它们就越能给你带来信心。”
模块依赖和调用时的方法,都应该像软件模块真正被使用的时候一样。
思考:是否可以让测试来驱动开发?
先写测试,再写业务代码,当所有测试用例都通过后,你的业务代码也就实现完成了。
:先写一个期望测试,得到“失败”
✅:开发代码,使测试通过
:进行重构,使其可维护性更高
适用场景:
其实我们平常打的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(纯配置)
✅ 解决:在 jest.config.js 文件中配置路径中配置模块路径的别名:
{
"jest": {
"moduleNameMapper": {
"^@/(.*)$": "/src/$1"
}
}
}
xxx
does not exist in the provided object✅ 解决:将 multiply 放置在一个对象上,然后导出这个对象
// 错误代码
export const multiply = (a, b) => {
return a + b
}
// 改为:
export const math = {
multiply: (a, b) => {
return a + b
},
}
✅ 解决:要对 babel 配置进行增强,可以安装 @babel/plugin-transform-runtime 这个插件解决
// 安装依赖
npm install --save-dev @babel/plugin-transform-runtime
// .babelrc.js 配置
"plugins": ["@babel/plugin-transform-runtime"]
// 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)
})
})