>>>此文是看很多博客,取了别人的经验。主要是方便小白不用去查找了。
>>> 感谢写过单元测试的各位前辈的经验分享。
在项目中开始写单元测试,因为没学过,所以从头开始学一下。
这些天看了很多博客以及文章,这篇【入门版】算是学习单元测试的【基本语法】吧。
知识都很基础。因为项目是react + ts +feflow的,所以采用单测形式为
Mocha+ chai + enzyme+sinon , 以达到Jest的使用体验。
一。定义及目的
1.定义:主要是把代码看成一个个的函数以及组件,通过编写测试脚本,以最小的粒度去测试,确保这些函数和组件都能达到预期值。
单元测试的英文是Unit Test,简称UT。
2.目的:提高代码质量和可维护性,防止开发的后期因bug过多而失控。
单测的威力更多不是体现在新代码的编写上,而是对已有代码的更改。
3.测试脚本命名方式:与所要测试的源码脚本同名,后缀为.test.js。
<那我们应该如何去在项目中使用单元测试呢?第一步,先了解单元测试框架种类,根据技术选型选取合适的单元测试方式。>
二。框架种类
常用工具:Jest、Mocha、Jasmine、Karma、Istanbul等。
(1)Mocha官网:属于基础框架,灵活,如需其他功能如assertions, spies,mocks等需要添加其他库/插件完成,需开发者自行去选择断言库。成本较高。
PS**:Mocha不内置expect,因此一般使用mocha时会使用chai。
(2)Jest官网:属于集大成者,不需要开发者额外配置,对React组件支持度非常友好,自带snapshot功能。成本较低。
内置Istanbul,可以查看到测试覆盖率。
PS:snapshot功能:能够确保UI不会意外被改变。Jest会把结果值保存在一个文件当中,每次进行测试的时候会把测试值与文件中的结果值进行比较,如果两个结果值不同,那么开发者可以选择要么改变代码,要么替代结果文件。
(3)Jasmine官网:安装即用,支持断言、仿真、全局环境,稳定性较好,文档完整。
<框架的使用往往是和库紧密联系在一起的,然后去看一下库的分类和一些辅助工具>
三。库种类
断言库:chai、assert、power-assert、 should.js、 expect.js。他们的差别主要是在断言风格上面的差别。
渲染库:enzyme
辅助库:sinon
请求工具:nock
模拟工具:JSDOM
断言:判断源码的实际执行结果与预期结果是否一致,如果不一致就抛出一个错误。
<到现在你已经了解了单元测试的框架和库,接下来先去看下框架的具体使用方式>
四。框架的使用
前言:不同框架都会基于 bdd 或者 tdd 的语言描述,所以你看起来有些api 是一样的。
背景:基于feflow和alek编写的插件
(1) 代码实例 及 运行结果
(2)测试代码结构
测试脚本里面应该包括一个或多个describe块,每个describe块应该包括一个或多个it块。所有的测试用例(it块)都应该含有一句或多句的断言。它是编写测试用例的关键。
》PS:断言功能由断言库来实现,Mocha本身不带断言库,所以必须先引入断言库。
describe块称为"测试套件"(test suite),表示一组相关的测试。它是一个函数,第一个参数是测试套件的名称("加法函数的测试"),第二个参数是一个实际执行的函数。
it块称为"测试用例"(test case),表示一个单独的测试,是测试的最小单位。它也是一个函数,第一个参数是测试用例的名称("1 加 1 应该等于 2”),第二个参数是一个实际执行的函数。
(3)引入断言库
expect(add(1, 1)).to.be.equal(2);
上述代码引入的断言库是chai,且指定使用它的expect断言风格。expect断言的优点是很接近自然语言。
基本上,expect断言的写法都是一样的。头部是expect方法,尾部是断言方法,比如equal、a/an、ok、match等。两者之间使用to或to.be连接。
(4)命令行相关
先全局安装Mocha: npm install --global mocha
运行指定测试脚本: mocha add.test.tsx
运行多个测试脚本: mocha ‘test/**/**.ts’
阮一峰的测试框架 Mocha 实例教程
五。库的使用
1. 断言库 chai
测试失败用抛异常来处理,比较麻烦,我们可以理解chai是对抛异常方法的一个封装,当判断失败时会抛出一个异常。
(1)断言起始:
在chai中,BDD风格包含expect和should (chai还有assert断言方式, 此处暂不讨论),二者以相同的链式结构进行断言,但不同在于他们初始化断言的方式:expect使用构造函数来创建断言对象实例,而should通过为Object.prototype新增方法来实现断言。
》PS:二者的区别:
expect接口提供了一个函数作为链接语言断言的起点。它适用于node.js和所有浏览器。
should接口实例化对象原型的产生单一实例来进行断言。它适用于node.js以及除Internet Explorer之外的所有现代浏览器。
》PS:推荐使用expect
(2)语言修饰符(语言链)
》PS:以下的语言修饰符单纯是为了提高阅读性。实际上是没有作用的。后续的api才是断言所用的接口。
(3)API chai断言库api文档
API可分类为类型判断、大小判断、长度判断、从属关系、其他 。
可从字面意思进行划分,下面把使用度高的放在前面。
.length:设置.have.length标记作为比较length属性值的前缀。
例: expect([1,3,4]).to.have.length.most(3);
.lengthOf(value):目标的length属性为value。value为number类型。
例: expect([1,3,4]).to.have.lengthOf(3);
.equal(value):目标===value(浅比较)。结合deep可深比较。
例1: expect(2).to.equal(2);
例2: expect({name:’lxh’}).to.deep.equal({name:’lxh'});
.deep:递归比较对象的键值对。常搭配equal和property。
.eql(value):目标深度等于value。相当于.deep.equal(value)。
.not:否定断言。 例: expect(2).to.not.equal(3); 少用.not
.a(type)/.an(type):类型判断。type为string类型,代表被测试的
值的类型。 例1: expect(‘lxh’).to.be.a(‘string’);
例2:expect(foo).to.be.a.instanceof(Foo);
.instanceof(constructor):目标为constructor的实例。
constructor:构造函数
例:let Animal = function(name){this.name = name;}
expect(new Animal(‘猫’)).to.be.an.instanceof(Animal);
.any:至少包含一个。在keys断言之前使用any标记。(与all相反)
例: let lxh = { age: 20, school: ‘XUPT’ };
expect(lxh).to.have.any.keys(‘age’,’hobby’);
.all:全部包含。在keys断言之前使用all标记。(与any相反)
例: let lxh = { age: 20, school: ‘XUPT’ };
expect(lxh).to.have.any.keys(‘age’,’school’);
.property(name,[value]):目标是否拥有名为name的属性。name
为string类型。可选地如果提供了value则该属性值还需要严格等于(===)value。如果设置了deep标记,则可以使用点.和中括号[]来指向对象和数组中的深层属性。
例: let lxh = { hobby: { eat: ‘apple’ } };
expect(lxh).to.have.deep.property(‘hobby.eat’,’apple’);
.keys(key1,[key2],[…]):目标包含传入的属性名。参数key可为string, array, object属性名。 断言目标包含传入的属性名。
*与any,all,contains或者have前缀结合使用会影响测试结果:
当与any结合使用时,无论是使用have还是使用contains前缀,目标必须至少存在一个传入的属性名才能通过测试。
注意,any或者all应当至少使用一个,否则默认为all。
当结合all和contains使用时,目标对象必须至少拥有全部传入的属性名,但是它也可以拥有其它属性名。
当结合all和have使用时,目标对象必须且仅能拥有全部传入的属性名。
.ok:目标是否为真值(类似隐私转换). 例:expect(‘lxh’).to.be.ok;
.true:目标为true(不进行隐式转换)。
例1: expect(true).to.be.true;
例2: expect(1).to.not.be.true;
.false:目标为false(不进行隐式转换)。
例1: expect(false).to.be.false;
例2: expect(0).to.not.be.false;
.null:目标为null。
例1: expect(null).to.be.null;
例2: expect(undefined).to.not.be.null;
.undefined:目标为undefined。
例: expect(null).to.not.be.undefined
.NaN:目标为NaN 例: expect(‘lxh’).to.not.be.NaN;
.arguments:目标为一个参数对象arguments。
.above(value):目标大于value。value为number类型。 也可在length后断言一个最小长度。
例1: expect(1314).to.be.above(5);
例2: expect(‘lxh’).to.have.length.above(2);
.least(value):目标大于等于value。value为number类型。也可在length后断言一个最小长度。
例1: expect(1314).to.be.at.least(1314);
例2: expect([1,3,4]).to.have.length.of.at.least(3);
.below(value):目标小于value。value为number类型。也可在length后断言一个最大长度。
例1: expect(5).to.be.at.below(1314);
例2: expect([1,3,4]).to.have.length.below(10);
.most(value):目标小于等于value。value为number类型。也可在length后断言一个最大长度。
例1: expect(1314).to.be.at.most(1314);
例2: expect([1,3,4]).to.have.length.most(3);
.within(start, end):目标在闭区间[start, end]内。start和end均为number类型。
例1: expect(5).to.be.at.within(1,10);
例2: expect([1,3,4]).to.have.length.most(3);
.exit:目标存在(非null也非undefined)。
例1: expect(null).to.not.exit;
.empty:目标长度为 0。对于数组和字符串,它检查length属性,对于对象,它检查可枚举属性的数量。
例: expect([]).to.be.empty;
还有蛮多API的,这里暂时不提了,自行查阅: chai断言库api文档
2.渲染库 Enzyme
Enzyme:React组件渲染和测试,可直接在node环境render虚拟DOM,并对其进行测试。分为三种级别的render:
》1. Shallow Rendering: shallow
浅渲染:只会渲染自己的部分,对于子组件不会递归的渲染。
渲染为虚拟DOM,所以速度很快。
开发者可focus在组件本身的单元测试。
例:import { shallow, ShallowWrapper } from ‘enzyme’;
let shallowWrapper: ShallowWrapper;
shallowWrapper = shallow(MonthSelector(new Date(2018, 0, 23, 12, 23, 23)));
shallowWrapper为浅渲染节点(组件)的TS类型。
何时使用shallow?
(1)和组件生命周期无关
(2)组件本身做为一个单位,对其进行各种测试,包括事件触发/响应,Virtual Dom
(2)测试用例各种断言,不依赖子组件,和子组件的表现没有直接的关联
》2. Full Rendering: mount
完整渲染:把组件包括他的所有的子组件完全渲染。
将React组件加载为真实DOM节点。可选择JSDOM库提供浏览器环境的NodeJS模拟。或document.createElement()去创建真实节点。
例 let wrapper: ReactWrapper;
wrapper = mount(<Menu onClick={() => {}}/>);
wrapper.find(‘.divContainer’);
wrapper.unmount();
用完即卸,防止内存泄漏。
shallow和mount的结果是个被封装的ReactWrapper,可以进行多种操作,譬如find()、parents()、children()等选择器进行元素查找;state()、props()进行数据查找,setState()、setprops()操作数据;simulate()模拟事件触发。
enzyme中有几个比较核心的函数需要注意,如下:
> 关于 交互测试
主要利用simulate()接口模拟事件,实际上simulate是通过触发事件绑定函数,来模拟事件的触发。触发事件后,去判断props上特定函数是否被调用,传参是否正确;组件状态是否发生预料之中的修改;某个dom节点是否存在是否符合期望。
> 关于 mock请求
需要mock掉真正的http请求,模拟返回值。不用担心不准确,你只用保证请求时的参数符合期望就好,mock的返回值按预期编写就好,置于这些请求是否真的能返回这些结果,是接口测试改干的活。
(nock库可模拟HTTP请求)
》3. Static Rendering: render
静态渲染:像真实的运行环境一样,render的所有内容都会展示。
不需要jsdom模拟环境解决子组件测试。
render采用的是第三方库Cheerio的渲染,渲染结果是普通的html结构,对于snapshot使用render比较合适。
Enzyme如何编写测试用例?
1、使用find(‘.classname')按类名查找,并使用text()获取文本。
2、 使用find(‘button')按标签名称查找,并使用at(0)获取第一个button。使用simulate('click')模拟点击事件。
3、模拟多次点击
4、使用state()函数获取组件state,并进行验证
5、使用find(‘#list')通过id进行查找,并验证dom结构的修改
6、使用props()和prop(key)函数来获取组件的props,并可通过setProps改变组件props验证组件是否正确根据props进行变化。
如果使用了redux,store和redux state也是通过connect,以props的形式传递给组件,同样可以进行测试。
7、获取组件下面结构的类名,验证各种操作下类名是否正确改变。
3. 辅助库:Sinon
复杂的逻辑或者依赖io、网络的异步代码,可以通过sinon简化复杂代码的测试。Sinon通过创建Test Double(测试替身),将我们代码中依赖的一些函数或者类,替换成测试替身,而我们可以对测试替身的行为进行设置,模拟我们的代码需要的结果,从而让难以测试的代码逻辑被执行。
Sinon通过伪装和拦截,来模拟与其他系统或函数的操作,解耦测试的依赖。
Sinon有主要有三个方法辅助我们进行测试:spy,stub,mock。
spy生成一个间谍函数,它会记录下函数调用的参数,返回值,this的值,以及抛出的异常。
spy一般有两种玩法,一种是生成一个新的匿名间谍函数,另外一种是对原有的函数进行封装并进行监听。
例1: 传入Once的函数会被调用
import { spy } from ‘sinon’;
describe('测试Once函数', function () {
it('匿名间谍函数', function () {
var callback = spy();
var proxy = once(callback);
proxy();
assert(callback.called);
});
})
spy()会产生一个函数对象,当once调用这个函数对象后,这个函数对象通过called可以返回一个bool值,表示函数是否被调用。
例2: 对原有函数的spy封装,可以监听原有函数的调用情况
it('封装监听', function () {
const obj={
func:()=>{
return 1+1
}
}
spy(obj,'func')
obj.func(3);
assert(obj.func.calledOnce)
assert.equal(obj.func.getCall(0).args[0], 3);
});
(2) stub:
stub是带有预编程行为的函数。即spy的加强版,还能操作函数的行为。stub也能匿名,也能去封住并监听已有函数。但当stub封装了一个已有函数后,原函数不会再被调用。
例:对原有函数的stub封装,可以监听原有函数的调用情况,以及模拟返回。
it('封装原函数', function () {
const obj={
func:()=>{
console.info(1)
}
}
stub(obj,’func').returns(42);
const result=obj.func(3);
assert(obj.func.calledOnce)
assert.equal(obj.func.getCall(0).args[0], 3);
assert.equal(result,43);
});
(3) mock:
mock其实和stub很像,只不过是stub是对对象中单个函数的监听和拦截,而mock是对多个。
例:it('mock的测试', function () {
var myAPI = {
method: function () {
console.info("运行method")
},
func: function () {
console.info("运行method")
}
};
// 对函数进行一个预期
var mock = mock(myAPI);
mock.expects("method").once().returns(2);
mock.expects("func").twice()
// 对函数进行实际操作
myAPI.method();
myAPI.func();
myAPI.func();
// 验证操作
mock.verify();
});
once就是预期运行一次,如果最终验证时函数没有被执行或者执行多次都会抛出错误。也可以操作返回结果,比如像stub一样returns(2)依然有效。
六。理解单元测试、TDD、BDD
你可以编写并运行很多单元测试来确保尽可能多的BUG被发现,特别是当你需要修改或者重构你的代码的时候,有一组可靠的单元测试做保护可以让你的操作更安全、更有信心不会带来对系统的未知破坏。
2. TDD (Test-Driven Development)
TDD是一个开发测试代码和业务代码的工作流程,基于此流程你可以写出具有极高测试覆盖率(通常接近90%)的代码。TDD还可以减少测试中发现比较难以定位的BUG的可能性。
(1)TDD的一般过程是:
1) 写一个测试
2) 运行这个测试,看到预期的失败
3) 编写尽可能少的业务代码,让测试通过
4) 重构代码
5) 不断重复以上过程
(2)好处
可维护性极高,你对代码的任何修改、扩展、重构都能得到及时的反馈,不用担心会无意中破坏系统。
(3)缺点
学习TDD的最大障碍在于你需要先写测试代码,然后才是产品代码,这是个思维转换和习惯养成的过程,需要不断的重复练习才能逐步掌握。
3. BDD (Behavior-Driven Development)
BDD是一组编写优秀自动化测试的最佳实践,可以单独使用,但是更多情况下是与TDD 单元测试配合使用的。
BDD解决的一个关键问题就是如何定义TDD或单元测试过程中的细节。一些不良的单元测试的一个常见问题是过于依赖被测试功能的实现逻辑。这通常意味着如果你要修改实现逻辑,即使输入输出没有变,通常也需要去更新测试代码。这就造成了一个问题,让开发人员对测试代码的维护感觉乏味和厌烦。
BDD建议针对行为进行测试,我们不考虑如何实现代码,取而代之的是我们花时间考虑场景是什么,会有什么行为,针对行为代码应该有什么反应。
4. 结论
单元测试回答的是What的问题,TDD回答的是When的问题,BDD回答的是How的问题。也可以把BDD看作是在需求与TDD之间架起一座桥梁,它将需求进一步场景化,更具体的描述系统应该满足哪些行为和场景,让TDD的输入更优雅、更可靠。你可以选择单独使用其中一种方法,也可以综合使用这几个方法以取得更好的效果。
单元测试 关注单元,思考划分单元的依据。我们测试的是哪一部分
而这部分又是如何划分产生的。
TDD 关注需求,深入业务,把业务需求最简化,迭代到细节。
BDD 关注反应,用户行为会产生什么反应,反应的正确性与否
作者:请叫我_没用的阿吉
链接:https://juejin.cn/post/6844904125906288653
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。