iOS单元测试之Kiwi的简介和使用

一、Kiwi相关简介

1.1、测试驱动开发和行为驱动开发

测试驱动开发(Test Driven Development,以下简称TDD),TDD是敏捷开发中的一项核心实践和技术,也是一种设计方法论。原理呢,是在开发功能代码之前,先编写单元测试用例代码,测试代码是要根据需求的产品来编写的代码。TDD的基本思路就是通过测试来推动整个开发的进行。测试驱动开发不是简单的测试,是需要把需求分析、设计和质量控制量化的过程。测试驱动开发就是,在了解需求功能之后,制定了一套测试用例代码,这套测试用例代码对你的需求(对象、功能、过程、接口等)进行设计,测试框架可以持续进行验证。就像是在画画之前先画好了基本的轮廓,来保证能够画成你想要的东西。

行为驱动开发( Behavior Driven Development,以下简称BDD), BDD是在应用程序存在之前,写出用例与期望,从而描述应用程序的行为,并且促使在项目中的人们彼此互相沟通。BDD关注的是业务领域,而不是技术。BDD强调用领域特定语言描述用户行为,定义业务需求,让开发者集中精力于代码的写法而不是技术细节上。着重在整个开发层面所有参与者对行为和业务的理解。行为驱动开发将所有人集中在一起用一种特定的语言将所需要的系统行为形成一个一致理解认可的术语。就像是统一了的普通话,各个地区的人可以通过普通话来了解一句话意义是什么。

1.2、Kiwi简介

作为第二代敏捷方法,BDD提倡的是通过将测试语句转换为类似自然语言的描述,开发人员可以使用更符合大众语言的习惯来书写测试,这样不论在项目交接/交付,或者之后自己修改时,都可以顺利很多。如果说作为开发者的我们日常工作是写代码,那么BDD其实就是在讲故事。一个典型的BDD的测试用例包活完整的三段式上下文,测试大多可以翻译为Given..When..Then的格式,读起来轻松惬意。BDD在其他语言中也已经有一些框架,包括最早的Java的JBehave和赫赫有名的Ruby的RSpecCucumber。而在objc社区中BDD框架也正在欣欣向荣地发展,得益于objc的语法本来就非常接近自然语言,再加上C语言宏的威力,我们是有可能写出漂亮优美的测试的。在objc中,现在比较流行的BDD框架有cedarspectaKiwi。本文主要介绍的是Kiwi,使用Kiwi写出的测试看起来大概会是这个样子的:

示例如下所示:

describe(@"Team", ^{
    context(@"when newly created", ^{
        it(@"has a name", ^{
            id team = [Team team];
            [[team.name should] equal:@"Black Hawks"];
        });

        it(@"has 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框架吧。

可以通过通过CocoaPods安装,请将此添加到您的Podfile

pod "Kiwi"

二、Kiwi的使用

点击下载Demo:ZJHUnitTestDemo

2.1、Kiwi测试的基本结构

可以直接创建一个普通的Objective-C test case class,如:ZJHFirstKiwiTests,然后再里面添加Kiwi代码:

#import "Kiwi.h"

SPEC_BEGIN(SimpleStringSpec)

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"];
        });
    });
});

SPEC_END

你可能会觉得这不是objc代码,甚至怀疑这些语法是否能够编译通过。其实SPEC_BEGINSPEC_END都是宏,它们定义了一个KWSpec的子类,并将其中的内容包装在一个函数中(有兴趣的朋友不妨点进去看看)。

describe描述需要测试的对象内容,也即我们三段式中的Givencontext描述测试上下文,也就是这个测试在When来进行,最后it中的是测试的本体,描述了这个测试应该满足的条件,三者共同构成了Kiwi测试中的行为描述。它们是可以nest的,也就是一个Spec文件中可以包含多个describe(虽然我们很少这么做,一个测试文件应该专注于测试一个类);一个describe可以包含多个context,来描述类在不同情景下的行为;一个context可以包含多个it的测试例。让我们运行一下这个测试,观察输出:

ZJHUnitTestDemo[14459:288758] + 'SimpleString, when assigned to 'Hello world', should exist' [PASSED]
ZJHUnitTestDemo[14459:288758] + 'SimpleString, when assigned to 'Hello world', should equal to 'Hello world'' [PASSED]

2.2、Kiwi规则

先看下面的第二个示例子代码

#import "Kiwi.h"
#import "ZJHKiwiSample.h"

// SPEC_BEGIN(ClassName) 和 SPEC_END 宏,用于标记 KWSpec 类的开始和结束,以及测试用例的分组声明
SPEC_BEGIN(ZJHKiwiSampleSpec)

describe(@"ZJHKiwiSample Kiwi test", ^{
    registerMatchers(@"ZJH"); // 注册所有使用"ZJH"命名空间前缀的匹配器.
    context(@"a state the component is in", ^{
        let(variable, ^{ // 在每个包含的 "it" 执行前执行执行一次.
            return [[ZJHKiwiSample alloc]init];
        });
        beforeAll(^{ // 在所有内嵌上下文或当前上下文的 it block执行之前执行一次.
            NSLog(@"beforAll");
        });
        afterAll(^{ // 在所有内嵌上下文或当前上下文的 it block执行之后执行一次.
            NSLog(@"afterAll");
        });
        beforeEach(^{ // 在所有包含的上下文环境的 it block执行之前,均各执行一次.用于初始化指定上下文环境的代码
            NSLog(@"beforeEach");
        });
        afterEach(^{ // 在所有包含的上下文环境的 it block执行之后,均各执行一次.
            NSLog(@"afterEach");
        });
        it(@"should do something", ^{ // 声明一个测试用例.这里描述了对对象或行为的期望.
            NSLog(@"should do something");
        });
        specify(^{ // 可用于标记尚未完成的功能或用例,仅会使Xcode输出一个黄色警告
            NSLog(@"specify");
            [[variable shouldNot] beNil];
        });
        
            context(@"inner context", ^{ // 可以嵌套context
            NSLog(@"inner context");
            it(@"does another thing", ^{
                NSLog(@"does another thing");
            });
            pending(@"等待实现的东西", ^{ // 可用于标记尚未完成的功能或用例,仅会使Xcode输出一个黄色警告
                NSLog(@"等待实现的东西");
            });
        });
    });
});

