前端必备的测试
DellLee 老师的 前端要学的测试课 从Jest入门到TDD/BDD双实战学习记录。
知识点
Jest 基础
基础API
、异步测试
、Mock技巧
、快照
、timer测试
、Dom测试
实现项目
Vue
、Vue-test-utils
、React
、Enzyme
、TDD+单元测试
、BDD+集成测试
初识测试
1 | // math.js |
自动化框架:Jest
优点:性能、功能、易用性、速度快、Api简单、易配置、隔离性好、监控模式、IDE整合、Snapshot、多项目并行、覆盖率、Mock丰富
简单配置
1 | 项目内部调用 jest |
jest.config.js
1 | // For a detailed explanation regarding each configuration property, visit: |
babelrc
jest 未配置转换时,默认只支持 commonjs 语法。
1 | { |
jest转换(commonjs -> es module)大概工作原理:
- npm run jest
- jest(babel-jest)jest 内部的
- 检测是有 babel-core
- 拿到 .babelrc 配置
- 再运行测试之前,结合 babel 把代码做一次转化
- 运行转化过的测试用例
常用匹配器(Matchers)
数字
.toBe()
.toEqual(value)
.toBeGreaterThan(number)
.toBeGreaterThanOrEqual(number)
.toBeLessThan(number)
.toBeLessThanOrEqual(number)
.toBeCloseTo(number,numDigits?)
第二个参数为精度,代表几位小数点,默认为2位
.toBeNaN()
1 | // 数字相关 |
真假
toBeNull
只匹配null
toBeUndefined
只匹配undefined
toBeDefined
与toBeUndefined
相反toBeTruthy
匹配任何if
语句为真toBeFalsy
匹配任何if
语句为假
.toBeNull()
.toBeUndefined()
.toBeDefined()
.toBeTruthy()
.toBeFalsy()
在JavaScript中,有六个falsy值:false
,0
,''
,null
,undefined
,和NaN
。其他一切都是真实的。
1 | // 真假相关 |
否定
.not
1 | test('匹配器:not-不是xx', () => { |
expect.not.arrayContaining(array)
匹配不是接收值的子集
1 | describe('not.arrayContaining', () => { |
expect.not.objectContaining(object)
匹配不包含某个对象
1 | describe('not.objectContaining', () => { |
expect.not.stringContaining(string)
匹配不包含某个字符串
1 | describe('not.stringContaining', () => { |
expect.not.stringMatching(string|regexp)
同上,不过参数可以是正则表达式
1 | describe('not.stringMatching', () => { |
字符串
.toMatch(regexpOrString
)
1 | // 字符串 |
expect.stringContaining(string)
匹配包含某个字符串
expect.stringMatching(string|regexp)
匹配字符串,可用正则
1 | describe('stringMatching in arrayContaining', () => { |
数组
expect.toContain()
1 |
|
expect.arrayContaining(array)
匹配子集
1 | describe('arrayContaining', () => { |
对象
objectContaining(object)
匹配任何递归预期属性的接收对象
1 | test('测试onPress函数回调参数匹配对象', () => { |
.toHaveProperty(keyPath,value?)
检查对象中各种属性存在和值,第二个参数是可选的
1 | // Object containing house features to be tested |
.toMatchObject(object)
检查对象的属性的子集相匹配
1 | const houseForSale = { |
类
.toBeInstanceOf(Class)
检查对象是一个类的实例
1 | class A {} |
异常
.toThrow(error?)
1 | const throwNewErrorFunc = () => { throw new Error('this is a new error') } |
任意
expect.anything()
匹配除了 null
、undefined
的任意值,可以它使用在 toEqual
或者 toBeCalledWith
里面替代文字值
1 | test('map 遍历一个非空的参数',()=>{ |
expect.any(constructor)
匹配任意构造器生成的实例
1 | function randocall(fn){ |
.toHaveLength(number)
检查对象有个 length 属性并将设为某一数值。这对于检查数组或字符串大小特别有用。
1 | expect([1, 2, 3]).toHaveLength(3); |
.toContain(item)
检查项目在数组或者字符串是否是另一个字符串的子串
1 | test('the flavor list contains lime', () => { |
.toContainEqual(item)
检查具有特定结构和值的元素是否包含在数据中
1 | it('test contain', () => { |
异步相关
expect.assertions()
匹配在测试用例里面使用断言的次数。确保在异步函数为了确保在回调函数里面断言被调用特别有用。
1 | test('异步调用所有回调',()=>{ |
expect.hasAssertions()
匹配在测试用例里面至少使用一次断言
.resolves
使用 resolves
解开 fulfilled promise,如果 reject promise,则断言失败
1 | test('resolves to lemon', () => { |
测试是异步的,需要告诉 jest 等待返回解开的断言
也可以用 async/await
结合 .resolves
1 | test('resolves to lemon', async () => { |
.rejects
使用 rejects
解开 rejected promise,如果 fulfilled promise,则断言失败
1 | test('rejects to octopus', async () => { |
测试是异步的,需要告诉 jest 等待返回解开的断言
同样使用 async/await
结合 .rejects
1 | test('rejects to octopus', async () => { |
函数调用
.toHaveBeenCalled()|.toBeCalled()
确保模拟功能得到调用
1 | function drinkAll(cb, flavour) { |
.toHaveBeenCallTimes(number)|.toBeCalledTimes(number)
确保模拟功能得到调用次数与指定数字一致
1 | test('drinkEach drinks each drink', () => { |
.toHaveBeenCalledWith(arg1,arg2,…)|.toBeCalledWith(arg1,arg2,…)
确保模拟功能被调用的具体参数
1 | function calledWithArg(cb) { |
.toHaveBeenLastCalledWith(arg1,arg2,…)|.lastCalledWith(arg1, arg2, …)
确保模拟功能被最后一次调用的具体参数
.toHaveBeenNthCalledWith(nthCall, arg1, arg2, ….)|.nthCalledWith(nthCall, arg1, arg2, ….)
确保模拟功能多次调用的顺序
1 | test('drinkEach drinks each drink', () => { |
n 必须是从 1开始的正整数
.toHaveReturned()|.toReturn()
测试模拟函数成功返回(即没有抛出错误)至少一次
1 | test('test calledWithArg', () => { |
.toHaveReturnedTimes(number)|.toReturnTimes(number)
确保模拟函数返回成功的次数,抛出错误的模拟函数的任何调用都不计入函数返回的次数
1 | test('drink returns twice', () => { |
.toHaveReturnedWith(value)|.toReturnWith(value)
确保模拟函数返回特定的值
1 | test('test Return 123', () => { |
.toHaveLastReturnedWith(value)|.lastReturnedWith(value)
确保模拟函数最后一次返回特定的值
.toHaveNthReturnedWith(nthCall, value)|.nthReturnedWith(nthCall, value)
确保模拟函数第n次调用返回特定的值
第n个参数必须是从1开始的正整数。
自定义扩展
expect.extend()
pass
表示是否有匹配,message
提供一个没有参数的函数,在出现错误的情况下返回消息。当pass:false
,message
返回 expect(x).matcher()
失败的错误信息,pass:true
,message
返回当expect(x).not.matcher()
失败时的错误消息
1 | // expect.extend(matchers) |
异步扩展,需要结合 async
和 await
函数来使用
1 | expect.extend({ |
测试异步代码
回调形式
1 | // fetchData.js |
Promise
1 | // fetchData.js |
钩子函数
1 | // Couter.js |
describe 里面就是一个作用域,嵌套作用域可以有多个钩子函数,钩子函数执行顺序由外到内
test.only
,只执行某个测试用例
1 | describe('测试减少相关代码',()=>{ |
Mock
- 捕获函数的调用,this 指向,调用顺序
- 自由设置返回结果
- 改变内部函数的实现
基本模拟-导入函数
1 | // demo |
修改原来函数的返回
1 | // xx.js |
__mocks__
文件夹
1 | // __mock__/xx.js |
接着改测试,模拟函数
1 | // xx.test.js |
上述函数顶部那步模拟也可以通过在 jest.config.js 中修改配置 automock:true
改为自动模拟,那么引入对应的函数的时候就会自动去 __mocks__
文件夹里面去寻找对应的模拟函数。注意一旦开启这个配置,需要启动才会生效,另外也会导致很多测试需要重新修改。
同时存在__mocks__
以及导入函数
在原来 xx.js 基础上面新增 一个 getNumber
函数
1 | xx.js |
接着修改测试用例
1 | // xx.test.js |
上述代码会报错,getNumebr
找不到对应的函数,因为测试用例还是会去__mocks__
文件夹去寻找 getNumber
。但是我们只希望模拟异步的函数,对于同步函数希望通过导入的方式来测试,那么就从原来的js文件中导入 getNumber
1 | // xx.test.js |
Mock Timer
写一个 setTimeout 函数
1 | // timer.js |
测试用例
1 | // timer.test.js |
上面的测试用例会直接通过, timer
是一个异步函数,并不会执行函数体内的内容,需要像之前的异步函数一样,加个 done 参数
1 | import timer from './timer' |
接着测试用例便会运行,并报错
1 | FAIL src/lesson3/timer.test.js (7.997s) |
也可以通过模拟 timer 这类异步函数,来达到目的
1 | // timer.test.js |
对于嵌套 timer 异步函数,jest.runOnlyPendingTimers
可以让只最外层的第一个 timer 运行。
另外还有快进时间的api,jest.advanceTimersByTime(n)
1 | // timer.test.js |
嵌套 timer 的测试用例结合 钩子beforeEach
以及两个上面讲的api的例子
1 | // timer.js |
Mock Funtions
Mock 函数可以轻松测试代码之间的连接——实现方式包括:擦除函数实际实现、捕获对函数的调用(以及在这些调用中传递的参数)、在使用 new
实例化时捕获构造函数的实例,允许测试时配置返回值
模拟函数
测试函数 forEach
的内部实现,这个函数为传入的数组中的每个元素调用一次回调函数
1 | function forEach(items,callback){ |
为了测试此函数,可以使用一个 mock 函数,然后检查 mock 函数的状态来确保回调函数如期调用
1 | const mockCallback = jest.fn(x => 42 + x); |
.mock
属性
所有 mock 函数都有这个特殊的 .mock
属性,它保存了关于此函数如何调用、调用时的返回值的信息。.mock
属性还追踪每次调用时 this
的值,所以我们同样也可以检视(inspect)this
1 | const myMock = jest.fn(); |
模拟返回值
1 | const myMock = jest.fn(); |
结合一些函数灵活模拟
1 | const filterTestFn = jest.fn(); |
模拟模块
1 | // users.js |
模拟 axios.get 返回一个假的 response
1 | // users.test.js |
模拟实现
通过模拟函数 jest.fn
或者mockImplementationOnce
方法来完成
1 | const myMockFn = jest.fn(cb=> cb(null,true)) |
mockImplementation
当您需要定义从另一个模块创建的模拟函数的默认实现时,该方法很有用
1 | // foo.js |
当需要重新创建模拟函数的复杂行为,以便多个函数调用产生不同的结果时,可以使用 mockImplementationOnce
方法
1 | const myMockFn = jest |
.mockReturnThis()
返回 this
1 | const myObj = { |
模拟函数名称
选择为模拟函数提供一个名称,改名称将在测试错误输出中显示,而不是 jest.fn()
,使用这个可以快速识别在测试输出中报告错误的模拟函数
1 | const myMockFn = jest |
Snapshot快照
适合测试配置文件
1 | // xx.js |
jest --watchAll
里面出现了 u
、i
模式分别对应 更新所有的快照跟更新单个快照
安装 prettier
,运行 toMatchInlineSnapshot
,会将 快照自动存到代码下面
1 | test('测试 generateConfig 函数', () => { |
ES6 类
1 | // util.js |
在别的函数里面使用这个类
1 | // useUtil.js |
写这个使用类的函数的测试用例的时候,我们会发现这个函数因为使用到了类里面的函数,而函数又很复杂,直接调用会损耗性能。所以这里我们用几种方法来模拟
1 | // useUtil.test.js |
另一种方法就是通过在 __mocks__
文件夹中模拟
1 | // __mocks__/util.js |
还有一种写法,是在原来的测试用例修改
1 | // useUtil.test.js |
DOM操作
1 | // dom.js |
TDD(测试驱动开发)
全称:Test Driven Development
开发流程(Red-Green Development)
- 编写测试用例
- 运行测试,测试用例无法通过测试
- 编写代码,使测试用例通过测试
- 优化代码,完成开发
- 重复上述步骤
优势
- 长期减少回归 bug
- 代码质量更好(组织、可维护性)
- 测试覆盖率高
- 错误测试代码不容易出现
Vue TDD
开始
1 | 安装脚手架 |
@vue/test-utils
1 | // HelloWorld.test.js |
shallowMount
浅层渲染,只渲染第一层,不渲染子组件,适合单元测试
mount
则会渲染子组件,适合集成测试
开发 Header 组件
测试用例先行
1 | // Header.test.js |
根据测试用例写代码
1 | // Header.vue |
测试覆盖率
1 | // jest.config.js |
React TDD
Enzyme
1 | 安装 |
同样,shallow
适合单元测试,mount
则是集成测试
例子:
1 | // App.je |
另外它海域一些扩展 API,例如 jest-enzyme,让语法易懂简洁
1 | // App.test.js |
别忘记在 jest.config.js
里面进行配置
1 | module.exports = { |
开发 Header 组件
同样测试先行
1 | import React from 'react'; |
根据测试写代码
1 | import React, { Component } from 'react'; |
TDD 小结
优势:代码质量提高
单元测试
测试覆盖率高,业务耦合度高,代码量大,过于独立
BDD(行为驱动开发)
全称:Behavior Driven Development
集成测试
1 | // vue integration/todoList |
TDD 与 BDD 比较
TDD
- 先写测试再写代码
- 一般结合单元测试使用,是白盒测试
- 测试重点在代码
- 安全感低
- 速度快
BDD
- 先写代码再写测试
- 一般结合集成测试使用,是黑盒测试
- 测试重点在 UI (DOM)
- 安全感高
- 速度慢
Redux相关测试
增加 redux 在项目
createStore
1 | // src/store/createStore |
store/actions.js
1 | import { CHANGE_INPUT_VALUE } from './constants'; |
store/constants.js
1 | export const CHANGE_INPUT_VALUE = 'CHANGE_INPUT_VALUE' |
store/reducer.js
1 | import { CHANGE_INPUT_VALUE } from './constants'; |
store/index.js
1 | import reducer from './reducer' |
TodoList/index.js
1 | import React, { Component } from 'react'; |
src/index.js
1 | import React from 'react'; |
然后修改集成测试的测试用例
src/containers/TodoList/__test__/integration/TodoList.test.js
1 | import React from 'react'; |
异步代码
compontDidMount
1 | componentDidMount(){ |
模拟函数
src/__mock__/axios.js
1 | const mockUndoList = { |
测试用例
1 | it(` |
setTimeout
1 | componentDidMount(){ |
测试用例
1 | jest.useFakeTimers(); |
前端自动化测试的优势
- 更好的代码组织,项目的可维护性增强
- 更小的bug 出现概率,尤其是回归测试中的 Bug
- 修改工程质量差的项目,更加安全
- 项目具备潜在的文档特性
- 扩广前端的知识面
Enzyme
Enzyme 是 React 的 JavaScript 测试应用程序,可以轻松测试 React Components 的输出。还可以在给定输出的情况下,遍历以某种方式模拟运行。
主要通过模仿 Jq 用于 DOM 操作和遍历
API
at(index)
.at(index) => shallowWrapper
返回当前 wrapper 中指定索引的节点
1 | const wrapper = shallow(<MyComponent />) |
first()
.first() => ShallowWrapper
将匹配节点集合减少到集合中的第一个,就像.at(0)
。
1 | expect(wrapper.find(Foo).first().props().foo).to.equal('bar'); |
last()
.last() => ShallowWrapper
将匹配节点集减少到集合中的最后一个,就像.at(length - 1)
。
1 | const wrapper = shallow(<MyComponent />); |
childAt(index)
.childAt(index) => ShallowWrapper
返回指定索引的子节点
1 | const wrapper = shallow(<TodoList items={items}/>); |
children([selector])
.children([selector]) => ShallowWrapper
返回父节点某个元素的所有子节点
1 | const wrapper = shallow(<TodoList items={items}/>); |
closest(selector)
.closest(selector) => shallowWrapper
通过遍历节点祖先,返回第一个相匹配的节点
1 | const wrapper = shallow(<MyComponent />); |
contains(nodeOrNodes)
.contains(nodeOrNodes) => Boolean
返回所有给定的react元素是否与渲染树中的元素匹配。它将通过检查期望元素是否与包装器元素具有相同的 props 并共享相同的值来确定包装器中的元素是否与预期元素匹配。
1 | let wrapper; |
containsAllMatchingElements(patternNodes)
.containsAllMatchingElements(patternNodes) => Boolean
返回所有给定的react元素是否patternNodes
与包装器的渲染树中的元素匹配。每个元素patternNodes
必须匹配一次或多次。匹配遵循规则containsMatchingElement
。
1 | const style = { fontSize: 13 }; |
containsAnyMatchingElements(patternNodes)
.containsAnyMatchingElements(patternNodes) => Boolean
返回至少一个给定的react元素是否patternNodes
与包装器的渲染树中的元素匹配。一个或多个元素patternNodes
必须匹配一次或多次。匹配遵循规则containsMatchingElement
。
1 | const style = { fontSize: 13 }; |
containsMatchingElement(patternNode)
.containsMatchingElement(patternNode) => Boolean
返回patternNode
react元素是否与渲染树中的任何元素匹配。
1 | const wrapper = shallow(( |
context([key])
返回包装器根节点的上下文哈希。可选地传入一个props,它将只返回该值。
1 | const wrapper = shallow( |
debug([options])
.debug([options]) => String
返回包装器的类似HTML的字符串,以便进行调试。当测试没有通过时,打印到控制台很有用。
options
(Object
[可选]):
options.ignoreProps
:(Boolean
[可选]):是否应在结果字符串中省略props。默认情况下包含道具。options.verbose
:(Boolean
[可选]):是否应该详细打印作为道具传递的数组和对象。
dive([options])
.dive([options]) => ShallowWrapper
浅呈现当前包装器的一个非DOM子项,并返回结果周围的包装器。它必须是单节点包装器,并且该节点必须是React组件。
注意:只能在单个非DOM组件元素节点的包装上调用,否则会引发错误。如果必须使用多个子节点对包装器进行浅包装,请使用.shallow()
1 | function Bar() { |
equals(node)
.equals(node) => Boolean
返回当前包装器根节点呈现树是否与传入的树相似
1 | const wrapper = shallow(<MyComponent />); |
every(selector)
.every(selector) => Boolean
返回包装器中的所有节点是否与提供的选择器匹配。
1 | const wrapper = shallow(( |
everyWhere(fn)
.everyWhere(fn) => Boolean
1 | const wrapper = shallow(( |
exists([selector])
.exists([selector]) => Boolean
返回包装器中是否存在任何节点。或者,如果传入选择器,则该选择器是否在包装器中具有任何匹配项。
1 | const wrapper = mount(<div className="some-class" />); |
filter(selector)
.filter(selector) => ShallowWrapper
返回一个新的包装器,其中只包含与提供的选择器匹配的当前包装器的节点。
1 | const wrapper = shallow(<MyComponent />); |
filterWhere(fn)
.filterWhere(fn) => ShallowWrapper
返回一个新的包装器,它只包含当前包装器的节点,当传递给提供的谓词函数时,返回true
1 | const wrapper = shallow(<MyComponent />); |
find(selector)
.find(selector) => ShallowWrapper
查找当前包装器的呈现树中与提供的选择器匹配的每个节点。
1 | import Foo from '../components/Foo'; |
findWhere(fn)
.findWhere(fn) => ShallowWrapper
查找渲染树中为提供的谓词函数返回true的每个节点。
1 | const wrapper = shallow(<MyComponent />); |
forEach(fn)
.forEach(fn) => Self
迭代当前包装器的每个节点,并使用围绕作为第一个参数传入的相应节点的包装器执行提供的函数。
1 | const wrapper = shallow(( |
get(index)
.get(index) => ReactElement
1 | const wrapper = shallow(<MyComponent />); |
getWrappingComponent()
.getWrappingComponent() => ShallowWrapper
如果wrappingComponent
传入了a options
,则此方法返回ShallowWrapper
渲染的周围wrappingComponent
。这ShallowWrapper
可以用来更新wrappingComponent
props,state等。
1 | import { Provider } from 'react-redux'; |
getElement()
.getElement() => ReactElement
返回包装的ReactElement。如果当前包装器正在包装根组件,则返回根组件的最新呈现输出。
1 | const element = ( |
getElements()
.getElements() => Array<ReactElement>
1 | const one = <span />; |
hasClass(className)
.hasClass(className) => Boolean
返回包装节点是否具有className
包含传入的类名称的prop。它必须是单节点包装器。
1 | const wrapper = shallow(<MyComponent />); |
hostNodes()
.hostNodes() => ShallowWrapper
返回仅包含主机节点的新包装器。当使用react-dom
,主机节点是HTML元素,而不是定制反应的组分
1 | const wrapper = shallow(( |
html()
.html() => String
返回整个当前渲染树(不仅仅是浅渲染部分)的渲染HTML标记的字符串。只能在单个节点的包装器上调用
1 | function Foo() { |
instance()
.instance() => ReactComponent
返回单节点包装器节点的底层类实例; this
在它的方法。
1 | function Stateless() { |
is(selector)
.is(selector) => Boolean
返回单个包装节点是否与提供的选择器匹配。它必须是单节点包装器。
1 | const wrapper = shallow(<div className="some-class other-class" />); |
isEmptyRender()
.isEmptyRender() => Boolean
返回包装器是否最终只呈现允许的假值:false
或null
。
1 | function Foo() { |
key()
.key() => String
返回当前包装器节点的键值。它必须是单节点包装器。
1 | const wrapper = shallow(( |
map(fn)
.map(fn) => Array<Any>
将当前节点数组映射到另一个数组。每个节点作为a传递ShallowWrapper
给map函数。
1 | const wrapper = shallow(( |
matchesElement(patternNode)
.matchesElement(patternNode) => Boolean
回给定的react元素是否patternNode
与包装器的渲染树匹配。它必须是单节点包装器,并且仅检查根节点。
这些patternNode
行为就像一张通配符。为了匹配包装器中的节点:
- 标签名称必须匹配
- 内容必须匹配:在文本节点中,前导和尾随空格被忽略,但中间空间不被忽略。子元素必须根据这些规则以递归方式匹配。
patternNode
props(attributes)必须出现在包装器的节点中,而不是相反。如果它们出现,它们的值必须匹配。patternNode
样式CSS属性必须出现在包装器节点的样式中,而不是相反。如果它们出现,它们的值必须匹配。
1 | class MyComponent extends React.Component { |
name()
.name() => String|null
返回此包装器的当前节点的名称。如果它是复合组件,则这将是最顶层渲染组件的名称。如果它是本机DOM节点,则它将是标记名称的字符串。如果是的话null
,那就是null
。
返回名称的优先顺序是:type.displayName
- > type.name
- > type
。
1 | const wrapper = shallow(<div />); |