Kiwi和BDD的测试思想
XCTest是基于OCUnit的传统测试框架,在书写性和可读性上都不太好。在测试用例太多的时候,由于各个测试方法是割裂的,想在某个很长的测试文件中找到特定的某个测试并搞明白这个测试是在做什么并不是很容易的事情。所有的测试都是由断言完成的,而很多时候断言的意义并不是特别的明确,对于项目交付或者新的开发人员加入时,往往要花上很大成本来进行理解或者转换。另外,每一个测试的描述都被写在断言之后,夹杂在代码之中,难以寻找。使用XCTest测试另外一个问题是难以进行mock或者stub,而这在测试中是非常重要的一部分(关于mock测试的问题,我会在下一篇中继续深入)。
行为驱动开发(BDD)正是为了解决上述问题而生的,作为第二代敏捷方法,BDD提倡的是通过将测试语句转换为类似自然语言的描述,开发人员可以使用更符合大众语言的习惯来书写测试,这样不论在项目交接/交付,或者之后自己修改时,都可以顺利很多。如果说作为开发者的我们日常工作是写代码,那么BDD其实就是在讲故事。一个典型的BDD的测试用例包活完整的三段式上下文,测试大多可以翻译为Given..When..Then的格式,读起来轻松惬意。BDD在其他语言中也已经有一些框架,包括最早的Java的JBehave和赫赫有名的Ruby的RSpec和Cucumber。而在objc社区中BDD框架也正在欣欣向荣地发展,得益于objc的语法本来就非常接近自然语言,再加上C语言宏的威力,我们是有可能写出漂亮优美的测试的。在objc中,现在比较流行的BDD框架有cedar,specta和Kiwi。其中个人比较喜欢Kiwi,使用Kiwi写出的测试看起来大概会是这个样子的:
describe(@"Team", ^{ context(@"when newly created", ^{ it(@"should have a name", ^{ id team = [Team team]; [[team.name should] equal:@"Black Hawks"]; }); it(@"should have 11 players", ^{ id team = [Team team]; [[[team should] have:11] players]; }); }); }); |
我们很容易根据上下文将其提取为Given..When..Then的三段式自然语言
Given a team, when newly created, it should have a name, and should have 11 players
很简单啊有木有!在这样的语法下,是不是写测试的兴趣都被激发出来了呢。关于Kiwi的进一步语法和使用,我们稍后详细展开。首先来看看如何在项目中添加Kiwi框架吧。
在项目中添加Kiwi
最简单和最推荐的方法当然是CocoaPods,如果您对CocoaPods还比较陌生的话,推荐您花时间先看一看这篇CocoaPods的简介。Xcode 5和XCTest环境下,我们需要在Podfile中添加类似下面的条目(记得将VVStackTests换成您自己的项目的测试target的名字):
target :VVStackTests, :exclusive => true do
pod 'Kiwi/XCTest'
end
之后pod install以后,打开生成的xcworkspace文件,Kiwi就已经处于可用状态了。另外,为了我们在新建测试的时候能省点事儿,可以在官方repo里下载并运行安装Kiwi的Xcode Template。如果您坚持不用CocoaPods,而想要自己进行配置Kiwi的话,可以参考这篇wiki。
行为描述(Specs)和期望(Expectations),Kiwi测试的基本结构
我们先来新建一个Kiwi测试吧。如果安装了Kiwi的Template的话,在新建文件中选择Kiwi/Kiwi Spec来建立一个Specs,取名为SimpleString,注意选择目标target为我们的测试target,模板将会在新建的文件名字后面加上Spec后缀。传统测试的文件名一般以Tests为后缀,表示这个文件中含有一组测试,而在Kiwi中,一个测试文件所包含的是一组对于行为的描述(Spec),因此习惯上使用需要测试的目标类来作为名字,并以Spec作为文件名后缀。在Xcode 5中建立测试时已经不会同时创建.h文件了,但是现在的模板中包含有对同名.h的引用,可以在创建后将其删去。如果您没有安装Kiwi的Template的话,可以直接创建一个普通的Objective-C test case class,然后将内容替换为下面这样:
#import <Kiwi/Kiwi.h>
SPEC_BEGIN(SimpleStringSpec)
describe(@"SimpleString", ^{
});
SPEC_END
你可能会觉得这不是objc代码,甚至怀疑这些语法是否能够编译通过。其实SPEC_BEGIN和SPEC_END都是宏,它们定义了一个KWSpec的子类,并将其中的内容包装在一个函数中(有兴趣的朋友不妨点进去看看)。我们现在先添加一些描述和测试语句,并运行看看吧,将上面的代码的SPEC_BEGIN和SPEC_END之间的内容替换为:
describe(@"SimpleString", ^{ context(@"when assigned to 'Hello world'", ^{ NSString *greeting = @"Hello world"; it(@"should exist", ^{ [[greeting shouldNot] beNil]; }); it(@"should equal to 'Hello world'", ^{ [[greeting should] equal:@"Hello world"]; }); }); }); |
describe描述需要测试的对象内容,也即我们三段式中的Given,context描述测试上下文,也就是这个测试在When来进行,最后it中的是测试的本体,描述了这个测试应该满足的条件,三者共同构成了Kiwi测试中的行为描述。它们是可以nest的,也就是一个Spec文件中可以包含多个describe(虽然我们很少这么做,一个测试文件应该专注于测试一个类);一个describe可以包含多个context,来描述类在不同情景下的行为;一个context可以包含多个it的测试例。让我们运行一下这个测试,观察输出:
VVStack[36517:70b] + 'SimpleString, when assigned to 'Hello world', should exist' [PASSED]
VVStack[36517:70b] + 'SimpleString, when assigned to 'Hello world', should equal to 'Hello world'' [PASSED]
可以看到,这三个关键字的描述将在测试时被依次打印出来,形成一个完整的行为描述。除了这三个之外,Kiwi还有一些其他的行为描述关键字,其中比较重要的包括
beforeAll(aBlock) - 当前scope内部的所有的其他block运行之前调用一次
afterAll(aBlock) - 当前scope内部的所有的其他block运行之后调用一次
beforeEach(aBlock) - 在scope内的每个it之前调用一次,对于context的配置代码应该写在这里
afterEach(aBlock) - 在scope内的每个it之后调用一次,用于清理测试后的代码
specify(aBlock) - 可以在里面直接书写不需要描述的测试
pending(aString, aBlock) - 只打印一条log信息,不做测试。这个语句会给出一条警告,可以作为一开始集中书写行为描述时还未实现的测试的提示。
xit(aString, aBlock) - 和pending一样,另一种写法。因为在真正实现时测试时只需要将x删掉就是it,但是pending语意更明确,因此还是推荐pending
可以看到,由于有context的存在,以及其可以嵌套的特性,测试的流程控制相比传统测试可以更加精确。我们更容易把before和after的作用区域限制在合适的地方。
实际的测试写在it里,是由一个一个的期望(Expectations)来进行描述的,期望相当于传统测试中的断言,要是运行的结果不能匹配期望,则测试失败。在Kiwi中期望都由should或者shouldNot开头,并紧接一个或多个判断的的链式调用,大部分常见的是be或者haveSomeCondition的形式。在我们上面的例子中我们使用了should not be nil和should equal两个期望来确保字符串赋值的行为正确。其他的期望语句非常丰富,并且都符合自然语言描述,所以并不需要太多介绍。在使用的时候不妨直接按照自己的想法来描述自己的期望,一般情况下在IDE的帮助下我们都能找到想要的结果。如果您想看看完整的期望语句的列表,可以参看文档的这个页面。另外,您还可以通过新建KWMatcher的子类,来简单地自定义自己和项目所需要的期望语句。从这一点来看,Kiwi可以说是一个非常灵活并具有可扩展性的测试框架。
到此为止的代码可以从这里找到。
Kiwi实际使用实例
最后我们来用Kiwi完整地实现VVStack类的测试和开发吧。首先重写刚才XCTest的相关测试:新建一个VVStackSpec作为Kiwi版的测试用例,然后把describe换成下面的代码:
describe(@"VVStack", ^{ context(@"when created", ^{ __block VVStack *stack = nil; beforeEach(^{ stack = [VVStack new]; }); afterEach(^{ stack = nil; }); it(@"should have the class VVStack", ^{ [[[VVStack class] shouldNot] beNil]; }); it(@"should exist", ^{ [[stack shouldNot] beNil]; }); it(@"should be able to push and get top", ^{ [stack push:2.3]; [[theValue([stack top]) should] equal:theValue(2.3)]; [stack push:4.6]; [[theValue([stack top]) should] equal:4.6 withDelta:0.001]; }); }); }); |
看到这里的您看这段测试应该不成问题。需要注意的有两点:首先stack分别是在beforeEach和afterEach的block中的赋值的,因此我们需要在声明时在其前面加上__block标志。其次,期望描述的should或者shouldNot是作用在对象上的宏,因此对于标量,我们需要先将其转换为对象。Kiwi为我们提供了一个标量转对象的语法糖,叫做theValue,在做精确比较的时候我们可以直接使用例子中直接与2.3做比较这样的写法来进行对比。但是如果测试涉及到运算的话,由于浮点数精度问题,我们一般使用带有精度的比较期望来进行描述,即4.6例子中的equal:withDelta:(当然,这里只是为了demo,实际在这用和上面2.3一样的方法就好了)。