SPEC_END
  • #import "Kiwi.h" 导入Kiwi库.这应该在规则的文件开始处最先导入.
  • SPEC_BEGIN(ClassName)SPEC_END 宏,用于标记 KWSpec 类的开始和结束,以及测试用例的分组声明.
  • registerMatchers(aNamespacePrefix) 注册所有使用指定命名空间前缀的匹配器.除了Kiwi默认的匹配器,这些匹配器也可以在当前规则中使用.
  • describe(aString, aBlock) 开启一个上下文环境,可包含测试用例或嵌套其他的上下文环境.
  • 为了使一个block中使用的变量真正被改变,它需要在定义时使用 __block 修饰符.
  • beforeAll(aBlock) 在所有内嵌上下文或当前上下文的``it`block执行之前执行一次.
  • afterAll(aBlock) 在所有内嵌上下文或当前上下文的``it`block执行之后执行一次.
  • beforeEach(aBlock) 在所有包含的上下文环境的 itblock执行之前,均各执行一次.用于初始化指定上下文环境的代码,应该放在这里.
  • afterEach(aBlock) 在所有包含的上下文环境的 itblock执行之后,均各执行一次.
  • it(aString, aBlock) 声明一个测试用例.这里描述了对对象或行为的期望.
  • specify(aBlock) 声明一个没有描述的测试用例.这个常用于简单的期望.
  • pending(aString, aBlock) 可用于标记尚未完成的功能或用例,仅会使Xcode输出一个黄色警告.(有点TODO的赶脚)
  • let(subject, aBlock) 声明一个本地工具变量,这个变量会在规则内所有上下文的每个 itblock执行前,重新初始化一次.

2.3、期望

期望(Expectations),用来验证用例中的对象行为是否符合你的语气。期望相当于传统测试中的断言,要是运行的结果不能匹配期望,则测试失败。在Kiwi中期望都由should或者shouldNot开头,并紧接一个或多个判断的的链式调用,大部分常见的是be或者haveSomeCondition的形式。在我们上面的例子中我们使用了should not be nil和should equal两个期望来确保字符串赋值的行为正确。一个期望,具有如下形式: [[subject should] someCondition:anArgument].此处 [subject should]是表达式的类型, ... someCondition:anArgument] 是匹配器的表达式。如下示例

// 可以用下面的内容替换原来的tests.m中的内容,然后cmd+u
// 测试失败可自行解决;解决不了的,继续往下看.
#import "Kiwi.h"
#import "ZJHKiwiCar.h"

SPEC_BEGIN(ZJHExpectationKiwiSpec)

describe(@"YFKiwiCar Test", ^{
    it(@"A Car Rule", ^{
        id car = [ZJHKiwiCar new];
        [[car shouldNot] beNil]; // car对象不能为nil
        [[car should] beKindOfClass:[ZJHKiwiCar class]]; // 应该是ZJHKiwiCar类
        [[car shouldNot] conformToProtocol:@protocol(NSCopying)]; // 应该没有实现NSCopying协议
        [[[car should] have:4] wheels]; // 应该有4个轮子
        [[theValue([(ZJHKiwiCar *)car speed]) should] equal:theValue(42.0f)]; // 测速应该是42
        [[car should] receive:@selector(changeToGear:) withArguments: theValue(3)]; // 接收的参数应该是3
        [car changeToGear: 3]; // 调用方法
    });
});

SPEC_END
2.3.1、should 和 shouldNot

[subject should][subject shouldNot] 表达式,类似于一个接收器,用于接收一个期望匹配器.他们后面紧跟的是真实的匹配表达式,这些表达式将真正被用于计算.

默认地,主语守卫(一种机制,可以保证nil不引起崩溃)也会在[subject should ][subject shouldNot]被使用时创建.给 nil 发送消息,通常不会有任何副作用.但是,你几乎不会希望:一个表达式,只是为了给某个对象传递一个无足轻重的消息,就因为对象本身是nil.也就说,向nil对象本身发送消息,并不会有任何副作用;但是在BBD里,某个要被传递消息的对象是nil,通常是非预期行为.所以,这些表达式的对象守卫机制,会将左侧无法判定为不为nil的表达式判定为 fail失败.

2.3.2、标量装箱

"装箱"是固定术语译法,其实即使我们iOS常说的基本类型转NSObject类型(事实如此,勿喷)。部分表达式中,匹配器表达式的参数总是NSObject对象.当将一个标量(如int整型,float浮点型等)用于需要id类型参数的地方时,应使用theValue(一个标量)宏将标量装箱.这种机制也适用于: 当一个标量需要是一个表达式的主语(主谓宾,基本语法规则,请自行脑补)时,或者一个 存根 的值需要是一个标量时.

it(@"Scalar packing",^{ // 标量装箱
        [[theValue(1 + 1) should] equal:theValue(2)];
        [[theValue(YES) shouldNot] equal:theValue(NO)];
        [[theValue(20u) should] beBetween:theValue(1) and:theValue(30.0)];
        ZJHKiwiCar * car = [ZJHKiwiCar new];
        [[theValue(car.speed) should] beGreaterThan:theValue(40.0f)];
 });
2.3.3、消息模式

在iOS中,常将调用某个实例对象的方法成为给这个对象发送了某个消息.所以"消息模式"中的"消息",更多的指的的实例对象的方法;"消息模式"也就被用来判断对象的某个方法是否会调用以及是否会按照预期的方式调用。一些 Kiwi 匹配器支持使用消息模式的期望.消息模式部分,常被放在一个表达式的后部,就像一个将要发给主语的消息一样.

