Kiwi 是 iOS 的一个行为驱动开发 (Behavior Driven Development, BDD) 的测试框架,我们在上一篇入门介绍中 简单了解了一些 iOS 中测试的概念以及 Kiwi 的基本用法。其实 Kiwi 的强大远不止如此,它不仅包含了基本的期望和断言,也集成了一些相对高级的测试方法。在本篇中我们将在之前的基础上,来看看 Kiwi 的这些相对高级的用法,包括模拟对象 (mock),桩程序 (stub),参数捕获和异步测试等内容。这些方法都是在测试中会经常用到的,用来减少我们测试的难度的手段,特别是在耦合复杂的情况下的测试以及对于 UI 事件的测试。
Stub 和 Mock 的基本概念
如果您曾经有过为代码编写测试的经验,您一定会知道其中不易。我们编写生产代码让它能够工作其实并不很难,项目中编码方面的工作难点往往在于框架搭 建以及随着项目发展如何保持代码优雅可读可维护。而测试相比起业务代码的编写一般来说会更难一些,很多时候你会发现有些代码是“无法测试”的,因为代码之 间存在较高的耦合程度,因此绕不开对于其他类的依赖,来对某个类单独测试其正确性。我们不能依赖于一个没有经过测试的类来对另一个需要测试的类进行测试, 如果这么做了,我们便无法确定测试的结果是否正是按我们的需要得到的(不能排除测试成功,但是其实是因为未测试的依赖类恰好失败了而恰巧得到的正确结果的 可能性)。
Stub
解决的方法之一是我们用一种最简单的语言来“描述”那些依赖类的行为,而避免对它们进行具体实现,这样就能最大限度地避免出错。比如我们有一个复杂 的算法通过输入的温度和湿度来预测明天的天气,现在我们在存储类中暴露了一个方法,它接受输入的温度和湿度,通过之前复杂算法的计算后将结果写入到数据库 中。相关的代码大概是下面这个样子,假设我们有个 WeatherRecorder
类来做这件事:
1 2 3 4 5 6 7 |
//WeatherRecorder.m -(void) writeResultToDatabaseWithTemprature:(NSInteger)temprature humidity:(NSInteger)humidity { id result = [self.weatherForecaster resultWithTemprature:temprature humidity:humidity]; [self write:result]; } |
(虽然这个例子设计得不太好,因为服务层架构不对,但是其实) 在实际项目中是可能会有不少类似的代码。对于这样的方法和相应的 WeatherRecorder
应该如何测试呢?这个方法依赖了 weatherForecaster
的计算方法,而我们这里关心的更多的是 write 这个方法的正确性 (算法的测试应该被分开写在对应的测试中),对于计算的细节和结果我们其实并不关心。但是这个方法本身和算法耦合在了一起,我们当然可以说直接给若干组输 入,运行这个方法然后检测数据库中的结果是否与我们预期的一致,但是这其实做了假设,那就是:在测试中我们自己的计算结果和预报计算方法的结果是一致的。 这个假设可能在一开始是成立的,但是你无法知道在之后的开发中这个算法会不会改变,会变成怎样。也许之后有修正模型出现,结果和现在大相径庭,这时就会出 现 write 数据库的测试居然因为预报的算法变更而失败。这不仅使得测试涵盖了它不应该包括的内容,违背了测试的单一性,也凭添了不少麻烦。
一个完美的解决的方案是,我们人为地来指定计算的结果,然后测试数据库的写入操作。人为地让一个对象对某个方法返回我们事先规定好的值,这就叫做 stub
。
在 Kiwi 中写一个 stub 非常简单,比如我们有一个 Person
类的实例,我们想要 stub 让它返回一个固定的名字,可以这么写:
1 2 |
Person *person = [Person somePerson]; [person stub:@selector(name) andReturn:@“Tom”]; |
在这个 stub 下,如下测试将会通过,而不论 person 到底具体是谁:
1 2 |
NSString *testName = [person name]; [ testName should] equal:@“Tom”]; |
另外,对于我们之前天气预报例子中的带有参数的方法,我们可以使用 Kiwi stub 的带参数版本来进行替换,比如:
1 2 3 |
[weatherForecaster stub:@selector(resultWithTemprature:humidity:) andReturn:someResult withArguments:theValue(23),theValue(50)]; |
这时我们再给 weatherForecaster
发送参数为温度 23
和湿度 50
的消息时,方法会直接将 someResult
返回给我们,这样我们就可以不再依赖于天气预报算法的具体实现,也不用担心算法变更会破坏测试,而对数据库写入进行稳定的测试了。
对于 Kiwi 的 stub,需要注意的是它不是永久有效的,在每个 it
block 的结尾 stub 都会被清空,超出范围的方法调用将不会被 stub 截取到。
Mock
mock
是一个非常容易和 stub
混淆的概念。简单来说,我们可以将 mock
看做是一种更全面和更智能的 stub
。
首先解释全面,我们需要明确,mock 其实就是一个对象,它是对现有类的行为一种模拟(或是对现有接口实现的模拟)。在 objc 的 OOP 中,类或者接口就是指导对象行为的蓝图,而 mock 则遵循这些蓝图并模拟它们的实例对象。从这方面来说,mock 与 stub 最大的区别在于 stub 只是简单的方法替换,而不涉及新的对象,被 stub 的对象可以是业务代码中真正的对象。而 mock 行为本身产生新的(不可能在业务代码中出现的)对象,并遵循类的定义相应某些方法。
其次是更智能。基础上来说,和 stub 很相似,我们可以为创造的 mock 定义在某种输入和方法调用下的输出,更进一步,我们还可以为 mock 设定期望 (准确来说,是我们一定会为 mock 设定期望,这也是 mock 最常见的用例)。即,我们可以为一个 mock 指定这样的期望:“这个 mock 应该收到以 X 为参数的 Y 方法,并规定它的返回为 Z”。其中”应该收到以 X 为参数的 Y 方法”这个期望会在测试与其不符合时让你的测试失败,而“返回 Z” 这个描述行为更接近于一种 stub 的定义。XCTest 框架想要实现这样的测试例可以说要费九牛之力,但是这在 Kiwi 里却十分自然。
还是举上面的天气预报的例子。我们在 stub 时将 weatherForecaster
的方法替换处理了。细心的读者可能会有疑惑,问这个 weatherForecaster
是怎么来的。因为这个对象其实只是 WeatherRecorder
中一个属性,而且很有可能在测试时我们并不能拥有一个恰好合适的 weatherForecaster
。WeatherRecorder
是不需要将 weatherForecaster
暴露在头文件中的,VC 是不需要知道它的实现细节的),而我们在上面的 stub 的前提是我们能在测试代码中拿到这个 weatherForecaster
,很多时候只能修改代码将其暴露,但是这并不是好的实践,很多时候也并不现实。现在有了 mock 后,我们就可以自创一个虚拟的 weatherForecaster
,并为其设定期望的调用来确保我们输入温度和湿度确实经过了计算然后存入了数据库中了。mock 所使用的期望和普通对象的调用期望类似:
1 2 3 4 |
id weatherForecasterMock = [WeatherForecaster mock]; [[weatherForecasterMock should] receive:@selector(resultWithTemprature:humidity:) andReturn:someResult withArguments:theValue(23),theValue(50)]; |
然后,对于要测试的 weatherRecorder
实例,用 stub 将 -weatherForecaster 的返回换为我们的 mock:
1 |
[weatherRecorder stub:@selector(weatherForecaster) andReturn:weatherForecasterMock]; |
这样一来,在 -writeResultToDatabaseWithTemprature:humidity:
中我们就可以使用一个 mock 的 weatherForecaster
来完成工作,并检验是否确实进行了预报了。类似的组合用法在 mock/stub 测试中是比较常见的,在本文最后的例子中我们会再次见到类似的用法。
参数捕获
有时候我们会对 mock 对象的输入参数感兴趣,比如期望某个参数符合一定要求,但是对于 mock 而言一般我们是通过调用别的方法来验证 mock 是否被调用的,所以很可能无法拿到传给 mock 对象的参数。这种情况下我们就可以使用参数捕获来获取输入的参数。比如对于上面的 weatherForecasterMock
,如果我们想捕获温度参数,可以在调用测试前使用
1 |
KWCaptureSpy *spy = [weatherForecasterMock captureArgument:@selector(resultWithTemprature:humidity:) atIndex:0]; |
来加一个参数捕获。这样,当我们在测试中使用 stub 将 weatherForecaster
替换为我们的 mock 后,再进行如下调用
1 |
[weatherRecorder writeResultToDatabaseWithTemprature:23 humidity:50] |
后,我们可以通过访问 spy.argument
来拿到实际输入 resultWithTemprature:humidity:
的第一个参数。
在这个例子中似乎不太有用,因为我们输入给 -writeResultToDatabaseWithTemprature:humidity:
的参数和 -resultWithTemprature:humidity:
的是一样的。但是在某些情况下确实会很有效果,我们会在之后看到一个实际的使用例。
异步测试
异步测试是为了对后台线程的结果进行期望检验时所需要的,Kiwi 可以对某个对象的未来的状况书写期望,并进行检验。通过将要检验的对象加上 expectFutureValue
,然后使用 shouldEventually
即可。就像这样:
1 2 3 |
[[expectFutureValue(myObject) shouldEventually] beNonNil]; [[expectFutureValue(theValue(myBool)) shouldEventually] beYes]; |
比如在 REST 网络测试中,我们可能大部分情况下会选择用一组 mock 来替代服务器的返回进行验证,但是也不排除会有直接访问服务器进行测试的情况。在这种情况下我们就可以使用延时来进行异步测试。这里直接照抄一个官方 Wiki 的例子进行说明:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
context(@"Fetching service data", ^{ it(@"should receive data within one second", ^{ __block NSString *fetchedData = nil; [[LRResty client] get:@"http://www.example.com" withBlock:^(LRRestyResponse* r) { NSLog(@"That's it! %@", [r asString]); fetchedData = [r asString]; }]; [[expectFutureValue(fetchedData) shouldEventually] beNonNil]; }); }); |
这个测试保证了返回的 LRRestyResponse
对象可以转为一个字符串并且不是 nil
。
其实没什么神奇的,就是生成了一个延时的验证,在一定时间间隔后再对观测的对象进行检查。这个时间间隔默认是 1 秒,如果你需要其他的时间间隔的话,可以使用 shouldEventuallyBeforeTimingOutAfter
版本:
一个例子:测试 ViewController
举个实际一点的例子吧,我们来看看平时觉得难以测试的 UIViewController
的部分,包括一个 tableView
和对应的 dataSource
和 delegate
的测试方法。我们使用了 objc.io 第一期中的 Lighter View Controllers 和 Clean table view code 中的代码来实现一个简单可测试的 VC 结构,然后使用 Kiwi 替换完成了 Testing View Controllers 一文中的所有测试模块。这里篇幅有限,实现的具体细节就不在复述了,有兴趣的同学可以看看 objc.io 的这三篇文章,或者也可以在 objc 中国 上找到它们的译文:更轻量的 View Controllers,整洁的 Table View 代码以及测试 View Controllers。
我们在这里结合 Kiwi 的方法对重写的测试部分进行一些说明。objc.io 原来的项目使用的是 OCMock 实现的解耦测试,而为了进行说明,我用 Kiwi 简单重写了测试部分的代码,这个项目也可以在 Github 上找到。
对于 ArchiveReading
的测试都是 Kiwi 最基本的内容,在上一篇文章中已经详细介绍过了;对于 PhotoCell
的测试形式上比较新颖,其实是一个对 xib 的测试,保证了 xib 的初始化和 outlet 连接的正确性,但是测试内容也比较基本。剩下的是对于 tableView 的 dataSource 和 viewController 的测试,我们来具体看看。
Data Source 的测试
首先是 ArrayDataSourceSpec
,得益于将 array 的 dataSource 进行抽象和封装,我们可以单独对其进行测试。基本思路是我们希望在为一个 tableView 设置好数据源后,tableView 可以正确地从数据源获取组织 UI 所需要的信息,基本上来说,也就是能够得到“有多少行”以及“每行的 cell 是什么”这两个问题的答案。到这里,有写过 iOS 的开发者应该都明白我们要测试的是什么了。没错,就是 -tableView:numberOfRowsInSection:
以及 -tableView:cellForRowAtIndexPath:
这两个接口的实现。
测试用例关键代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
TableViewCellConfigureBlock block = ^(UITableViewCell *a, id b){ configuredCell = a; configuredObject = b; }; ArrayDataSource *dataSource = [[ArrayDataSource alloc] initWithItems:@[@"a", @"b"] cellIdentifier:@"foo" configureCellBlock:block]; id mockTableView = [UITableView mock]; UITableViewCell *cell = [[UITableViewCell alloc] init]; it(@"should be 2 items", ^{ NSInteger count = [dataSource tableView:mockTableView numberOfRowsInSection:0]; [[theValue(count) should] equal:theValue(2)]; }); __block id result = nil; NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0]; it(@"should receive cell request", ^{ [[mockTableView should] receive:@selector(dequeueReusableCellWithIdentifier:forIndexPath:) andReturn:cell withArguments:@"foo",indexPath]; result = [dataSource tableView:mockTableView cellForRowAtIndexPath:indexPath]; }); |
为了简要说明,我改变了 repo 中的代码组织结构,不过意思是一样的。我们要测试的是 ArrayDataSource
类,因此我们生成一个实例对象。在测试中我们不希望测试依赖于 UITableView
,因此我们 mock 了一个对象代替之。接下来向 dataSource 发送询问元素个数的方法,这里应该毫无疑问返回数组中的元素数量。接下来我们给