再谈前端单元测试
KevinHu-1024 opened this issue · 0 comments
单元测试的意义
- 作为现有代码行为的描述
- 对发现的bug补充测试代码,防止再次出现
- 强迫开发者写可测试的代码,一般来说可测试的代码可读性也比较好
- 依赖的组件如果有修改,受影响的组件能在测试中发现错误
下面是一个典型的组件测试:
describe('测试权限高阶组件', () => {
it('当组件被权限组件包裹,且处理函数返回true时,组件被渲染', () => {
const handleFunc = () => true
const wrapper = shallow(<Btn
customHandler={handleFunc}
permissionsName={[]}
otherProps=""
currentUserPermissions={currentUserPermissions}
>按钮</Btn>)
expect(wrapper.contains(<Button otherProps="">按钮</Button>)).toBe(true)
})
it('当组件被权限组件包裹,且处理函数返回false时,组件不渲染', () => {
const handleFunc = () => false
const wrapper = shallow(<Btn
customHandler={handleFunc}
permissionsName={[]}
otherProps=""
currentUserPermissions={currentUserPermissions}
>按钮</Btn>)
expect(wrapper.contains(<Button otherProps="">按钮</Button>)).toBe(false)
})
it('当没有自定义判断函数传入时,组件应该使用hasPermission函数作为this.permissionHandler', () => {
const wrapper = shallow(<Btn
permissionsName={[]}
currentUserPermissions={currentUserPermissions}
>按钮</Btn>)
expect(wrapper.instance().permissionHandler).toBe(hasPermission)
})
it('当使用自定义判断函数时,函数的参数应该是[组件权限[], 当前用户所有权限[]]', () => {
const handleFunc = jest.fn()
const componentPermissions = ['edit_site']
shallow(<Btn
customHandler={handleFunc}
permissionsName={componentPermissions}
otherProps=""
currentUserPermissions={currentUserPermissions}
>按钮</Btn>)
expect(handleFunc.mock.calls[0][0]).toBe(componentPermissions)
expect(handleFunc.mock.calls[0][1]).toBe(currentUserPermissions)
})
})
敏捷开发下单元测试的意义
小瀑布流还是敏捷开发?
不断重构
敏捷开发意味着要经常交付可运行的软件,这个软件不一定是完美的,在早期交付的软件甚至没有架构可言,之所以在早期淡化架构,是因为此时软件足够小,足够简单。
这就意味着在下一个迭代周期内,我们势必要对这个软件进行重构,来满足功能的复杂性,架构的合理性,甚至是在保证外部行为不变的前提下,重构大部分代码。
那么问题来了,如何在数十次重构过程中,保证我以前交付的功能不会受到影响呢?那么单元测试就是记录软件行为的方案之一。没有单元测试,就只能走小瀑布流开发,无法走敏捷开发,因为重构没有质量保证。
工具链与参考项目
使用create-react-app
可以快速创建一个具备单元测试支持的前端项目,其使用的工具链为Jest
,为了测试React组件更加方便,我们需要参考其文档,把Enzyme
集成进来。
我这里使用create-react-app生成了项目,并集成了Enzyme,可以参考我的commit看具体文件变动,很简单
一般而言,一个测试由Runner
、断言库
、假函数(模拟函数)
组成
Runner
Runner是用来执行测试用例的程序,对用例进行分组、提供一些测试周期钩子进行环境准备和清理、决定跑用例的顺序等等,是整个测试程序的骨架,一般来说当你看到describe
,it
,test
,beforeEach
等函数名时,这块就是Runner掌管的部分(Runner不同,刚才提到的函数名也不同,但意义是差不多的),一般来说一段测试代码的骨架长这个样子(使用Jest作为Runner,当然还有一些更通用的、知名的Runner —— Mocha
):
describe('用例分组名称', () => {
it('第一个测试用例', () => {
// 至于里面些什么随意,就算不是测试代码也一样可以跑
})
it('第二个测试用例', () => {
})
})
Jest提供的有关Runner方面(它还提供了断言等功能,这里只说Runner)的全局函数参见文档
断言库
断言库就是一系列用来判断值的函数库,比如相等、全等、字符串包含、布尔判断、对象浅层判断、深层判断等,我们所熟知的断言库 —— Chai
就是做这个功能的。不过在这里,为了使用方便,减少集成成本,我们使用了Jest提供的内置断言库,它所提供的断言函数文档在这。
断言库的行文风格一般有两个流派,TDD和BDD这里面的TDD和BDD只能理解为"风格",与你是否使用测试驱动开发TDD、行为驱动开发BDD没什么关系,你在测试驱动开发流程中使用BDD风格的断言库是没有问题的。一般而言,BDD风格的断言库更接近于自然语言,读起代码来比较流畅。
典型的TDD断言风格如下(Chai):
// 判断相等
assert.equal(3, '3', '== coerces values to strings')
典型的BDD断言风格如下(Chai),BDD还包含两种风格expect
和should
:
// 判断相等expect风格
expect(1).to.equal(1)
// 判断相等should风格
foo.should.equal('bar');
关于expect
和should
的区别,可以参考这里
Jest内部提供的断言库为BDD-expect风格,它所提供的断言函数文档在这。
假函数(模拟函数)
假函数是做函数行为判断的,当你想知道某个函数是否被调用了、调用了几次、每次调用的参数是什么的时候,假函数就派上用场了。在通用测试场景中一般使用sinon
这个假函数库。不过还是为了使用方便,减少集成成本,我们使用了Jest提供的内置的假函数库来做这件事。
// 比如,我想测试A函数在测试用例中是否以A(param1, param2)调用
function A(p1, p2) {
// do something...
}
A = jest.fn() // 使用假函数替换了A原始函数
// 调用A代码....如A(参数, 参数)
// 可以拿到A的每一次调用记录
A.mock.calls
// 可以拿到A的第一次调用记录的第一个参数
A.mock.call[0][0]
// 断言
expect(A.mock.calls[0][0]).toEqual(param1)
expect(A.mock.calls[0][1]).toEqual(param2)
由于我们替换了原始A函数,原始A函数的行为都不会执行,我们只是测试传入值是否正确。
想要测试A函数正不正确,需要真实的去测试A函数,那是另一层测试了。这里的算是测试调用A函数的函数的行为是否正确,给A传参是否正确。
Jest内置的假函数库文档在这里
Enzyme
Enzyme是测试React组件的专用工具,它出现的意义是帮助我们简化React组件测试的过程。
如果不用Enzyme我们这样测试React组件:
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<App />, div);
});
使用了Enzyme我们这样测试React组件:
import React from 'react'
import { shallow } from 'enzyme'
import App from './App'
describe('单元测试1', () => {
it('App组件正常渲染', () => {
shallow(<App />)
})
})
我们知道React帮助我们接管了DOM操作,我们只需要关注数据:state、props、自己实现的组件实例上的各个函数、最终render出的组件树是否正确就行了,至于用一个个选择DOM树来确定是否渲染正确,就没有必要了。基于这个目的,Enzyme为我们提供了大量的用来访问React组件实例的便捷方法,来辅助我们进行判断,大大减少了选择器的使用。下面是几个简单的例子(Jest + Enzyme):
- 测试一个组件中是否包含另一个组件,使用.contains()
import { shallow } from 'enzyme'
import A from './A'
import B from './B'
it('A组件应该包含B组件', () => {
const wrapper = shallow(<A />)
expect(wrapper.contains(<B />)).toBe(true)
})
- 测试组件this上的属性,使用.instance()
// 比如A组件长这个样子
class A extends React.Component {
constructor(props) {
super(props)
this.someProp = 'a'
}
render() {
return <div>text</div>
}
}
// 测试
import { shallow } from 'enzyme'
it('A组件实例上的属性someProp等于a', () => {
const wrapper = shallow(<A />)
expect(wrapper.instance().someProp).toBe('a')
})
- 测试组件的prop,使用.prop(key)或.props()[key]
// 比如A组件长这个样子
class A extends React.Component {
static propsTypes = {
text: PropTypes.string,
}
constructor(props) {
super(props)
}
render() {
return <div>{text}</div>
}
}
// 测试
import { shallow } from 'enzyme'
it('A组件的text prop等于'a'', () => {
const wrapper = shallow(<A text="a" />)
expect(wrapper.prop(text)).toBe('a')
// 或
expect(wrapper.props().text).toBe('a')
})
-
关于
shallow
和mount
的选择以A组件为例,
shallow
只渲染A组件render下出现的组件,如果render下有<B />
,B组件的render下还有<C />
,那么B组件不会递归继续渲染出C来,使用contains不会检测到C组件,好处是,测试环境比较纯净,速度比较快,缺点是无法测试到C组件对A的影响。mount
则是完整渲染出C,速度较慢,当你想要测试C与A的关系的时候可以使用mount
更多的例子参见Enzyme文档,这个文档中的例子使用的是Mocha
+ Chai
+ Enzyme
的方案,api稍有不同,但是语义基本一样,在Jest中找到相应api替换使用即可。
单元测试的测试策略
由于单元测试时间成本比较高,测的越细则时间越长。所以大家可以根据项目的情况,选择适合自己项目的测试深度,下面的几个层次供参考:
Stage1:保证覆盖率,所有组件渲染不报错
这里是最基础的测试策略,目的只是为了在及其有限的时间下或历史遗留代码较多、拆分不好的条件下,迅速接入前端单元测试,并保证一定的覆盖率(当然这个覆盖率并没有太大的意义,因为没有具体的测试)。
这个策略会对组件进行简单的默认渲染操作,不报错即为通过。本身默认条件下的正确渲染就是测试用例,不包括任何断言、模拟过程。
缺点:测试粒度很粗,只能发现最容易发现的问题,只适合刚开始接入前端单元测试团队做过渡使用。
例子如下:
class A extends React.Component {
static propTypes = {
text1: PropTypes.string.isRequired,
text2: PropTypes.string,
}
static defaultProps = {
text2: '',
}
render() {
const { text1, text2 } = this.props
return <div>必填参数: {text1}, 选填参数: {text2}</div>
}
}
import { shallow } from 'enzyme'
it('A组件正常渲染', () => {
shallow(<A text1="a" />)
})
Stage2:Stage1 + 公共函数测试,公共组件测试
这个就是比较适中的一个测试策略,经过大量测试用例的测试,保证公共部分的质量及可靠性,同时,不深入业务,日常迭代时对测试代码改动较少,是比较推荐的通用的测试策略。
缺点是初期要花一定时间写测试代码,新项目边测编写还比较容易,如果是老的遗留项目,可能需要下很大力气去写(因为内部的补丁太多,对外依赖较多,函数很大难于测试等因素,而且由于是先写代码后写测试,可能导致某些情况难以覆盖到位)
以按钮组件为例:
class Button extends React.Component {
static propTypes = {
text: PropTypes.string.isRequired,
handler: PropTypes.func,
}
static defaultProps = {
handler: null,
}
handleClick = (e) => {
const { handler } = this.props
if (!handler) {
return false
}
handler(e)
}
render() {
const { text } = this.props
return <button onClick={this.handler}>{text}</button>
}
}
import { shallow } from 'enzyme'
it('默认情况下正常渲染', () => {
shallow(<Button text="按钮" />)
})
describe('功能测试', () => {
it('传入的文字应该选渲染出来', () => {
const wrapper = shallow(<Button text="按钮" />)
expect(wrapper.contains('按钮')).toBe(true)
})
it('当传入处理函数时,handleClick应该调用它,并正确传参', () => {
const handler = jest.fn()
const wrapper = shallow(<Button2 text="按钮" handler={handler} />)
const e = {}
wrapper.instance().handleClick(e)
expect(handler).toHaveBeenCalled()
expect(handler.mock.calls[0][0]).toEqual(e)
})
it('当没传处理函数时,handleClick应该返回false', () => {
const wrapper = shallow(<Button2 text="按钮" />)
const e = {}
const res = wrapper.instance().handleClick(e)
expect(res).toEqual(false)
})
})
测试公共函数的话,就不需要用Enzyme了,直接使用断言库就足够了。
Stage3:Stage1 + Stage2 + 测试业务组件的自定义函数
这个测试会针对经常变动的业务组件中的自定义函数进行测试,尽可能挑选不变的函数进行测试,防止由于业务变更导致用例失效
缺点:测试用例不太稳定,适合增量业务场景下保证迭代质量,不适合经常大改动的业务
Stage4:Stage1 + Stage2 + Stage3 + 测试业务组件的业务功能
这个测试会更加精细一些,针对业务组件的业务进行测试
缺点:测试用例很不稳定,受业务变动影响,失去了稳定的检测基础。需求改变,用例将大幅修改或作废,需要重新积累测试用例。由于很少存在迭代的过程(都是直接替换),单元测试的意义不明显,大量时间被浪费了,适合增量业务场景下保证迭代质量,不适合经常大改动的业务。另外由于业务经常涉及其他系统及异步场景,环境不纯净,测试困难
Stage0:补测试
补测试是在整个软件生命周期中随时都应该进行的过程,当测试同学或自己发现代码存在bug且单元测试有没有报错时,应该先按照bug产生的条件尽可能的在单元测试中写出模拟,并且报错,然后再去组件代码中将bug改正,此时单元测试应该为通过状态,这样就保证了bug被记录下来,在以后的重构中不会再次出现。
当然,如果受到测试环境等限制,导致单元测试无法模拟bug的产生,此时应责成测试同学补充自动测试用例来模拟bug。
单元测试的原则
易于替换的部件
组件中的函数应该易于测试,易于替换(替换为jest.fn()),一个坏例子:
const funcA = () => {}
class Tab extends React.Component {
// some code
someHandler() {
// ...
funcA()
}
// some code
}
在上面的例子里面,如果我们有场景需要测试someHandler是否对funcA正确传参时,我们在测试代码中很难将funcA替换成jest.fn,因为funcA已经被硬编码进了函数体,造成无法测试。
解决方案:将外部依赖管理在组件的constructor中:
const funcA = () => {}
class Tab extends React.Component {
// some code
constructor(props) {
super(props)
this.depFunc = funcA
}
someHandler() {
// ...
this.depFunc()
}
// some code
}
// 测试代码中的函数替换
it('测试someHandler向depFunc传参', () => {
const instance = shallow(<Tab />).instance()
const someHandler = instance.someHandler
// 替换
instance.depFunc = jest.fn
// 执行
someHandler()
// 断言传参
expect(instance.depFunc.mock.calls ...........)
})
只测自己写的
只测试自己写的部分,第三方库的行为已经由他们自己的单元测试保证了,我们不应该再去测试。比如给组件的onClick绑定了处理函数,那么就不应该去测试:点击之后处理函数是不是执行了。
尽可能不测试业务
业务多变,测试用例难以稳定,应交由自动化测试最终结果就可以了
样式由自动化截图测试来测
单元测试中不进行样式测试,自动化测试中有截图比较技术,更加方便易维护
单元测试与自动化测试的边界
异步/Promise测试
我们不测试http请求,这块一般由第三方库来保证。而且受测试环境的限制,也很难进行http测试。这里的异步主要是一些非http请求的异步函数测试。
it('异步', async() {
await expect(xxx异步函数()).........
})
Jest的限制
Jest默认使用js-dom
库来进行测试环境模拟,这个库需要打一系列Polyfill才能工作,在create-react-app
中这些工作已经做好,直接使用即可,日后需要增加Polyfill,可在相应文件中进行添加。
单元测试与覆盖率
单元测试与覆盖率是两码事,覆盖率高的代码,未必单元测试写得好。以我们的测试策略1为例,即使一句断言都不加,覆盖率也能跑到20%-40%。