it(@"Message Pattern", ^{ // 消息模式
        ZJHKiwiCar *cruiser = [[ZJHKiwiCar alloc]init];
        [[cruiser should] receive:@selector(jumpToStarSystemWithIndex:) withArguments: theValue(3)];
        [cruiser jumpToStarSystemWithIndex: 3]; // 期望传的参数是3
  });
2.3.4、期望:数值 和 数字
[[subject shouldNot] beNil]
[[subject should] beNil]
[[subject should] beIdenticalTo:(id)anObject] - 比较是否完全相同
[[subject should] equal:(id)anObject]
[[subject should] equal:(double)aValue withDelta:(double)aDelta]
[[subject should] beWithin:(id)aDistance of:(id)aValue]
[[subject should] beLessThan:(id)aValue]
[[subject should] beLessThanOrEqualTo:(id)aValue]
[[subject should] beGreaterThan:(id)aValue]
[[subject should] beGreaterThanOrEqualTo:(id)aValue]
[[subject should] beBetween:(id)aLowerEndpoint and:(id)anUpperEndpoint]
[[subject should] beInTheIntervalFrom:(id)aLowerEndpoint to:(id)anUpperEndpoint]
[[subject should] beTrue]
[[subject should] beFalse]
[[subject should] beYes]
[[subject should] beNo]
[[subject should] beZero]
2.3.5、期望: 子串匹配
[[subject should] containString:(NSString*)substring]
[[subject should] containString:(NSString*)substring  options:(NSStringCompareOptions)options]
[[subject should] startWithString:(NSString*)prefix]
[[subject should] endWithString:(NSString*)suffix]

示例:
    [[@"Hello, world!" should] containString:@"world"];
    [[@"Hello, world!" should] containString:@"WORLD" options:NSCaseInsensitiveSearch];
    [[@"Hello, world!" should] startWithString:@"Hello,"];
    [[@"Hello, world!" should] endWithString:@"world!"];
2.3.6、期望:正则表达式匹配
[[subject should] matchPattern:(NSString*)pattern]
[[subject should] matchPattern:(NSString*)pattern options:(NSRegularExpressionOptions)options]

示例:
    [[@"ababab" should] matchPattern:@"(ab)+"];
    [[@" foo " shouldNot] matchPattern:@"^foo$"];
    [[@"abABab" should] matchPattern:@"(ab)+" options:NSRegularExpressionCaseInsensitive];
2.3.7、期望:数量的变化
[[theBlock(^{ ... }) should] change:^{ return (NSInteger)count; }]
[[theBlock(^{ ... }) should] change:^{ return (NSInteger)count; } by:+1]
[[theBlock(^{ ... }) should] change:^{ return (NSInteger)count; } by:-1]

示例:
it(@"Expectations: Count changes", ^{ // 期望: 数量的变化
        NSMutableArray * array = [NSMutableArray arrayWithCapacity: 42];
        
        [[theBlock(^{ // 数量应该+1
            [array addObject:@"foo"];
        }) should] change:^{
            return (NSInteger)[array count];
        } by:+1];
        
        [[theBlock(^{ // 数量不应该改变
            [array addObject:@"bar"];
            [array removeObject:@"foo"];
        }) shouldNot] change:^{ return (NSInteger)[array count]; }];
        
        [[theBlock(^{ // 数量应该-1
            [array removeObject:@"bar"];
        }) should] change:^{ return (NSInteger)[array count]; } by:-1];
    });
2.3.8、期望:对象测试
[[subject should] beKindOfClass:(Class)aClass]
[[subject should] beMemberOfClass:(Class)aClass]
[[subject should] conformToProtocol:(Protocol *)aProtocol]
[[subject should] respondToSelector:(SEL)aSelector]
2.3.9、期望:集合
对于集合主语(即,主语是集合类型的):
[[subject should] beEmpty]
[[subject should] contain:(id)anObject]
[[subject should] containObjectsInArray:(NSArray *)anArray]
[[subject should] containObjects:(id)firstObject, ...]
[[subject should] haveCountOf:(NSUInteger)aCount]
[[subject should] haveCountOfAtLeast:(NSUInteger)aCount]
[[subject should] haveCountOfAtMost:(NSUInteger)aCount]

对于集合键(即此属性/方法名对应/返回一个集合类型的对象):
[[[subject should] have:(NSUInteger)aCount] collectionKey]
[[[subject should] haveAtLeast:(NSUInteger)aCount] collectionKey]
[[[subject should] haveAtMost:(NSUInteger)aCount] collectionKey]

如果主语是一个集合(比如 NSArray数组), coollectionKey 可以是任何东西(比如 items),只要遵循语法结构就行.否则, coollectionKey应当是一个可以发送给主语并返回集合类型数据的消息.更进一步说: 对于集合类型的主语,coollectionKey的数量总是根据主语的集合内的元素数量, coollectionKey 本身并无实际意义.

示例:
    NSArray *array = [NSArray arrayWithObject:@"foo"];
    [[array should] have:1] item];
    
    Car *car = [Car car];
    [car setPassengers:[NSArray arrayWithObjects:@"Eric", "Stan", nil]];
    [[[[car passengers] should] haveAtLeast:2] items];
    [[[car should] haveAtLeast:2] passengers];
2.3.10、期望:交互和消息

这些期望用于验证主语是否在从创建期望到用例结束的这段时间里接收到了某个消息(或者说对象的某个方法是否被调用).这个期望会同时存储 选择器或参数等信息,并依次来决定期望是否满足。这些期望可用于真实或模拟的独享,但是在设置 receive 表达式时,Xcode 可能会给警告(报黄).

