jest 快速上手
jest 是一个包含了最佳实践、测试运行器、CLI、断言库、存根库、模块模拟库和覆盖率的js测试框架。
常见数据类型的匹配器
jest 提供了丰富的匹配器,用来判断值是否符合预期。
对象
toHaveProperty('name'),判断对象是否有某个属性
toBe 引用比较 或者
===
toEqual 值比较,值相同,就相等
toBeNull 检查
null
toBeDefined(只要有值,即通过测试)、toBeUndefined 检查
undefined
数组、set
toContain 数组、set 严格匹配
toContainEqual 数组、set 值相等匹配
字符串
toContain 包含字符串
数字
BeCloseTo 浮点数判断相等
toBeGreaterThan 大于
布尔值
toBeTruthy 真值
toBeFalsy 假值
技巧:少用 toBe(true),因为当测试失败,它给不出更加具体的错误提示,只是 true 或者 false,会难以定位错误。
函数匹配器
抛出异常
const testFn = () => {
throw new Error('test')
}
describe('函数抛出方法匹配', () => {
test('should throw error', () => {
expect(testFn).toThrow()
expect(testFn).not.toThrow(/a/)
expect(testFn).toThrow('test')
})
})
jest 匹配器还有很多,可以参考官方文档:Expect。
常用函数钩子
beforeEach
在每个测试之前运行。 afterEach
在每个测试之后运行。 beforeAll
所有测试运行运行之前运行。 afterAll
所有测试运行运行之后运行。
钩子函数的作用域:钩子函数的作用域和 describe
的作用域一样,只在当前 describe
有效。
顶级的 beforeEach 会比 describe 中的 beforeEach 执行的更早。
beforeAll(() => {
console.log('beforeAll ---1')
})
afterAll(() => {
console.log('afterAll ---1')
})
beforeEach(() => {
console.log('beforeEach ----- 1')
})
afterEach(() => {
console.log('afterEach ----- 1')
})
test('city database has Vienna', () => {
expect('Vienna').toBeTruthy()
})
describe('真值假值测试', () => {
beforeAll(() => {
console.log('beforeAll')
})
afterAll(() => {
console.log('afterAll')
})
beforeEach(() => {
console.log('beforeEach')
})
afterEach(() => {
console.log('afterEach')
})
test('真值?', () => {
expect(true).toBeTruthy()
expect('0').toBeTruthy()
expect(1).toBeTruthy()
expect(100).toBeTruthy()
expect({}).toBeTruthy()
expect([]).toBeTruthy()
})
})
// beforeAll ---1
// beforeEach ----- 1
// afterEach ----- 1
// beforeAll
// beforeEach ----- 1
// beforeEach
// afterEach
// afterEach ----- 1
// afterAll
// afterAll ----- 1
describe 之间互不影响。
describe('describe 1', () => {
beforeAll(() => {
console.log('beforeAll ---1')
})
afterAll(() => {
console.log('afterAll ---1')
})
beforeEach(() => {
console.log('beforeEach ----- 1')
})
afterEach(() => {
console.log('afterEach ----- 1')
})
test('city database has Vienna', () => {
expect('Vienna').toBeTruthy()
})
})
describe('真值假值测试', () => {
beforeAll(() => {
console.log('beforeAll')
})
afterAll(() => {
console.log('afterAll')
})
beforeEach(() => {
console.log('beforeEach')
})
afterEach(() => {
console.log('afterEach')
})
test('真值?', () => {
expect(true).toBeTruthy()
expect('0').toBeTruthy()
expect(1).toBeTruthy()
expect(100).toBeTruthy()
expect({}).toBeTruthy()
expect([]).toBeTruthy()
})
// beforeAll ---1
// beforeEach ----- 1
// afterEach ----- 1
// afterAll ---1
// beforeAll
// beforeEach
// afterEach
// afterAll
})
describe 的回调先于所有测试之前运行。
describe('describe outer', () => {
console.log('describe outer-a')
describe('describe inner 1', () => {
console.log('describe inner 1')
test('test 1', () => console.log('test 1'))
})
console.log('describe outer-b')
test('test 2', () => console.log('test 2'))
describe('describe inner 2', () => {
console.log('describe inner 2')
test('test 3', () => console.log('test 3'))
})
console.log('describe outer-c')
})
只运行一条测试:
describe('describe outer', () => {
console.log('describe outer-a')
describe('describe inner 1', () => {
console.log('describe inner 1')
test('test 1', () => console.log('test 1'))
})
console.log('describe outer-b')
test.only('test only', () => console.log('test only'))
test('test 2', () => console.log('test 2'))
describe('describe inner 2', () => {
console.log('describe inner 2')
test('test 3', () => console.log('test 3'))
})
console.log('describe outer-c')
})
test.only
表示只运行这个测试用例,其他测试用例都会被跳过, 在测试之间相互影响时,很有用。
it 是 test 的别名 xit 表示跳过这个测试用例,在跳过某些正在或者不想要测试的用例时特别有用。
第一个参数是测试用例的名字,在同一个测试套件里要唯一,取名字最好见名知义。
更多阅读:How to run, ignore or skip Jest tests, suites and files
测试异步代码
当你有以异步方式运行的代码时,Jest 需要知道当前它测试的代码是否已完成,然后它可以转移到另一个测试。
回调类型的异步
使用 done
有一函数:
import axios from 'axios'
function asyncApiCallback(callback: any) {
axios.get('http://localhost:3001/posts').then(res => callback(res.data))
}
测试用例:
it('asyncApiCallback', done => {
asyncApiCallback((data: any) => {
expect(data).toEqual(dbJson.posts)
done()
})
})
promise 类型的异步
使用 async await
有一函数:
function asyncApiPromise() {
return axios.get('https://jsonplaceholder.typicode.com/todos/120').then(res => res.data)
}
测试用例:
it('asyncApiPromise async', async () => {
const res = await asyncApiPromise()
expect(res.id).toEqual(120)
})
也可以使用 done
it('asyncApiPromise', done => {
asyncApiPromise().then((data: any) => {
expect(data.id).toEqual(120)
done()
})
})
还可以使用 return
的方式:
it('asyncApiPromise2', () => {
expect.assertions(1)
return asyncApiPromise().then((data: any) => {
expect(data.id).toEqual(120)
})
})
推荐的实践:回调类型的使用
done
,promise 类型的使用async await
,且使用expect.assertions(1)
保证有一个断言,防止忘记写断言。
模拟依赖
有一个函数:
function forEach(items, callback) {
for (let index = 0; index < items.length; index++) {
callback(items[index])
}
}
callback 是用户使用这个函数给的具体实现,希望测试 forEach,就需要模拟一个实现,测试函数的行为:入参、调用次数、返回值等。
describe('forEach', () => {
test('forEach', () => {
const mockCallback = jest.fn(x => 42 + x)
forEach([0, 1], mockCallback)
// 此 mock 函数被调用了两次
expect(mockCallback.mock.calls.length).toBe(2)
// 第一次调用函数时的第一个参数是 0
expect(mockCallback.mock.calls[0][0]).toBe(0)
// 第二次调用函数时的第一个参数是 1
expect(mockCallback.mock.calls[1][0]).toBe(1)
// 第一次函数调用的返回值是 42
expect(mockCallback.mock.results[0].value).toBe(42)
})
})
mock 属性
所有 mock 函数都有一个 .mock
属性,它保存了函数调用情况:是否被调用、调用次数、调用参数、返回值、调用顺序和this 等信息。
const myMock1 = jest.fn()
const a = new myMock1()
// this 实例
console.log(myMock1.mock.instances) // [ mockConstructor { name: 'a' } ]
// > [ <a> ]
const myMock2 = jest.fn()
const b = {}
const bound = myMock2.bind(b)
bound()
// this 实例
console.log(myMock2.mock.contexts) // [ { name: 'b' } ]
// > [ <b> ]
mock.calls
被调用的次数mock.calls[0][0]
第一次被调用的第一个参数mock.results[0].value
第一次被调用的返回值mock.lastCall[0]
最后一次调用的第一个参数
但是这种用法,挺繁琐,下面是更简单的用法。
mock 函数返回值
直接模拟返回值,可跳过中间操作,直接观察组件的表现。
const myMock = jest.fn()
console.log(myMock())
// > undefined
myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true)
console.log(myMock(), myMock(), myMock(), myMock())
//### x true true // 返回值保留著最后一个
const filterTestFn = jest.fn()
// Make the mock return `true` for the first call,
// and `false` for the second call
filterTestFn.mockReturnValueOnce(true).mockReturnValueOnce(false)
const result = [1, 12].filter(num => filterTestFn(num))
console.log(result)
// > [11]
console.log(filterTestFn.mock.calls[0][0]) // 11
console.log(filterTestFn.mock.calls[1][0]) // 12
mock 模块
有一个模块:
import axios from 'axios'
class Users {
static all() {
return axios.get('/users.json').then(resp => resp.data)
}
}
export default Users
测试这个接口调用,也许接口还没写好,也许接口很脆弱(不能多次调用),就需要我们模拟接口的返回值,提供一些假的数据。
import axios from 'axios'
import Users from './users'
jest.mock('axios')
test('should fetch users', () => {
const users = [{
name: 'Bob'
}]
const resp = {
data: users
}
axios.get.mockResolvedValue(resp) // NOTE 模拟 promise resolve
// or you could use the following depending on your use case:
// axios.get.mockImplementation(() => Promise.resolve(resp))
return Users.all().then(data => expect(data).toEqual(users))
})
如何模拟浏览器的环境
jest 是在 node 环境下运行的,没有浏览器的环境,所以我们需要模拟浏览器的环境。
jsdom 是一个模拟浏览器环境的库,可以在 node 环境下运行。
- 使用 jsdom 模拟浏览器环境
jest.config.js
的 testEnvironment: 'jsdom'
,开启 jsdom 模拟浏览器环境。
- 自己实现一个 localStorage
可参考这个教程
js 函数的测试
js 函数的测试,是最简单的测试,只需要调用函数,然后断言函数的返回值是否符合预期即可。
使用的环境是 jest + ts + react + sass 测试环境,封装一个浏览器存储函数,对其进行测试。
// src/utils/storage.ts
type StorageType = 'local' | 'session'
function set<V = unknown>(key: string, value: V, type: StorageType = 'session') {
const jsonValue = JSON.stringify(value)
if (type === 'local') {
localStorage.setItem(key, jsonValue)
} else if (type === 'session') {
sessionStorage.setItem(key, jsonValue)
} else {
throw new Error('不支持的存储类型')
}
}
function get(key: string, type: StorageType = 'session') {
if (type === 'local') {
try {
let value = JSON.parse(localStorage.getItem(key) !)
return value
} catch (error) {
return localStorage.getItem(key)
}
} else if (type === 'session') {
try {
let value = JSON.parse(sessionStorage.getItem(key) !)
return value
} catch (error) {
return sessionStorage.getItem(key)
}
} else {
throw new Error('不支持的存储类型')
}
}
function clear(type: StorageType = 'session') {
if (type === 'local') {
localStorage.clear()
} else if (type === 'session') {
sessionStorage.clear()
} else {
throw new Error('不支持的存储类型')
}
}
function remove(key: string, type: StorageType = 'session') {
if (type === 'local') {
localStorage.removeItem(key)
} else if (type === 'session') {
sessionStorage.removeItem(key)
} else {
throw new Error('不支持的存储类型')
}
}
const storage = {
get,
set,
clear,
remove,
}
export {
storage
}
测试代码:
// src/utils/storage.test.ts
import {
storage
} from './storage'
describe('storage', () => {
describe('默认是 sessionStorage', () => {
beforeEach(() => {
sessionStorage.clear()
})
it('storage.set', () => {
const value = 'hello'
const key = 'sessionKey'
storage.set(key, value)
expect(sessionStorage.getItem(key)).toEqual(JSON.stringify(value))
const key2 = 'sessionKey2'
const value2 = {
name: 'zqj',
}
storage.set(key2, value2)
expect(storage.get(key2)).toEqual(value2)
})
it('storage.get', () => {
const value = JSON.stringify('hello')
const key = 'sessionKey'
sessionStorage.setItem(key, value)
expect(sessionStorage.getItem(key)).toEqual(value)
expect(storage.get(key)).toEqual(JSON.parse(value))
})
it('storage.remove', () => {
const key = 'sessionKey'
const value = ['hello']
storage.set(key, value)
expect(storage.get(key)).toEqual(value)
storage.remove(key)
expect(storage.get(key)).toBeNull()
})
it('storage.clear', () => {
const key = 'sessionKey'
const value = ['hello']
storage.set(key, value)
const key2 = 'sessionKey2'
const value2 = {}
storage.set(key2, value2)
expect(storage.get(key)).toEqual(value)
expect(storage.get(key2)).toEqual(value2)
storage.clear()
expect(storage.get(key)).toBeNull()
expect(storage.get(key2)).toBeNull()
})
})
describe('设置 localStorage', () => {
beforeEach(() => {
localStorage.clear()
})
it('storage.set', () => {
const value = 'hello'
const key = 'sessionKey'
storage.set(key, value, 'local')
expect(localStorage.getItem(key)).toEqual(JSON.stringify(value))
const key2 = 'sessionKey2'
const value2 = {
name: 'zqj',
}
storage.set(key2, value2, 'local')
expect(storage.get(key2, 'local')).toEqual(value2)
})
it('storage.get', () => {
const value = JSON.stringify('hello')
const key = 'sessionKey'
localStorage.setItem(key, value)
expect(localStorage.getItem(key)).toEqual(value)
expect(storage.get(key, 'local')).toEqual(JSON.parse(value))
})
it('storage.remove', () => {
const key = 'sessionKey'
const value = ['hello']
storage.set(key, value, 'local')
expect(storage.get(key, 'local')).toEqual(value)
storage.remove(key, 'local')
expect(storage.get(key, 'local')).toBeNull()
})
it('storage.clear', () => {
const key = 'sessionKey'
const value = ['hello']
storage.set(key, value, 'local')
const key2 = 'sessionKey2'
const value2 = {}
storage.set(key2, value2, 'local')
expect(storage.get(key, 'local')).toEqual(value)
expect(storage.get(key2, 'local')).toEqual(value2)
storage.clear('local')
expect(storage.get(key, 'local')).toBeNull()
expect(storage.get(key2, 'local')).toBeNull()
})
})
})
控制台的测试报告显示,测试通过,但是 storage.ts
的代码语句覆盖率、分支覆盖率、行级覆盖率都不是100%。
使用 liver Server
扩展打来测试报告,看看哪些代码没有覆盖到:
粉红色表示未覆盖的语句,黄色表示未覆盖的分支。
行号旁边的 nx,表示这行代码执行过几次。
是否生成测试报告和统计哪些文件的覆盖率,可从
jest.config.js
配置:
{
collectCoverage: true, // 是否生成测试报告
collectCoverageFrom: [ // 统计哪些文件的覆盖率
'./src/**/*.{js,jsx,ts,tsx}',
'!./src/apis/**',
'!./src/**/*/index.{ts,tsx,js,jsx}',
'!./src/**/*.d.ts',
'!./src/App.tsx',
'!./src/main.tsx',
'!**/node_modules/**',
],
}
报告显示,几个异常分支没有覆盖到,我们来补充测试用例,再嵌套一个 describe('设置错误的 type')
:
describe('设置错误的 type', () => {
it('storage.set throw', () => {
expect(() => storage.set('key', 'error', 'errorType'
as any)).toThrowError()
})
it('storage.get throw', () => {
storage.set('key', 'error')
expect(() => storage.get('key', 'errorType'
as any)).toThrowError()
const value = 'error'
sessionStorage.setItem('key2', value)
expect(storage.get('key2')).toBe(value)
// 不是一个合法的 json 字符串
const valueObj = '{name: "zqj"}}'
localStorage.setItem('key2', valueObj)
// 部署合法的 JSON 字符串,返回原字符串,不进行 JSON.parse
expect(storage.get('key2', 'local')).toBe(valueObj)
})
it('storage.remove throw', () => {
storage.set('key', 'error')
expect(() => storage.remove('key', 'errorType'
as any)).toThrowError()
})
it('storage.clear throw', () => {
expect(() => storage.clear('errorType'
as any)).toThrowError()
})
})
再次运行测试,测试通过,测试覆盖率都是 100%。✅ js 函数的测试,最好是 100% 的覆盖率。
测试技巧1 --- 先正常,后异常
先测试代码的正常分支,再测试异常分支,更加有条理。
测试技巧2 --- 同类用例合并
一个 describe
是一个测试套件,一个 it
是一个测试用例,一个 describe
可以嵌套多个 it
,通常把同类型的测试用例放在一个 describe
中,更加有条理,比如上面的 describe('默认是 sessionStorage')
,都是 sessionStorage
的测试用例。
测试技巧3 --- 一个文件最好只有一个测试套件
上面的三个 describe
放在一个 describe('storage')
中,可以使用 jest Runner
一键执行所有用例。
测试技巧4 --- 给测试用例起一个好名字
it 的第一个参数是测试用例的名字,在同一个测试套件里要唯一,取名字最好见名知义。
测试技巧5 --- 使用 jest 钩子在用例执行之前或者之后执行一些操作
beforeEach
在每个测试用例执行之前执行,可以在这里做一些初始化操作,比如上面的 beforeEach(() => { localStorage.clear() })
,在每个测试用例执行之前清空 localStorage。
afterEach
在每个测试用例执行之后执行。 beforeAll
在所有测试用例执行之前执行。 afterAll
在所有测试用例执行之后执行。
钩子函数的作用域:钩子函数的作用域和 describe
的作用域一样,只在当前 describe
有效。
小结
- 学习了常用的匹配器
- 学习了测试异步代码的方法
- 学习如何模拟依赖
- 使用钩子函数组织测试用例
- 封装了 storage 工具类,学习了如何测试 js 函数