本文转载自微信公众号「Swift社区」,作者五更琉璃0。转载本文请联系Swift社区公众号。
前言
写代码要有单测已经不是什么新鲜理论了。对iOS开发者来说,XCode自带了一个还不错(?)的TDD测试框架XCTest。但聪明的开发者们很快就基于XCTest编写了更好的框架,比如许多团队在单测中使用的开源框架:Kiwi。
虽然Kiwi自称BDD框架,但我觉得我们对一个框架是所谓TDD还是BDD大可不必过于纠结,毕竟彼此都能做对方的事情,只看你怎么用。
(如果你不清楚这两个缩写是什么意思,或者在接下来的内容里看到使用Kiwi的代码时无法望文生义地大致理解代码意义,可以看一看这篇文章[1])
Kiwi语义清晰,实现方式优雅,用它写出来的测试代码会有很好的可读性。今天我们来根据一个简化的小场景,一步一步学习使用Kiwi,并稍微深入地探究一下Kiwi中各个功能的实现原理。
出于可读性的考虑,在这篇文章中会尽量规避粘贴大段源代码的情况。此外,出现的Kiwi源码大多经过了一定的调整。
背景
假设我们的项目中有一个加载图片用的类RKImageLoader,它提供以下两个接口:
/************* RKImageDownloader.h **************/
@interface RKImageDownloader : NSObject
/// 也许是一张本地的兜底图,会根据传入的参数进行裁剪,最终得到的image.size等于
/// 传入size * UIScreen.mainScreen.scale
- (UIImage *)defaultImageWithSize:(CGSize)size;
/// 调用底层组件HYNetworkEngine提供的接口下载图片,将结果通知给delegate
- (void)downloadImageWithURL:(NSString *)url
delegate:(id<RKImageDownloaderDelegate>)delegate;
@end
@protocol RKImageDownloaderDelegate <NSObject>
@required
/// 通知下载结果
- (void)downloadCompleteWithImage:(UIImage * _Nullable)image
error:(NSError * _Nullable)error;
@end
上面说到downloadImageWithURL:delegate:方法调用了底层网络组件HYNetworkEngine提供的接口获取图片,后者是这样声明的:
/************* HYNetworkEngine.h **************/
@interface HYNetworkEngine : NSObject
// HYNetworkEngine提供的网络下载接口
+ (void)requestImageWithURL:(NSString *)url
completionBlock:(void(^)(UIImage * _Nullable image,
NSError * _Nullable error)block;
@end
HYNetworkEngine作为底层框架,我们在测试中予以充分的信任。我们要测试的是RKImageLoader,根据它提供的接口,我们至少要确认以下几件事才能认为这个类是在正常工作的:
- 调用defaultImageWithSize:方法会返回一个非空的UIImage
- 上面说到的image的size应当与我们传入的参数相对应
- 传入正确的URL调用downloadCompleteWithImage:delegate:方法后,在规定时间内,传入的delegate应该会收到downloadCompleteWithImage:error:回调,其中参数image非空,而error为nil。
下面,我们将以完成这几个测试用例为目标,开始我们的探索Kiwi之旅。
Spec
我们先来创建一个XCTest文件,文件的名字应该叫{你要测试的类名+Spec.m},在本例中就是RKImageDownloaderSpec.m。以下是这个测试文件中的全部代码:
#import <Kiwi/Kiwi.h>
#import "RKImageDownloader.h"
SPEC_BEGIN(RKImageDownloaderSpec)
describe(@"test RKImageDownloader", ^{
// 声明一个名为`download`的变量,在执行每个测试用例前,会调用你传入的block
// 并将downloader重置为该block的返回值
let(downloader, ^{
return RKImageDownloader.new;
});
// 这种声明方式和`let`方式是等价的,但可以感受到语义上的不同。
// 在本段代码中将使用以`let`方式定义的downloader
__block RKImageDownloader *downloader1;
beforeEach(^{
downloader1 = RKImageDownloader.new;
});
// context的description应当足够清晰,能方便后面的人知道把新的用例加在哪里
context(@"get default image", ^{
// context中做一些测试的准备
UIImage *image = [downloader defaultImageWithSize:CGSizeMake(20, 20)];
// it的description应当是一个判断句,陈述你的预期
it(@"RKImageDownloader should return a image after calling `defaultImageWithSize:`", ^{
// it里用于存放进行判断(expectation)所**必要**的语句
// 公共的、可以被当前context下多个it共享的逻辑,应尽量写在context里
[[image should] beKindOfClass:RKImageDownloader.class];
});
it(@"the return image should have the expected size", ^{
CGFloat scale = UIScreen.mainScreen.scale;
[[theValue(image.size) should] equal:theValue(CGSizeMake(scale * 20, scale * 20))];
});
});
// 我们一会再来测试它的下载能力,现在先在这里做个标记
pending(@"the downloader should be able to download image", ^{});
});
SPEC_END
基于Kiwi的强大可读性,相信你就算没有接触过单元测试,也能大概看懂上面的代码做了什么。首先,每个代码块都是以XXXX(description, ^{})的形式开始的。这里的XXXX在Kiwi中被称为Spec。我们先来了解下每个Spec的功能:
- describe: 整个测试文件的最外层节点,用于描述你在这个测试文件中想要做什么
- context: 你可以把它理解为一个环境。很多情况下,你对测试类的某一个操作(或调用)涉及到多个测试用例。你可以在一个context里执行这个操作,再把用例一个一个写在这个context里
注意:context可以嵌套。实际上,describe就是一个context,它们的实现完全相同,只有语义上的区别。整个测试文件可以看做由一个describe(也即context)为根组成的树 。其中除了context可以有子结点外,其他节点(it, let等等)都是叶结点(实际上,Kiwi会添加一个虚拟结点作为根,我们的describe只是第一层子节点)
- it: 每个it结点可以代表一个测试用例,你应当将你的expectation语句写在这里。it的description应当是一个判断句,来描述你的预期结果
- beforeEach、afterEach、beforeAll、afterAll:功能和名字一样。值得一提的是,你在一个context中声明的beforeEach、afterEach,对所有子context都会产生同样的效果
- let: 实际上是一个宏,可以定义变量。和beforeEach类似,let的block在当前context下的所有it执行之前都会调用一次。但是用let进行声明会有更清晰的语义。此外,与beforeEach不同,每个context中可以包含任意数量的let
顺便一提,let是在beforeEach之前调用的
- pending: pending虽然接收一个block,但里面的代码并不会执行。它只会给你一个warning。可以用来做TODO mark
Kiwi中还有两个上面的代码中没有提到的Spec类型:
- specify: 实际上就是一个不需要传入description的it结点
- registerMatchers: 你可以传入一个前缀(如RK),从而你可以在这个context下使用所有matcher,且它们的前缀都是RK(如RKNilMatcher)。你可以在这里[2]看到Kiwi的matchers
这个方法的作用会在文章的最后一部分详细介绍,你可以暂时忽略它
以上就是Kiwi中定义的全部Spec。不难看出,Kiwi对语义的精确性和结构的清晰性有很深的执念。
let声明的变量类型是什么样的?Kiwi的实现方法很骚。参考下面的定义代码:
#define let(var, ...) \
__block __typeof__((__VA_ARGS__)()) var; \
let_(KW_LET_REF(var), #var, __VA_ARGS__)
可以看出,变量的类型是调用你的block,从返回值中获取的。也就是说,let block可能并不像你预期的那样在执行每个用例前调用精确的一次。具体会调用几次?我们后面会分析。
另外,如果你在LLDB内查看var的类型,它会显示是这样的:typeof((^{})()) (C++:论抽象程度还是比我的[](){}稍逊一筹)
构建Spec Tree
这段看起来有点奇怪的代码到底做了什么呢?我们之前说了,这段代码是整个测试文件的全部代码,那测试类又是在哪里声明的呢?相信你注意到了最开头的SPEC_BEGIN(RKImageDownloaderSpec)和结尾的SPEC_END。这是两个宏,我们来看看它们的定义:
#define SPEC_BEGIN(name) \
@interface name : KWSpec \
@end \
@implementation name \
+ (NSString *)file { return @__FILE__; } \
+ (void)buildExampleGroups { \
[super buildExampleGroups]; \
id _kw_test_case_class = self; \
{ \
/* The shadow `self` must be declared inside a new scope to avoid compiler warnings. */ \
/* The receiving class object delegates unrecognized selectors to the current example. */ \
__unused name *self = _kw_test_case_class;
#define SPEC_END \
} \
} \
@end
通过这段定义我们知道了两件事:
- 我们声明的RKImagwDownloaderSpec类是KWSpec的子类,重写了一个叫buildExampleGroups的方法
- 我们的测试代码是放在buildExampleGroups的方法体里的
实际上,KWSpec作为XCTextCase的子类,重写了+ (NSArray *)testInvocations方法以返回所有测试用例对应的Invocation。在执行这个方法的过程中,会使用KWExampleSuiteBuilder构建Spec树。
KWExampleSuiteBuilder会先创建一个根节点,然后调用我们的buildExampleGroups方法,以DFS的方式构建Spec树。当前的结点路径记录在KWExampleSuiteBuilder单例的contextNodeStack中,栈顶元素就是此时的context结点。
在每个结点里,都有一个KWCallSite的字段,里面有两个属性:fileName和lineNumber,用于在测试失败时精确指出问题出现在哪一行,这很重要。这些信息是在运行时通过atos命令获取的。如果你感兴趣,可以在KWSymbolicator.m[3]中看到具体的实现
这样就很容易理解我们写的Spec本质上是什么了:context(...)是调用一个叫context的C函数,将当前context结点入栈,并加到上层context的子节点列表中,然后调用block()。let(...)宏展开后是声明一个变量,并调用let_函数将一个let结点加到当前context的letNodes列表里。其他节点的行为也都大致相同。
这里特别说明一下it和pending,除了把自己添加到当前的context里之外,还会创建一个KWExample,后者是一个用例的抽象。它会被加到一个列表中,用于后续执行测试时调用。
在buildExampleGroups方法中,Kiwi构建了内部的Spec树,根节点记录在KWExampleSuite对象里,后者被存储在KWExampleSuiteBuilder的一个数组中。此外,在构建过程中遇到的所有it结点和pending结点,也都各自生成了KWExample对象,按照正确的顺序加入到了KWExampleSuite对象中。万事俱备。现在只需要返回所有test case对应的Invocation,后面就交给系统框架去调用啦。
这些invocation的IMP是KWSpec对象里的runExample方法。但Kiwi为了给方法一个更有意义的名字,在运行时创建了新的selector,这个新selector根据当前Spec以及context的description,用驼峰命名组合而成的。虽然此举是出于提高可读性的考虑,但实际上组合出来的名字总是非常冗长,读起来很困难。
执行测试用例
就在刚刚,Kiwi已经构建出了一个清晰漂亮的Spec Tree,并把所有用例抽象成一个个KWExample,在testInvocations方法中返回了它们对应的Invocation。现在一切已经准备妥当,系统组件要开始调用Kiwi返回的Invocation了。之前我们说了,这些Invocation的实现是runExample,它会做什么呢?
我们只讨论it结点。因为pending结点实际上并不会做什么实质性的事情。经过层层调用,首先会进入KWExample的visitItNode:方法里。这个方法将以下所有操作包装进一个block里(我们叫它block1):
- 执行你写在it block里的代码——你的部分用例在这一步就已经完成了检查
- 对自身的verifiers进行自检——这就是检查你另一部分用例是否通过的时机。后面我们还会详细说明
- 如果有expectation没有被满足,报告用例失败,否则报告通过
- 清除所有的spy和stub (不影响mock对象)。这意味着如果你希望在整个用例里都执行某个stub或spy,那么你最好把它写进beforeEach里
不清楚spy和stub、mock是什么?不用着急,下一部分就会讲到它们。到时候十分建议你回头再来看一眼。为了帮你更好的掌握这个知识点,我还准备了一个小测试,你可以在回来时尝试一下(或者现在也可以,如果你已经了解了stub和mock的用法的话)
一个小测试
describe(@"describe", ^{
context(@"context", ^{
NSObject *obj1 = [NSObject new];
NSObject *obj2 = [NSObject mock];
[obj1 stub:@selector(description) andReturn:@"obj1"];
[obj2 stub:@selector(description) andReturn:@"obj2"];
it(@"it1", ^{
printf("------ obj1 : %s\n", obj1.description.UTF8String);
printf("------ obj2 : %s\n", obj2.description.UTF8String);
});
it(@"it2", ^{
printf("------ obj1 : %s\n", obj1.description.UTF8String);
printf("------ obj2 : %s\n", obj2.description.UTF8String);
});
});
});
假设默认的Object description输出为,这段代码的输出会是什么呢?
答案
obj1 : obj1
obj2 : obj2
obj1 : <NSObject: 0xXXXXXXXX>
obj2 : obj2
KWExample会以刚刚生成的block1为参数,继续调用KWContextNode的performExample:withBlock:方法,后者又会将以下操作包装进一个block里(我们称为block2):
- 首先它会通知context去依次执行registerMatcher、beforeAll、let、beforeEach逻辑。其中,执行let过程中,会用let结点构建一棵letNodeTree,每个结点记录自己的子节点和邻接兄弟节点。然后从根节点到当前context的最后一个let节点,依次执行其block。
- 调用block1
- 最后,再执行afterEach、afterAll block
KWExample不一定会立即执行block2,而是会检查自己是否有父context,如果有的话,递归调用performExample:withBlock:。也就是说,会从context树从根节点到当前节点,依次执行before...、let等block,全部执行完后才会执行你写的it block,并完成expectation的检查。最后从当前节点到根节点,依次执行after... block。
看到递归这两个字,想必你已经对各个节点的调用路径有比较清晰的认识了吧?我们来稍微挑战一下,看看你对每个Spec的调用时机掌握得如何:
describe(@"describe", ^{
context(@"context1", ^{
printf("enter context1\n");
beforeEach(^{
printf("context1 beforeEach\n");
});
afterAll(^{
printf("context1 afterAll\n");
});
let(let1, ^id{
printf("enter let1\n");
return RKImageLoader.new;
});
it(@"it1", ^{
printf("enter it1\n\n");
});
context(@"context2", ^{
printf("enter context2\n");
beforeEach(^{
printf("context2 beforeEach\n");
});
afterAll(^{
printf("context2 afterAll\n");
});
let(let2, ^id{
printf("enter let2\n");
return RKImageLoader.new;
});
it(@"it2", ^{
printf("enter it2\n\n");
});
it(@"it3", ^{
printf("enter it3\n\n");
});
});
});
});
如果你确实准备了解Kiwi的执行顺序,那么你最好先好好思考一番,写下来你的答案,再和正确答案对比:
答案点这里
enter context1
enter context2
enter let1
context1 beforeEach
enter let1
enter let2
context2 beforeEach
enter it2
enter let1
enter let2
context1 beforeEach
enter let1
enter let2
context2 beforeEach
enter it3
context2 afterAll
context1 afterAll
enter let1
context1 beforeEach
enter it1
不出意外的话,我们的测试类前面已经出现了一个绿色的小:heavy_check_mark:。我们已经成功测试了defaultImageWithSize:方法。接下来我们准备更进一步,测试另一个接口:downloadCompleteWithImage:delegate:
Mock & Stub
正当我们准备着手像之前一样写测试用例时,忽然意识到情况似乎有些不对。这个场景的特殊之处在于涉及到了网络下载。根据网络状况的不同,即使多次传入了相同的URL,底层下载组件HYNetworkEngine可能有时会下载到图片,从而RKImageDownloader将结果传给delegate,测试成功;有时只会给你一个error,导致测试失败。也就是说,相同输入下,测试的结果可能是不同的。这违背了测试最基本的原则。
解决这个问题的办法是:不走网络下载,改为读取本地资源。
可是现有的实现里,RKImageDownloader调用了HYNetworkEngine的requestImage...方法,后者已经写死了发送网络请求,难道要用runtime接口在运行时替换它的实现吗?想想就很头痛。好在Kiwi已经帮我们做了这个事情,那就是stub。
简单地说,对一个对象进行stub,传入你想要替换的selector,再传入一个block。这样调用那个selector时就不会执行它原本的逻辑,而是会执行你的block。如果你没太听懂,没关系,后面会给出例子。
我们先来考虑另外一个问题:downloadImageWithURL:delegate:方法会把结果通知给delegate参数。但我们上哪里去找这个delegate呢?难道要新创建一个类,让它遵循RKImageDownloaderDelegate协议,然后实例化一个它的对象,在收到回调的时候更新自己的某个flag字段,然后在it中延时查询这个flag吗?虽然可以,但这已经不只是优不优雅的问题,这简直就是在犯罪。
Kiwi的mock能力完美的解决了这个问题。简单地说,你只需要传入一个protocol(当然也可以是类)的名字,就可以生成“遵循了该协议”的KWMock对象。这样,你就可以stub这个对象,让它实现downloadCompleteWithImage:error:代理方法,然后把它作为参数传给HYNetworkEngine。问题得到了完美的解决。
如果上面的说明没有让你明白stub和mock的功能是什么,可以先试着读一读下面的代码。如果还是看不懂,可以参考这篇文章[4]。作者对stub和mock的功能做了很详细(但并不深入)的介绍。
现在让我们应用 stub 和 mock 这两个特性,补充上面的测试代码:
context(@"test downloading image", ^{
__block UIImage *resultImage;
__block NSError *resultError;
RKImageDownloader *mockedDelegate = [KWMock mockForProtocol:@protocol(RKImageDownloaderDelegate)];
// 虽然本例只有一个测试用例,但我将`stub`操作放进`beforeEach`里,用于提醒你别忘了
// 所有的`stub`都会在`it` block结束时被清除
beforeEach(^{
// 这最好被抽出来作为一个单独的工具方法
[HYNetworkEngine stub:@selector(requestImageWithURL:completionBlock:)
withBlock:^id(NSArray *params) {
// 读取参数。注意,由于params是放在一个数组里的,所有的nil都被转成了[NSNull null]
NSString *url = params[0];
void(^block)(UIImage *, NSError *) = params[1];
// 参数合法性检查
if ([block isEqual:[NSNull null]]) {
return nil;
}
NSError *fileNotFoundError = [NSError errorWithDomain:@"domain.fileNotExist" code:-1 userInfo:nil];
if ([url isEqual:[NSNull null]]) {
block(nil, fileNotFoundError);
return nil;
}
// 查找本地资源
NSBundle *bundle = [NSBundle bundleForClass:self.class];
NSString *path = [bundle pathForResource:url ofType:nil];
UIImage *image = [UIImage imageWithContentsOfFile:path];
// 模拟下载耗时
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
block(image, image == nil ? fileNotFoundError : nil);
});
return nil;
}];
});
// 我把这句`stub`调用写在了`beforeEach`之外,你知道为什么吧?
[mockedDelegate stub:@selector(downloadCompleteWithImage:error:) withBlock:^id(NSArray *params) {
resultImage = [params[0] isEqual:[NSNull null] ? nil : params[0]];
resultError = [params[1] isEqual:[NSNull null] ? nil : params[1]];
}];
it(@"the downloader should be able to download image", ^{
[[expectFutureValue(resultImage) shouldEventuallyBeforeTimingOutAfter(0.1)] beNonNil];
[[expectFutureValue(resultError) shouldEventuallyBeforeTimingOutAfter(0.1)] beNil];
});
});
Mock
我们来介绍一下Kiwi中生成一个Mock的方法:
- 使用Kiwi为NSObject添加的类方法+ (id)mock; 来mock某个类
- 使用[KWMock mockForProtocol:] 来生成一个遵循了某协议的对象
- 使用[KWMock partialMockForObject:] 来根据已有object生成一个mock了该object类型的对象
KWMock还提供了nullMockFor...方法。与上面方法的不同在于:当mock对象收到了没有被stub过的调用(更准确的说,走进了消息转发的forwoardInvocation:方法里)时:
- nullMock: 就当无事发生,忽略这个调用
- partialMock: 让初始化时传入的object来响应这个selector
- 普通Mock:抛出exception
现在假设我们以[HYNetworkEngine mock]方法生成了一个KWMock对象,来看看这个有用的功能是怎么实现的
Stub a Method
下面介绍了你在stub一个mock对象时时,可能会用到的参数:
- (SEL)selector 被stub方法的selector
- (id (^)(NSArray params))block 当被stub的方法被调用时,执行这个block,此block的返回值也将作为这次调用的返回值
- (id)firstArgument, ... argument filter, 如果在调用某个方法时,传入的参数不和argumentList中的值一一对应且完全相等,那么这次调用就不会走stub逻辑
- (id)returnValue 调用被stub方法时,直接返回这个值。注意:如果你希望返回的是一个数值类型,那么你应该用theValue()函数包装它,而不是用@()指令。(theValue(0.8)√ / @(0.8)×)
当你调用了[anHYNetworkEngineMock stub:@selector(requestImageWithURL:completionBlock:) withBlock:^id(NSArray *params) {...}]时发生了什么?
在更详细的分析之前,最好简单说明一下SEL, NSMethodSignature和NSInvocation。在ObjC里,声明一个函数显然需要四个要素:方法名、返回值类型、参数数量和参数类型。其中至少需要方法名和参数数量这两个要素就能唯一确定一个方法——不需要参数类型,原因你懂的。
大多数情况下SEL是一个字符串,可以理解为对函数的一种编码,其中正是蕴含了方法名和参数数量两个信息。
NSMethodSignature则包含了函数声明的另外两个信息:返回值类型和参数类型——当然,它也得知道参数的数量。NSMethodSignature使用一种简洁的编码来记录这些信息,例如"v"代表"void","@"代表"id",":"代表"SEL"。因此"v@:"就描述了一个返回值为void,分别接收id和SEL两个参数的函数。此外,NSMethodSignature还封装了一些便捷的方法用于获取参数/返回值的类型、占用字节数等信息。(如果想更详细了解关于ObjC类型编码的信息,可以阅读这篇文章[5])
SEL和NSMethodSignature都是对一个函数声明的片面描述。有了这两个信息,我们就可以得知函数声明的全貌。
现在让我们来考虑调用一个方法时的事情。ObjC使用objc_msgSend(id obj, SEL sel, ...params)发送消息时,需要一个target,一个selector,以及与selector中所声明数量相同的参数。一个NSInvocation就包含了所有这些信息。你可以用它在运行时动态的设置target,设置selector,填入参数,最后用invoke方法发送这个消息。函数执行完成后,你还可以从它那里获取本次调用的返回值。
此外,NSInvocation中还包含了一个NSMethodSignature的成员,但NSInvocation本身并不会检查methodSignature与传入参数是否相符。换句话说,不论一个invocation的methodSignature字段是什么稀奇古怪的样式,只要传入的target、selector和参数没有错,invoke执行的结果就是正确的。不过,我们却可以用这个methodSignature字段做一些非常有意义的事情。例如Kiw中的 argument filter
回到调用stub...方法时。KWMock将会:
- 根据传入的selector生成一个KWMessagePattern,后者是KWStub中用于唯一区分方法的数据结构(而不是用selector)
- 用这个KWMessagePattern生成一个KWStub对象。如果你在初始化KWMock时指定了block、returnValue、argument filter等信息,也会一并传给KWStub
- 把KWStub他放到自身的列表里
现在你已经成功stub了一个mock对象中的方法。现在你调用 [anHYNetworkEngineMock requestImageWithURL:@"someURL" completionBlock:^(UIImage * image, NSError *error) {...}]时,由于KWMock对象本身没有实现这个方法,将不会真正的走到HYNetworkEngine的下载逻辑里,而是执行所谓完全消息转发。KWMock重写了那两个方法。其中:
- - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector返回自己mock的Class或Protocol对此selector的methodSignature。如果找不到,就用默认的"v@:"构造一个返回(还认识它吧?)
- 接下来进入了- (void)forwardInvocation:(NSInvocation *)anInvocation方法:
(题外话,[[anObj should] receive...]就是通过spy机制实现的。而spy的实现比较简单,不必细说。如果你也想spy某个调用,使用KWMessageSpying.h[6]这个文件里的接口)
(这里说的匹配,就是之前提到的argument filter,里面涉及了比较繁杂的类型转换逻辑。如果你感兴趣,可以在KWMessagePattern.m[7]中看到具体实现。在这里,我们默认已知Kiwi在messagePattern和invocation提供的参数数量和值都相同时才会返回YES)
- 如果没有stub能匹配这个调用,则根据partialMock或nullMock作出不同反应
- 如果既不是partialMock也不是nullMock,那么就看是否在自己的expectedMessagePattern列表里。这个列表包含了被stub方法以及KWMock从NSObject中继承的白名单方法方法,如description、hash等。此外,你也可以调用Kiwi的expect...接口向这里添加messagePattern
- 如果消息还没有被处理,则抛出异常
- 之后,KWMock将遍历自己的stub列表,让stub去处理这个调用。KWStub首先会用本次invocation与自己的messagePattern进行匹配,如果匹配结果成功,则调用你提供的block(如果有的话。注意,因为参数是用NSArray传过去的,所以所有的nil都被替换为了[NSNull null])。然后将返回值写进invocation。最后返回YES,结束责任链
- 首先,它会检查是否有人(spy)希望监听到这次调用。如果有,就通知给他
消息转发处理的代码如下(有所改动):
- (void)forwardInvocation:(NSInvocation *)anInvocation {
// 将本次调用通知给关心它的spies
for (KWMessagePattern *messagePattern in self.messageSpies) {
if ([messagePattern matchesInvocation:invocation]) {
NSArray *spies = [self.messageSpies objectForKey:messagePattern];
for (id<KWMessageSpying> spy in spies) {
[spy object:self didReceiveInvocation:invocation];
}
}
}
for (KWStub *stub in self.stubs) {
if ([stub processInvocation:invocation])
return;
}
if (self.isPartialMock)
[anInvocation invokeWithTarget:self.mockedObject];
if (self.isNullMock)
return;
// expectedMessagePattern除了所有被stub的方法外
// 还包括KWMock从NSObject中继承的白名单方法方法,如description、hash等
for (KWMessagePattern *expectedMessagePattern in self.expectedMessagePatterns) {
if ([expectedMessagePattern matchesInvocation:anInvocation])
return;
}
[NSException raise:@"KWMockException" format:@"description"];
}
至此,我们向mock对象创建和调用stub方法的步骤都已经完成了
KWMock中还有不需要selector参数的mock...、expect...方法。他们会返回一个KWInvocationCapturer对象。这家伙实际上是一个NSProxy,会将收到的消息转发给它的delegate,也就是KWMock对象。mock对象在收到消息后,会用这次调用的selector生成一个messagePattern并stub它。这有什么妙用(?)呢?看这句调用:[[anRKImageDownloaderMock stubAndReturn:UIImage.new] defaultImage] 它和 [anRKImageDownloaderMock stub:@selector(defaultImage) andReturn:UIImage.new] 是等价的。 这种形式的好处是避免了使用selector,可以直接调用方法,而且这种链式调用看起来更骚。但实际上并没听说谁用过这种写法。
以上是Kiwi中KWMock对stub创建以及调用的处理。但在Kiwi中,不只是KWMock对象可以stub,任何ObjC对象都支持。二者的原理似乎没有什么不同,但实现后者却困难得多。Kiwi中巧妙运用了runtime的能力,让这件事情成为现实。
Stub on Any ObjC Object
这一部分的主要实现逻辑在KWIntercept.m[8]文件里
提示:别忘了Class也是一个Object。Kiwi为二者提供了完全一致的接口,使你可以轻松stub一个类方法
stub一个mock对象和stub任意对象最大的不同在于:KWMock对象本身几乎不会实现需要stub的selector,所以可以直接进入完全消息转发逻辑。而Object已经实现了。Kiwi是怎么解决这个问题的呢?
我们重复刚刚的方法,看看在调用[HYNetworkEngine @selector(requestImageWithURL:completionBlock:) withBlock:^id(NSArray *params) {...}]后会发生什么:
- 首先,Kiwi会生成一个新的类(如果不存在),它的名字可能叫HYNetworkEngine_KWIntercept1,后面的数字表示已经生成了多少个Intercept类,用于防止你stub多个相同类型的对象时出现起名冲突。它的基类是HYNetworkEngine(这里也被称为Canonical Class)。当然,还会创建对应的metaclass
- 创建完成后,向Intercept类中动态添加四个方法:class、superclass、dealloc以及我们熟悉的forwardInvocation:。最后一个方法也会添加到metaclass中
- 将自身的isa指向Intercept类。如果自身是Class对象,则指向对应的metaclass
- 把Intercept类的requestImageWithURL:completionBlock实现替换成一个名字叫KWNonExistantSelector的selector对应的IMP。仔细看它的名字,既然叫non existant selector,就说明对应的IMP是不存在的。这样在调用这个方法时,就可以走进forwardInvocation:方法,解决了我们上面提到的问题
- 后面的步骤和KWMock的处理比较相似:用根据传入的selector生成的messagePattern创建KWStub对象,再把它加到stub列表里。这里用了一个全局的字典(NSMapTable),记录所有对象的stub list。值得一提的是,字典的key是这样定义的:
__weak id weakobj = anObject;
key = ^{ return weakobj; };
这样既避免了强引用或copy对象,还有办法在需要的时候找到该对象
上面第二步中提到的动态添加forwardInvocation:方法的实现函数为void KWInterceptedForwardInvocation(id, SEL, NSInvocation*),它与上面KWMock的forwardInvocation:实现并没多大区别,只是在stub无法处理消息的情况下,执行这段逻辑,来调用这个方法原本的实现:
void KWInterceptedForwardInvocation(id anObject, SEL aSelector, NSInvocation* anInvocation) {
// ...
// stub列表中没有能match这次invocation的
// anObject->ias = canonicalClass
Class interceptClass = KWRestoreOriginalClass(anObject);
// call original method
[anInvocation invoke];
// anObject->isa = interceptClass;
object_setClass(anObject, interceptClass);
}
Verifier and Matcher
虽然我们的三个用例已经全部完成。但你可能还不够满意,因为我们对Kiwi内部具体是如何判断我们给出的Expectation还一无所知。当我们写下should、shouldEventually、beNil、graterThan、receive等语句时,Kiwi为我们做了什么?延时判断是怎么实现的?前面说的registerMatchers语句有什么用?接下来我们会一一分析。
Kiwi中对Expectation的理解是:一个对象(称它为 subject)在现在或将来的某个时候 应该(should) 或 不应该(shouldNot) 满足某个条件。
在Kiwi中,有一个概念叫Verifier,顾名思义,是用于判断 subject 是否满足某个条件的。Verifier在Kiwi中共分为三种,分别是:
- ExistVerifier 用于判断 subject 是否为空。相应的接口已经废弃,这里只提一下,不再分析。对应的调用方式包括:
- [subject shouBeNil]
- MatchVerifier 用于判断 subject 是否满足某个条件。对应的调用方式包括:
- [[subject should] beNil]
- AsyncVerifier MatcherVerifier的子类。不同的是,它用来执行延时判断。对应的调用方式包括
如果你在用AsyncVerifier,别忘了用expectFutureValue函数包装你的 subject,以便在它的值改变时,Kiwi依然能够找到它。
- [[expectFutureValue(subject) shouldEventuallyBeforeTimingOutAfter(0.5)] beNil]
- [[expectFutureValue(subject) shouldAfterWaitOf(0.5)] beNil]
我们先来介绍MatcherVerifier
MatchVerifier
假设我们有这样的一个Expectation:
[[resultError should] equal:[NSNull null]];
这段代码中,should实际上是一个宏,它创建了一个MatchVerifier,把它添加到当前Example的verifiers 列表里,并返回这个MatchVerifier。
接下来,我们调用了equal方法。实际上,MatchVerifier并没有实现这个方法,因此会走进转发逻辑。在forwardInvocation:方法中,MatchVerifier会从 matcherFactory 中查找实现了equal方法的Matcher。后者是一个遵循KWMatching协议的对象,用来判断 subject 是否满足某个条件。
matcherFactory 最终找到了一个Kiwi中内置的,叫KWEqualMatcher的类,它实现了equal方法,并且没有在自己的canMatchSubject:方法中返回 NO。因此,MatchVerifier会将消息转发给它的实例。
之后,MatchVerifier会根据 matcher 的shouldBeEvaluatedAtEndOfExample方法返回值,来决定立刻调用 matcher 中实现的evaluate方法来检测测试结果,还是等到整个 Example 执行完成后(也就是说,你在这个it节点内写的代码都执行之后。还记得前面执行测试用例那一小节提到的 verifiers 自检步骤吗?)才检查。
Kiwi内置的 matcher 中,只有KWNotificationMatcher和KWReceiveMatcher是在 Example 执行完成后进行检查的,其余都是立即检查。
MatcherFactory
上面又出现了一个陌生的名字:matcherFactory。在每个KWExample初始化的时候,会新建一个KWMatcherFactory的实例,并以@"KW"作为前缀向后者注册 matcher。
matcherFactory 在收到注册请求后,会遍历所有已注册的类,并找到其中遵循了KWMatching的类。之后,通过传入的参数(此处是@"KW")对类名进行筛选。
KWMatching协议中声明了一个方法:+(NSArray *)matcherStrings。如果你自定义了一个 matcher,那么你就得在这个方法里返回这个 matcher 所支持的检测方法的 selector 对应的字符串。matcherFactory会以这个字符串为key生成一个字典,以便在收到一个 selector 时,能够找到实现了该selector 对应方法的 matcher。
例如,[KWEqualMatcher matcherStrings]的返回值是@[@"equal:"] —— 意味着equal:是这个 matcher 支持的唯一一个检测方法。
registerMatchers
现在我们已经知道 matcherFactory 注册和使用matcher 的原理了,自定义一个 matcher 也是水到渠成的事情。事实上,我们只需要创建一个遵循KWMatching协议的类——当然,继承KWMatcher或许是一个更方便的选择。这个类中需要实现的方法和其作用,我们大部分都已经说过了。
接下来,在当前的 context 下使用registerMatchers函数将你的 matcher 注册给matcherFactory,记得传入的参数要和你刚刚创建的matcher 类名前缀严格一致。
Kiwi的文档[9]里对你需要实现的方法有比较详细的描述,不过正如文档中提到的,看一看那些Kiwi内置的 matcher[10]是最好的选择。
AsyncVerifier
上面说过,AsyncVerifier是MatchVerifier的子类。这意味着,它也是通过 matcherFactory 提供的matcher 去判断你的 Expectation 是否通过的。唯一不同的是,它会以0.1s为周期对结果进行轮询。具体的实现方式为:在当前线程使用 Default 模式,以0.1s为时长运行RunLoop。这意味着,虽然它的名字带了Async,但实际上它的轮询操作是同步执行的。你最好把AsyncVerifier这个名字理解为:用于测试你的Async操作结果的Verifyer。
所以,一般情况下没有必要把等待时间设置得过长。
AsyncVerifier有两种使用方法,分别是shouldEventually...和shouldAfterWait...,你可以指定等待的时间,否则默认为1秒。两种方法的区别在于:前者在轮询过程中发现预期的结果已经满足,会立刻返回。后者则会固定执行到给定的等待时间结束后才检测结果。
以上就是关于AsyncVerifier的介绍了。在最后,我准备了一段代码,你可以试着预测这些用例的通过情况,来加深对这一部分的理解。注意看dispatch_after和我们的Expectation中传入的时间参数:
/************* Example1 ***********/
it(@"", ^{
__block UIImage *image = [UIImage new];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
image = nil;
});
[[expectFutureValue(image) shouldEventuallyBeforeTimingOutAfter(0.3)] beNonNil];
});
/************* Example2 ***********/
it(@"", ^{
__block UIImage *image = [UIImage new];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
image = nil;
});
[[expectFutureValue(image) shouldAfterWaitOf(0.3)] beNonNil];
});
/************* Example3 ***********/
it(@"", ^{
__block UIImage *image = [UIImage new];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
image = nil;
});
[[expectFutureValue(image) shouldEventuallyBeforeTimingOutAfter(0.03)] beNil];
});
/************* Example4 ***********/
it(@"", ^{
__block UIImage *image = [UIImage new];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
image = nil;
});
[[expectFutureValue(image) shouldAfterWaitOf(0.03)] beNil];
});
/************* Example5 ***********/
it(@"", ^{
__block UIImage *image = [UIImage new];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
image = nil;
});
[[expectFutureValue(image) shouldEventually] beNil];
[[image should] beNil];
});
哪些it会通过测试呢?
答案点这里
- Example1: 通过。虽然在0.2s时将image的值设为nil,但实际上在第一次采样时就已经完成beNonNil的判断了
- Example2: 不通过。shouldAfterWaitOf会等到预定时间——也就是0.3s——之后,才进行判断,此时image已经为nil了
- Example3: 通过。虽然我们在0.03s进行检测,而代码中0.05s才将image设置为nil。但实际上由于其采样间隔为0.1s,所以是在0.1s后才执行检测的
- Example4: 通过。与Example3相同
- Example5: 通过。虽然image在0.5s后才被设置为nil,而我们的[[image should] beNil]又是同步调用处的。但实际上上面的shouldEventually调用会阻塞1s,执行到[[image should] beNil]时,image已经为nil了
总结和其它
通过本文的学习,我们较为深入的分析了Kiwi的功能、实现原理,以及一些比较容易被忽略的机制。Kiwi的 context 概念很好地分隔了代码,对有一定规模的测试文件来说非常有用。stub和mock机制让很多困难的测试项目变得轻而易举。此外,作者在接口的自解释性上下了很大功夫,就算是一个完全不懂的人也可以快速上手。我想,用一个第三方库时非常爽的事情,就是在需要一个功能,但不知道它支不支持时,我输入一个关键词,IDE为我推荐了一个接口,点进去一看,真的就是想要的那个。使用Kiwi时,确实能体会到这种感觉。
本文中难免有遗漏、疏忽或者理解有误的地方,希望大家多多包涵,友好指出,谢谢!
阅读链接:
AFNetworking 的作者写的关于 Unit Testing 的文章[11](这是我最喜欢的博客网站)
Kiwi 的官方 Wiki[12]
顺便说一下,示例代码里类名所用的前缀RK和HY,分别代表Rikka和Hayasaka。
参考资料
参考资料
[1]TDD的iOS开发初步以及Kiwi使用入门: https://onevcat.com/2014/02/ios-test-with-kiwi/
[2]Kiwi的matchers: https://github.com/kiwi-bdd/Kiwi/tree/master/Classes/Matchers
[3]KWSymbolicator.m: https://github.com/kiwi-bdd/Kiwi/blob/master/Classes/Core/KWSymbolicator.m
[4]Kiwi 使用进阶 Mock, Stub, 参数捕获和异步测试: https://onevcat.com/2014/05/kiwi-mock-stub-test/
[5]
Type Encodings: https://nshipster.com/type-encodings/
[6]KWMessageSpying.h: https://github.com/kiwi-bdd/Kiwi/blob/master/Classes/Core/KWMessageSpying.h
[7]KWMessagePattern.m: https://github.com/kiwi-bdd/Kiwi/blob/master/Classes/Core/KWMessagePattern.m
[8]KWIntercept.m: https://github.com/kiwi-bdd/Kiwi/blob/master/Classes/Stubbing/KWIntercept.m
[9]Kiwi的文档: https://github.com/kiwi-bdd/Kiwi/wiki/Expectations
[10]Kiwi内置的 matcher: https://github.com/kiwi-bdd/Kiwi/tree/master/Classes/Matchers
[11]Unit Testing: https://nshipster.com/unit-testing/
[12]Kiwi 的官方 Wiki: https://github.com/kiwi-bdd/Kiwi/wiki