对参数无要求的选择器:
[[subject should] receive:(SEL)aSelector]
[[subject should] receive:(SEL)aSelector withCount:(NSUInteger)aCount]
[[subject should] receive:(SEL)aSelector withCountAtLeast:(NSUInteger)aCount]
[[subject should] receive:(SEL)aSelector withCountAtMost:(NSUInteger)aCount]
[[subject should] receive:(SEL)aSelector andReturn:(id)aValue]
[[subject should] receive:(SEL)aSelector andReturn:(id)aValue withCount:(NSUInteger)aCount]
[[subject should] receive:(SEL)aSelector andReturn:(id)aValue withCountAtLeast:(NSUInteger)aCount]
[[subject should] receive:(SEL)aSelector andReturn:(id)aValue withCountAtMost:(NSUInteger)aCount]
含有指定参数的选择器:
[[subject should] receive:(SEL)aSelector withArguments:(id)firstArgument, ...]
[[subject should] receive:(SEL)aSelector withCount:(NSUInteger)aCount arguments:(id)firstArgument, ...]
[[subject should] receive:(SEL)aSelector withCountAtLeast:(NSUInteger)aCount arguments:(id)firstArgument, ...]
[[subject should] receive:(SEL)aSelector withCountAtMost:(NSUInteger)aCount arguments:(id)firstArgument, ...]
[[subject should] receive:(SEL)aSelector andReturn:(id)aValue withArguments:(id)firstArgument, ...]
[[subject should] receive:(SEL)aSelector andReturn:(id)aValue withCount:(NSUInteger)aCount arguments:(id)firstArgument, ...]
[[subject should] receive:(SEL)aSelector andReturn:(id)aValue withCountAtLeast:(NSUInteger)aCount arguments:(id)firstArgument, ...]
[[subject should] receive:(SEL)aSelector andReturn:(id)aValue withCountAtMost:(NSUInteger)aCount arguments:(id)firstArgument, ...]
示例:
subject = [Cruiser cruiser];
[[subject should] receive:@selector(energyLevelInWarpCore:) 
    andReturn:theValue(42.0f) withCount:2 arguments:theValue(7)];
[subject energyLevelInWarpCore:7];
float energyLevel = [subject energyLevelInWarpCore:7];
[[theValue(energyLevel) should] equal:theValue(42.0f)];

注意你可以将 any() 通配符用作参数.如果你只关心一个方法的部分参数的值,这回很有用:
id subject = [Robot robot];
[[subject should] receive:@selector(speak:afterDelay:whenDone:) withArguments:@"Hello world",any(),any()];
[subject speak:@"Hello world" afterDelay:3 whenDone:nil];
2.3.11、期望:通知
[[@"MyNotification" should] bePosted];
[[@"MyNotification" should] bePostedWithObject:(id)object];
[[@"MyNotification" should] bePostedWithUserInfo:(NSDictionary *)userInfo];
[[@"MyNotification" should] bePostedWithObject:(id)object andUserInfo:(NSDictionary *)userInfo];
[[@"MyNotification" should] bePostedEvaluatingBlock:^(NSNotification *note)block];

示例:
it(@"Notification", ^{ // 期望:通知
        [[@"自定义通知" should] bePosted];
        NSNotification *myNotification = [NSNotification notificationWithName:@"自定义通知"
                                                                       object:nil];
        [[NSNotificationCenter defaultCenter] postNotification:myNotification];
   });
2.3.12、期望:异步调用
[[subject shouldEventually] receive:(SEL)aSelector]
[[subject shouldEventually] receive:(SEL)aSelector withArguments:(id)firstArgument, ...]
2.3.13、期望:异常
[[theBlock(^{ ... }) should] raise]
[[theBlock(^{ ... }) should] raiseWithName:]
[[theBlock(^{ ... }) should] raiseWithReason:(NSString *)aReason]
[[theBlock(^{ ... }) should] raiseWithName:(NSString *)aName reason:(NSString *)aReason]

示例:
    [[theBlock(^{
        [NSException raise:@"FooException" reason:@"Bar-ed"];
    }) should] raiseWithName:@"FooException" reason:@"Bar-ed"];
2.3.14、自定义匹配器

Kiwi中,自定义匹配器的最简单方式是创建KWMatcher的子类,并以适当的方式重写下面示例中的方法.为了让你自定义的匹配器在规则中可用,你需要在规则中使用 registerMatchers(namespacePrefix)进行注册.看下Kiwi源文件中的匹配器写法(如KWEqualMatcher等),将会使你受益匪浅.

registerMatchers 待补充

2.4、模拟对象

模拟对象模拟某个类,或者遵循某个写一个.他们让你在完全功能完全实现之前,就能更好地专注于对象间的交互行为,并且能降低对象间的依赖--模拟或比避免那些运行规则时几乎很难出现的情况.

it(@"Mock", ^{ // 模拟对象
        id carMock = [ZJHKiwiCar mock]; // 模拟创建一个对象
        [ [carMock should] beMemberOfClass:[ZJHKiwiCar class]]; // 判断对象的类型
        [ [carMock should] receive:@selector(currentGear) andReturn:theValue(3)];
        [ [theValue([carMock currentGear]) should] equal:theValue(3)]; // 调用模拟对象的方法

        id carNullMock = [ZJHKiwiCar nullMock]; // 模拟创建一个空对象
        [ [theValue([carNullMock currentGear]) should] equal:theValue(0)];
        [carNullMock applyBrakes];

        // 模拟协议
        id flyerMock = [KWMock mockForProtocol:@protocol(ZJHKiwiFlyingMachine)];
        [ [flyerMock should] conformToProtocol:@protocol(ZJHKiwiFlyingMachine)];
        [flyerMock stub:@selector(dragCoefficient) andReturn:theValue(17.0f)];

        id flyerNullMock = [KWMock nullMockForProtocol:@protocol(ZJHKiwiFlyingMachine)];
        [flyerNullMock takeOff];
    });
2.4.1、模拟 Null 对象

通常模拟对象收到一个非预期的选择器或消息模式时,会抛出异常(PS:iOS开发常见错误奔溃之一).在模拟对象上使用 stubreceive期望,期望的消息会自动添加到模拟对象上,以实现对方法的模拟。如果你不关心模拟对象如何处理其他非预期的消息,也不想在收到非预期消息时抛出异常,那就使用 null 模拟对象吧(也即 null 对象).

当mock对象收到了没有被stub过的调用(更准确的说,走进了消息转发的forwoardInvocation:方法里)时:

  • nullMock: 就当无事发生,忽略这个调用
  • partialMock: 让初始化时传入的object来响应这个selector
  • 普通Mock:抛出exception
