可以看到,VVStackTests是XCTestCase的子类,而XCTestCase正是XCTest测试框架中的测试用例类。XCTest在进行测试时将会寻找测试target中的所有XCTestCase子类,并运行其中以test开头的所有实例方法。在这里,默认实现的-testExample将被执行,而在这个方法里,Xcode默认写了一个XCTFail的断言,来强制这个测试失败,用以提醒我们测试还没有实现。所谓断言,就是判断输入的条件是否满足。如果不满足,则抛出错误并输出预先规定的字符串作为提示。在这个Fail的断言一定会失败,并提示没有实现该测试。另外,默认还有两个方法-setUp和-tearDown,正如它们的注释里所述,这两个方法会分别在每个测试开始和结束的时候被调用。我们现在正要开始编写我们的测试,所以先将原来的-testExample删除掉。现在再使用?U来进行测试,应该可以顺利通过了(因为我们已经没有任何测试了)。
接下来让我们想想要做什么吧。我们要实现一个简单的栈数据结构,那么当然会有一个类来代表这种数据结构,在这个工程中我打算就叫它VVStack。按照常规,我们可以新建一个Cocoa Touch类,继承NSObject并且开始实现了。但是别忘了,我们现在在TDD,我们需要先写测试!那么首先测试的目标是什么呢?没错,是测试这个VVStack类是否存在,以及是否能够初始化。有了这个目标,我们就可以动手开始编写测试了。在文件开头加上#import "VVStack.h",然后在VVStackTests.m的@end前面加上如下代码:
- (void)testStackExist { XCTAssertNotNil([VVStack class], @"VVStack class should exist."); } - (void)testStackObjectCanBeCreated { VVStack *stack = [VVStack new]; XCTAssertNotNil(stack, @"VVStack object can be created."); } |
当然是不可能通过测试的,而且甚至连编译都无法完成,因为我们现在根本没有一个叫做VVStack的类。最简单的让测试通过的方法就是在产品代码中添加VVStack类。新建一个Cocoa Touch的Objective-C class,取名VVStack,作为NSObject的子类。注意在添加的时候,应该只将其加入产品的target中:
添加类的时候注意选择合适的target
由于VVStack是NSObject的子类,所以上面的两个断言应该都能通过。这时候再运行测试,成功变绿。接下来我们开始考虑这个类的功能:栈的话肯定需要能够push,并且push后的栈顶元素应该就是刚才所push进去的元素。那么建立一个push方法的测试吧,在刚才添加的代码之下继续写:
- (void)testPushANumberAndGetIt { VVStack *stack = [VVStack new]; [stack push:2.3]; double topNumber = [stack top]; XCTAssertEqual(topNumber, 2.3, @"VVStack should can be pushed and has that top value."); } |
因为我们还没有实现-push:和-top方法,所以测试毫无疑问地失败了(在ARC环境中直接无法编译)。为了使测试立即通过我们首先需要在VVStack.h中声明这两个方法,然后在.m的实现文件中进行实现。令测试通过的最简单的实现是一个空的push方法以及直接返回2.3这个数:
//VVStack.h @interface VVStack : NSObject - (void)push:(double)num; - (double)top; @end //VVStack.m @implementation VVStack - (void)push:(double)num { } - (double)top { return 2.3; } @end |
再次运行测试,我们顺利回到了绿灯状态。也许你很快就会说,这算哪门子实现啊,如果再增加一组测试例,比如push一个4.6,然后检查top,不就失败了么?我们难道不应该直接实现一个真正的合理的实现么?对此的回答是,在实际开发中,我们肯定不会以这样的步伐来处理像例子中这样类似的简单问题,而是会直接跳过一些error-try的步骤,实现一个比较完整的方案。但是在更多的时候,我们所关心和需要实现的目标并不是这样容易。特别是在对TDD还不熟悉的时候,我们有必要放慢节奏和动作,将整个开发理念进行充分实践,这样才有可能在之后更复杂的案例中正确使用。于是我们发扬不怕繁杂,精益求精的精神,在刚才的测试例上增加一个测试,回到VVStackTests.m中,在刚才的测试方法中加上:
- (void)testPushANumberAndGetIt { //... [stack push:4.6]; topNumber = [stack top]; XCTAssertEqual(topNumber, 4.6, @"Top value of VVStack should be the last num pushed into it"); } |
很好,这下子我们回到了红灯状态,这正是我们所期望的,现在是时候来考虑实现这个栈了。这个实现过于简单,也有非常多的思路,其中一种是使用一个NSMutableArray来存储数据,然后在top方法里返回最后加入的数据。修改VVStack.m,加入数组,更改实现:
//VVStack.m @interface VVStack() @property (nonatomic, strong) NSMutableArray *numbers; @end @implementation VVStack - (id)init { if (self = [super init]) { _numbers = [NSMutableArray new]; } return self; } - (void)push:(double)num { [self.numbers addObject:@(num)]; } - (double)top { return [[self.numbers lastObject] doubleValue]; } @end |
测试通过,注意到在-testStackObjectCanBeCreated和testPushANumberAndGetIt两个测试中都生成了一个VVStack对象。在这个测试文件中基本每个测试都会需要初始化对象,因此我们可以考虑在测试文件中添加一个VVStack的实例成员,并将测试中的初始化代码移到-setUp中,并在-tearDown中释放。
接下来我们可以模仿继续实现pop等栈的方法。鉴于篇幅这里不再继续详细实现,大家可以自己动手试试看。记住先实现测试,然后再实现产品代码。一开始您可能会觉得这很无聊,效率低下,但是请记住这是起步练习不可缺少的一部分,而且在我们的例子中其实一切都是以“慢动作”在进行的。相信在经过实践和使用后,您将会逐渐掌握自己的节奏和重点测试。关于使用XCTest到这里为止的代码,可以在github上找到。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一样的方法就好了)。 接下来我们再为这个context添加一个测试例,用来测试初始状况时栈是否为空。因为我们使用了一个Array来作为存储容器,根据我们之前用过的equal方法,我们很容易想到下面这样的测试代码
it(@"should equal contains 0 element", ^{
[[theValue([stack.numbers count]) should] equal:theValue(0)];
});
这段测试在逻辑上没有太大问题,但是有非常多值得改进的地方。首先如果我们需要将原来写在Extension里的numbers暴露到头文件中,这对于类的封装是一种破坏,对于这个,一种常见的做法是只暴露一个-count方法,让其返回numbers的元素个数,从而保证numbers的私有性。另外对于取值和转换,其实theValue的存在在一定程度上是破坏了测试可读性的,我们可以想办法改善一下,比如对于0的来说,我们有beZero这样的期望可以使用。简单改写以后,这个VVStack.h和这个测试可以变成这个样子:
//VVStack.h //... - (NSUInteger)count; //... //VVStack.m //... - (NSUInteger)count { return [self.numbers count]; } //... it(@"should equal contains 0 element", ^{ [[theValue([stack count]) should] beZero]; }); |
更进一步地,对于一个collection来说,Kiwi有一些特殊处理,比如have和haveCountOf系列的期望。如果测试的对象实现了-count方法的话,我们就可以使用这一系列期望来写出更好的测试语句。比如上面的测试还可以进一步写成
it(@"should equal contains 0 element", ^{
[[stack should] haveCountOf:0];
});
在这种情况下,我们并没有显式地调用VVStack的-count方法,所以我们可以在头文件中将其删掉。但是我们需要保留这个方法的实现,因为测试时是需要这个方法的。如果测试对象不能响应count方法的话,如你所料,测试时会扔一个unrecognized selector的错。Kiwi的内部实现是一个大量依赖了一个个行为Matcher和objc的消息转发,对objcruntime特性比较熟悉,并想更深入的朋友不放可以看看Kiwi的源码,写得相当漂亮。
其实对于这个测试,我们还可以写出更漂亮的版本,像这样:
it(@"should equal contains 0 element", ^{
[[stack should] beEmpty];
});
好了。关于空栈这个情景下的测试感觉差不多了。我们继续用TDD的思想来完善VVStack类吧。栈的话,我们当然需要能够-pop,也就是说在(Given)给定一个栈时,(When)当栈中有元素的时候,(Then)我们可以pop它,并且得到栈顶元素。我们新建一个context,然后按照这个思路书写行为描述(测试):
context(@"when new created and pushed 4.6", ^{ __block VVStack *stack = nil; beforeEach(^{ stack = [VVStack new]; [stack push:4.6]; }); afterEach(^{ stack = nil; }); it(@"can be poped and the value equals 4.6", ^{ [[theValue([stack pop]) should] equal:theValue(4.6)]; }); it(@"should contains 0 element after pop", ^{ [stack pop]; [[stack should] beEmpty]; }); }); |
完成了测试书写后,我们开始按照设计填写产品代码。在VVStack.h中完成申明,并在.m中加入相应实现。
- (double)pop {
double result = [self top];
[self.numbers removeLastObject];
return result;
}
很简单吧。而且因为有测试的保证,我们在提供像Stack这样的基础类时,就不需要等到或者在真实的环境中检测了。因为在被别人使用之前,我们自己的测试代码已经能够保证它的正确性了。VVStack剩余的最后一个小问题是,在栈是空的时候,我们执行pop操作时应该给出一个错误,用以提示空栈无法pop。虽然在objc中异常并不常见,但是在这个情景下是抛异常的好时机,也符合一般C语言对于出空栈的行为。我们可以在之前的“when created”上下文中加入一个期望:
it(@"should raise a exception when pop", ^{
[[theBlock(^{
[stack pop];
}) should] raiseWithName:@"VVStackPopEmptyException"];
});
和theValue配合标量值类似,theBlock也是Kiwi中的一个转换语法,用来将一段程序转换为相应的matcher,使其可以被施加期望。这里我们期望空的Stack在被pop时抛出一个叫做”VVStackPopEmptyException”的异常。我们可以重构pop方法,在栈为空时给一个异常:
- (double)pop { if ([self count] == 0) { [NSException raise:@"VVStackPopEmptyException" format:@"Can not pop an empty stack."]; } double result = [self top]; [self.numbers removeLastObject]; return result; } |
进一步的Kiwi
VVStack的测试和实现就到这里吧,根据这套测试,您可以使用自己的实现来轻易地重构这个类,而不必担心破坏它的公共接口的行为。如果需要添加新的功能或者修正已有bug的时候,我们也可以通过添加或者修改相应的测试,来确保正确性。我将会在下一篇博文中继续介绍Kiwi,看看Kiwi在异步测试和mock/stub的使用和表现如何。Kiwi现在还在比较快速的发展中,官方repo的wiki上有一些不错的资料和文档,可以参考。VVStack的项目代码可以在这个repo上找到,可以作为参考。