2.4.2、模拟类的实例
创建类的模拟实例(NSObject 扩展):
[SomeClass mock]
[SomeClass mockWithName:(NSString *)aName]
[SomeClass nullMock]
[SomeClass nullMockWithName:(NSString *)aName]

创建类的模拟实例:
[KWMock mockForClass:(Class)aClass]
[KWMock mockWithName:(NSString *)aName forClass:(Class)aClass]
[KWMock nullMockForClass:(Class)aClass]
[KWMock nullMockWithName:(NSString *)aName forClass:(Class)aClass]
2.4.3、模拟协议的实例
创建遵循某协议的实例:
[KWMock mockForProtocol:(Protocol *)aProtocol]
[KWMock mockWithName:(NSString *)aName forProtocol:(Protocol *)aProtocol]
[KWMock nullMockForProtocol:(Protocol *)aProtocol]
[KWMock nullMockWithName:(NSString *)aName forProtocol:(Protocol *)aProtocol]

2.5、存根

存根,能返回指定定选择器或消息模式的封装好的请求.Kiwi中,你可以存根真实对象(包括类对象)或模拟对象的方法.没有指定返回值的存根,将会对应返回nil,0等零值.存根需要返回标量的,标量需要使用 theValue(某个标量)宏 装箱。所有的存根都会在规范的一个例子的末尾(一个itblock)被清除.

存根选择器:
[subject stub:(SEL)aSelector]
[subject stub:(SEL)aSelector andReturn:(id)aValue]

存根消息模式:
[ [subject stub] *messagePattern*]
[ [subject stubAndReturn:(id)aValue] *messagePattern*]

示例:
it(@"stub", ^{ // 存根
        
        id mock = [ZJHKiwiCar mock]; // 设置对象的名字为Rolls-Royce
        [mock stub:@selector(carName) andReturn:@"Rolls-Royce"];
        [ [[mock carName] should] equal:@"Rolls-Royce"];
        
        // 模拟对象接收的消息的某个参数是一个block;通常必须捕捉并执行这个block才能确认这个block的行为.
        id robotMock = [KWMock nullMockForClass:[ZJHKiwiCar class]];
        // 捕捉block参数
        KWCaptureSpy *spy = [robotMock captureArgument:@selector(speak:afterDelay:whenDone:) atIndex:2];
        // 设置存储参数
        [[robotMock should] receive:@selector(speak:) withArguments:@"Goodbye"];
        // 模拟对象接收的消息的某个参数是一个block
        [robotMock speak:@"Hello" afterDelay:2 whenDone:^{
            [robotMock speak:@"Goodbye"];
        }];
        // 执行block参数
        void (^block)(void) = spy.argument;
        block();
    });
2.5.1、捕捉参数

有时,你可能想要捕捉传递给模拟对象的参数.比如,参数可能没有是一个没有很好实现 isEqual: 的对象,如果你想确认传入的参数是否是需要的,那就要单独根据某种自定义规则去验证.另外一种情况,也是最常遇到的情况,就是模拟对象接收的消息的某个参数是一个block;通常必须捕捉并执行这个block才能确认这个block的行为。示例如上

2.5.2、存根的内存管理问题

未来的某天,你或许需要存根alloc等方法.这可能不是一个好主意,但是如果你坚持,Kiwi也是支持的.需要提前指出的是,这么做需要深入思考某些细节问题,比如如何管理初始化。Kiwi 存根遵循 Objective-C 的内存管理机制.当存根将返回值写入一个对象时,如果选择器是以alloc,或new开头,或含有 copy时,retain消息将会由存根自动在对象发送前发送。因此,调用者不需要特别处理由存根返回的对象的内存管理问题.

2.5.3、警告

Kiwi深度依赖Objective-C的运行时机制,包括消息转发(比如 forwardInvocation:).因为Kiwi需要预先判断出来哪些方法可以安全调用.使用Kiwi时,有一些惯例,也是你需要遵守的。为了使情况简化和有条理,某些方法/选择器,是决不能在消息模式中使用,接收期望,或者被存根;否则它们的常规行为将会被改变.不支持使用这些控制器,而且使用后的代码的行为结果也会变的很奇怪。在实践中,对于高质量的程序代码,你可能不需要担心这些,但是最好还是对这些有些印象

黑名单(使用有风险):

  • 所有不在白名单中的NSObject类方法和NSObject协议中的方法.(比如-class, -superclass, -retain, -release等.)
  • 所有的Kiwi对象和方法.

白名单(可安全使用):

  • +alloc
  • +new
  • +copy
  • -copy
  • -mutableCopy
  • -isEqual:
  • -description
  • -hash
  • -init
  • 其他任何不在NSObject类或NSobject协议中的方法.

2.6、异步测试

iOS应用经常有组件需要在后台和主线程中内容沟通.为此,Kiwi支持异步测试;因此就可以进行集成测试-一起测试多个对象.

2.6.1、异步测试简介

为了设置异步测试,你 必须 使用 expectFutureValue 装箱,并且使用 shouldEventuallyshouldEventuallyBeforeTimingOutAfter来验证。shouldEventually 默认在判定为失败前等待一秒.

[[expectFutureValue(myObject) shouldEventually] beNonNil];

标量的处理:当主语中含有标量时,应该使用 expectFutureValue中使用 theValue装箱标量,例如:

[[expectFutureValue(theValue(myBool)) shouldEventually] beYes];

shouldEventuallyBeforeTimingOutAfter():这个block默认值是2秒而不是1秒.

[[expectFutureValue(fetchedData) shouldEventuallyBeforeTimingOutAfter(2.0)] equal:@"expected response data"];

也有shouldNotEventuallyshouldNotEventuallyBeforeTimingOutAfter 的变体.

2.6.2、一个示例

这个block会在匹配器满足或者超时(默认: 1秒)时完成。This will block until the matcher is satisfied or it times out (default: 1s)

   it(@"shouldEventually", ^{ // 异步测试
        __block NSString *featchData = nil;
        
        // 模拟发送请求,处理异步回调
        dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC));
        dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
            featchData = @"数据返回";
        });
        
        [[expectFutureValue(featchData) shouldEventually] beNonNil];
    });

2.7、Kiwi使用示例

完成代码可下载:ZJHUnitTestDemo

2.7.1、测试代码(节选部分)

ArrayDataSource:

typedef void (^TableViewCellConfigureBlock)(id cell, id item);

@interface ArrayDataSource : NSObject <UITableViewDataSource>

- (id)initWithItems:(NSArray *)anItems cellIdentifier:(NSString *)aCellIdentifier configureCellBlock:(TableViewCellConfigureBlock)aConfigureCellBlock;

- (id)itemAtIndexPath:(NSIndexPath *)indexPath;

@end

@interface ArrayDataSource ()

@property (nonatomic, strong) NSArray *items;
@property (nonatomic, copy) NSString *cellIdentifier;
@property (nonatomic, copy) TableViewCellConfigureBlock configureCellBlock;

@end

@implementation ArrayDataSource

- (id)init {
    return nil;
}

- (id)initWithItems:(NSArray *)anItems cellIdentifier:(NSString *)aCellIdentifier configureCellBlock:(TableViewCellConfigureBlock)aConfigureCellBlock {
    self = [super init];
    if (self) {
        self.items = anItems;
        self.cellIdentifier = aCellIdentifier;
        self.configureCellBlock = [aConfigureCellBlock copy];
    }
    return self;
}

- (id)itemAtIndexPath:(NSIndexPath *)indexPath {
    return self.items[(NSUInteger) indexPath.row];
}

#pragma mark UITableViewDataSource

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return self.items.count;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:self.cellIdentifier forIndexPath:indexPath];
    id item = [self itemAtIndexPath:indexPath];
    self.configureCellBlock(cell, item);
    return cell;
}

@end

PhotosViewController

static NSString * const PhotoCellIdentifier = @"PhotoCell";

@interface PhotosViewController () <UITableViewDataSource, UITableViewDelegate>

@property (nonatomic, strong) ArrayDataSource *photosArrayDataSource;

@end


@implementation PhotosViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.navigationItem.title = @"Photos";
    [self setupTableView];
}

- (void)setupTableView {
    TableViewCellConfigureBlock configureCell = ^(PhotoCell *cell, Photo *photo) {
        [cell configureForPhoto:photo];
    };
    
    Store *st =[Store sharedInstance];
    NSArray *photos = [st sortedPhotos];
    self.photosArrayDataSource = [[ArrayDataSource alloc] initWithItems:photos
                                                         cellIdentifier:PhotoCellIdentifier
                                                     configureCellBlock:configureCell];
    self.tableView.dataSource = self.photosArrayDataSource;
    [self.tableView registerClass:[PhotoCell class] forCellReuseIdentifier:PhotoCellIdentifier];
}

#pragma mark UITableViewDelegate

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    PhotoViewController *photoViewController = [[PhotoViewController alloc] init];
    photoViewController.photo = [self.photosArrayDataSource itemAtIndexPath:indexPath];
    [self.navigationController pushViewController:photoViewController animated:YES];
}

@end

2.7.2、测试用例(节选部分)

ArrayDataSourceSpec是针对ArrayDataSource的测试用例,基本思路是我们希望在为一个 tableView 设置好数据源后,tableView 可以正确地从数据源获取组织 UI 所需要的信息,基本上来说,也就是能够得到“有多少行”以及“每行的 cell 是什么”这两个问题的答案。到这里,有写过 iOS 的开发者应该都明白我们要测试的是什么了。没错,就是 -tableView:numberOfRowsInSection: 以及 -tableView:cellForRowAtIndexPath: 这两个接口的实现。我们要测试的是 ArrayDataSource 类,因此我们生成一个实例对象。在测试中我们不希望测试依赖于 UITableView,因此我们 mock 了一个对象代替之。接下来向 dataSource 发送询问元素个数的方法,这里应该毫无疑问返回数组中的元素数量。接下来我们给 mockTableView 设定了一个期望,当将向这个 mock 的 tableView 请求 dequeu indexPath 为 (0,0) 的 cell 时,将直接返回我们预先生成的一个 cell,并进行接下来的处理。完成设定后,我们调用要测试的方法 [dataSource tableView:mockTableView cellForRowAtIndexPath:indexPath]dataSource 在接到这个方法后,向 mockTableView 请求一个 cell(这个方法已经被 mock),接下来通过之前定义的 block 来对 cell 进行配置,最后返回并赋值给 result。于是,我们就得到了一个可以进行期望断言的 result,它应该和我们之前做的 cell 是同一个对象,并且经过了正确的配置。至此这个 dataSource 测试完毕。

describe(@"ArrayDataSource", ^{
    // init方法校验
    context(@"Initializing", ^{
        it(@"should not be allowed using init", ^{
            [[[[ArrayDataSource alloc] init] should] beNil];
        });
    });
    
    // 配置方法校验
    context(@"Configuration", ^{
        __block UITableViewCell *configuredCell = nil;
        __block id configuredObject = nil;
        
        TableViewCellConfigureBlock block = ^(UITableViewCell *a, id b){
            configuredCell = a;
            configuredObject = b;
            
            [[configuredObject should] equal:@"a"];

        };
        // 生成数据源
        ArrayDataSource *dataSource = [[ArrayDataSource alloc] initWithItems:@[@"a", @"b"]
                                                              cellIdentifier:@"foo"
                                                          configureCellBlock:block];
        // mock一个tableView
        id mockTableView = [UITableView mock];
        UITableViewCell *cell = [[UITableViewCell alloc] init];
        
        __block id result = nil;
        NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
        
        it(@"should receive cell request", ^{
            // tableView设置存根
            [[mockTableView should] receive:@selector(dequeueReusableCellWithIdentifier:forIndexPath:)
                                  andReturn:cell
                              withArguments:@"foo",indexPath];
            // dataSource 调用代理方法
            result = [dataSource tableView:mockTableView cellForRowAtIndexPath:indexPath];
        });
        
        it(@"should return the dummy cell", ^{
            [[result should] equal:cell];
        });
    });
    
    // 获取数据方法校验
    context(@"number of rows", ^{
        id mockTableView = [UITableView mock];
        ArrayDataSource *dataSource = [[ArrayDataSource alloc] initWithItems:@[@"a", @"b"]
                                                              cellIdentifier:@"foo"
                                                          configureCellBlock:nil];
        it(@"should be 2 items", ^{
            NSInteger count = [dataSource tableView:mockTableView numberOfRowsInSection:0];
            [[theValue(count) should] equal:theValue(2)];
        });
    });
});

SPEC_END

PhotosViewControllerSpec是针对PhotosViewController的测试用例。我们模拟了 tableView 中对一个 cell 的点击,然后检查 navigationControllerpush 操作是否确实被调用,以及被 push 的对象是否是我们想要的下一个 ViewController。要测试的是 PhotosViewController 的实例,因此我们生成一个。对于它的 UINavigationController,因为其没有在导航栈中,也这不是我们要测试的对象(保持测试的单一性),所以用一个 mock 对象来代替。然后为其设定 -pushViewController:animated: 需要被调用的期望。然后再用输入参数捕获将被 push 的对象抓出来,进行判断。在这里我们用 stub 替换了 photosViewControllernavigationController,这个替换进去的 UINavigationController 的 mock 被期望响应 -pushViewController:animated:。于是在点击 tableView 的 cell 时,我们期望 push 一个新的 PhotoViewController 实例,这一点可以通过捕获 push 消息的参数来达成。关于 mock 还有一点需要补充的是,使用 +mock 方法生成的 mock 对象对于期望收到的方法是严格判定的,就是说它能且只能响应那些你添加了期望或者 stub 的方法。比如只为一个 mock 设定了 should receive selector(a) 这样的期望,那么对这个 mock 发送一个消息 b 的话,将会抛出异常 (当然,如果你没有向其发送消息 a 的话,测试会失败)。如果你的 mock 还需要相应其他方法的话,可以使用 +nullMock 方法来生成一个可以接受任意预定消息而不会抛出异常的空 mock。

describe(@"PhotosViewController", ^{
    context(@"when click a cell in table view", ^{
        it(@"A PhotoViewController should be pushed", ^{
            // 新建PhotosViewController对象
            PhotosViewController *photosViewController = [[PhotosViewController alloc] init];
            // 判断view的创建
            UIView *view = photosViewController.view;
            [[view shouldNot] beNil];
            
            // mock一个导航条
            UINavigationController *mockNavController = [UINavigationController mock];
            // 设置photosViewController存根
            [photosViewController stub:@selector(navigationController) andReturn:mockNavController];
            // 设置mockNavController存根
            [[mockNavController should] receive:@selector(pushViewController:animated:)];
            // 添加参数捕捉
            KWCaptureSpy *spy = [mockNavController captureArgument:@selector(pushViewController:animated:)
                                                           atIndex:0];
            // 调用参数
            [photosViewController tableView:photosViewController.tableView
                    didSelectRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]];
            
            // 获取捕捉的参数
            id obj = spy.argument;
            PhotoViewController *vc = obj;
            // 校验参数是否正确
            [[vc should] beKindOfClass:[PhotoViewController class]];
            [[vc.photo shouldNot] beNil];
        });
    });
});

SPEC_END

三、Kiwi原理分析

3.1、构建Spec Tree

以章节 2.1、Kiwi测试的基本结构 示例为例,最开头的SPEC_BEGIN(SimpleStringSpec)和结尾的SPEC_END。这是两个宏,我们来看看它们的定义:

// Example group declarations.
#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

通过这段定义我们知道了两件事:

  • 我们声明的SimpleStringSpec类是KWSpec的子类,重写了一个叫buildExampleGroups的方法
  • 我们的测试代码是放在buildExampleGroups的方法体里的

实际上,KWSpec作为XCTextCase的子类,重写了+ (NSArray *)testInvocations方法以返回所有测试用例对应的Invocation。在执行这个方法的过程中,会使用KWExampleSuiteBuilder构建Spec树。KWExampleSuiteBuilder会先创建一个根节点,然后调用我们的buildExampleGroups方法,以DFS的方式构建Spec树。当前的结点路径记录在KWExampleSuiteBuilder单例的contextNodeStack中,栈顶元素就是此时的context结点。

在每个结点里,都有一个KWCallSite的字段,里面有两个属性:fileName和lineNumber,用于在测试失败时精确指出问题出现在哪一行,这很重要。这些信息是在运行时通过atos命令获取的。如果你感兴趣,可以在 KWSymbolicator.m 中看到具体的实现

这样就很容易理解我们写的Spec本质上是什么了:context(...)是调用一个叫context的C函数,将当前context结点入栈,并加到上层context的子节点列表中,然后调用block()let(...)宏展开后是声明一个变量,并调用let_函数将一个let结点加到当前contextletNodes列表里。其他节点的行为也都大致相同。这里特别说明一下itpending,除了把自己添加到当前的context里之外,还会创建一个KWExample,后者是一个用例的抽象。它会被加到一个列表中,用于后续执行测试时调用。

buildExampleGroups方法中,Kiwi构建了内部的Spec树,根节点记录在KWExampleSuite对象里,后者被存储在KWExampleSuiteBuilder的一个数组中。此外,在构建过程中遇到的所有it结点和pending结点,也都各自生成了KWExample对象,按照正确的顺序加入到了KWExampleSuite对象中。万事俱备。现在只需要返回所有test case对应的Invocation,后面就交给系统框架去调用啦。

这些invocation的IMP是KWSpec对象里的runExample方法。但Kiwi为了给方法一个更有意义的名字,在运行时创建了新的selector,这个新selector根据当前Spec以及context的description,用驼峰命名组合而成的。虽然此举是出于提高可读性的考虑,但实际上组合出来的名字总是非常冗长,读起来很困难。

3.2、执行测试用例

就在刚刚,Kiwi已经构建出了一个清晰漂亮的Spec Tree,并把所有用例抽象成一个个KWExample,在testInvocations方法中返回了它们对应的Invocation。现在一切已经准备妥当,系统组件要开始调用Kiwi返回的Invocation了。之前我们说了,这些Invocation的实现是runExample,它会做什么呢?

我们只讨论it结点。因为pending结点实际上并不会做什么实质性的事情。经过层层调用,首先会进入KWExamplevisitItNode:方法里。这个方法将以下所有操作包装进一个block里(我们叫它block1):

  • 执行你写在it block里的代码——你的部分用例在这一步就已经完成了检查
  • 对自身的verifiers进行自检——这就是检查你另一部分用例是否通过的时机。后面我们还会详细说明
  • 如果有expectation没有被满足,报告用例失败,否则报告通过
  • 清除所有的spystub (不影响mock对象)。 这意味着如果你希望在整个用例里都执行某个stubspy,那么你最好把它写进beforeEach

3.3、Mock & Stub

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

现在假设我们以[ZJHNetworkTool 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)×)

当你调用了[networkMock stub:@selector(requestUrl:param:completion:) withBlock:^id(NSArray *params){..}];

KWMock将会:

  • 根据传入的selector生成一个KWMessagePattern,后者是KWStub中用于唯一区分方法的数据结构(而不是用selector)
  • 用这个KWMessagePattern生成一个KWStub对象。如果你在初始化KWMock时指定了block、returnValue、argument filter等信息,也会一并传给KWStub
  • KWStub他放到自身的列表里

现在你已经成功stub了一个mock对象中的方法。现在你调用 [networkMock requestUrl:@"someURL" param:@{} completion:^(NSDictionary *respondDic) { }]时,由于KWMock对象本身没有实现这个方法,将不会真正的走到HYNetworkEngine的下载逻辑里,而是执行所谓完全消息转发。KWMock重写了那两个方法。其中:

  • - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector返回自己mock的Class或Protocol对此selector的methodSignature。如果找不到,就用默认的"v@:"构造一个返回(还认识它吧?)

  • 接下来进入了 - (void)forwardInvocation:(NSInvocation *)anInvocation方法:

    • 如果没有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)希望监听到这次调用。如果有,就通知给他

消息转发处理的代码如下,至此,我们向mock对象创建和调用stub方法的步骤都已经完成了

- (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"];
}

3.4、Verifier and Matcher

当我们写下shouldshouldEventuallybeNilgraterThanreceive等语句时,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]

MatchVerifier

假设我们有这样的一个Expectation

[[resultError should] equal:[NSNull null]];

这段代码中,should实际上是一个宏,它创建了一个MatchVerifier,把它添加到当前Exampleverifiers 列表里,并返回这个MatchVerifier。接下来,我们调用了equal方法。实际上,MatchVerifier并没有实现这个方法,因此会走进转发逻辑。在forwardInvocation:方法中,MatchVerifier会从 matcherFactory 中查找实现了equal方法的Matcher。后者是一个遵循KWMatching协议的对象,用来判断 subject 是否满足某个条件。matcherFactory 最终找到了一个Kiwi中内置的,叫KWEqualMatcher的类,它实现了equal方法,并且没有在自己的canMatchSubject:方法中返回 NO。因此,MatchVerifier会将消息转发给它的实例。

之后,MatchVerifier会根据 matchershouldBeEvaluatedAtEndOfExample方法返回值,来决定立刻调用 matcher 中实现的evaluate方法来检测测试结果,还是等到整个 Example 执行完成后(也就是说,你在这个it节点内写的代码都执行之后。还记得前面执行测试用例那一小节提到的 verifiers 自检步骤吗?)才检查。

Kiwi内置的 matcher 中,只有KWNotificationMatcherKWReceiveMatcher是在 Example 执行完成后进行检查的,其余都是立即检查

registerMatchers

现在我们已经知道 matcherFactory 注册和使用 matcher 的原理了,自定义一个 matcher 也是水到渠成的事情。事实上,我们只需要创建一个遵循KWMatching协议的类——当然,继承KWMatcher或许是一个更方便的选择。这个类中需要实现的方法和其作用,我们大部分都已经说过了。接下来,在当前的 context 下使用registerMatchers函数将你的 matcher 注册给 matcherFactory,记得传入的参数要和你刚刚创建的 matcher 类名前缀严格一致。

AsyncVerifier

上面说过,AsyncVerifierMatchVerifier的子类。这意味着,它也是通过 matcherFactory 提供的 matcher 去判断你的 Expectation 是否通过的。唯一不同的是,它会以0.1s为周期对结果进行轮询。具体的实现方式为:在当前线程使用 Default 模式,以0.1s为时长运行RunLoop。这意味着,虽然它的名字带了Async,但实际上它的轮询操作是同步执行的。你最好把AsyncVerifier这个名字理解为:用于测试你的Async操作结果的Verifyer

所以,一般情况下没有必要把等待时间设置得过长。

AsyncVerifier有两种使用方法,分别是shouldEventually...shouldAfterWait...,你可以指定等待的时间,否则默认为1秒。两种方法的区别在于:前者在轮询过程中发现预期的结果已经满足,会立刻返回。后者则会固定执行到给定的等待时间结束后才检测结果。



参考链接:
TDD的iOS开发初步以及Kiwi使用入门:https://onevcat.com/2014/02/ios-test-with-kiwi/
Kiwi,BDD行为测试框架--iOS攻城狮进阶必备技能:https://cloud.tencent.com/developer/article/1011286
iOS 自动化测试框架 Kiwi 的使用介绍及原理分析:https://cloud.tencent.com/developer/article/1972234
Kiwi 使用进阶 Mock, Stub, 参数捕获和异步测试:https://onevcat.com/2014/05/kiwi-mock-stub-test/

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容