Unit Testing for iOS Part Ⅰ

本文来自 Jon ReidMCE 2014 上的演讲 Test Driven Development for iOS (and anything),本来大家学习 TDD 的动力都不足,翻了很多国内的文章更是把初学者都吓跑的节奏,而这篇演讲主要介绍了 TDD 的一些基本概念和在 iOS 上的应用,通俗易懂,就随手听译了一下做个记录,水平有限,多多指正。

一、TDD

首先来看下 TDD 的介绍:

测试驱动开发(Test-driven development)是极限编程中倡导的程序开发方法,以其倡导先写测试程序,然后编码实现其功能得名。测试驱动开发始于20世纪90年代。测试驱动开发的目的是取得快速反馈并使用“illustrate the main line”方法来构建程序。

测试驱动开发是戴两顶帽子思考的开发方式:先戴上实现功能的帽子,在测试的辅助下,快速实现其功能;再戴上重构的帽子,在测试的保护下,通过去除冗余的代码,提高代码质量。测试驱动着整个开发过程:首先,驱动代码的设计和功能的实现;其后,驱动代码的再设计和重构。

摘自 wikipedia

简单说来,就是先写一个测试例来测试还不存在功能(注定会失败),然后再来补齐该功能,让先前的测试通过,最后再重构,依次反复,如下图所示:

TDD 的精髓是重构

二、Unit Test

现在再回到 TDD 的核心 Unit Test,有三种类型的 Unit Test:

  • Return Test
  • State Test
  • Interaction Test

分别来看一下:

1. Return Test

Return Test 很简单,理解起来也很直观,用来测试有返回值的方法,主要是三个 A 字母开头的步骤

  • Arrange:做一些初始化设置
  • Act:调用要被测试的方法得到一个返回值
  • Assert:用期望值和该返回值做比较

上图中的 SUT 可能是一个对象,也可能是许多对象组成,都没有关系,只要他能满足『调用方法得到返回值』,我们就说他是可测试的

2. State Test

Return Test 类似,也是 3A 原则,不同之处在于,我们要测试的方法并没有返回值,因此需要手动去查询我们感兴趣的 对象状态,确认是否符合我们的期待。

3. Interaction Test

第三种 Interaction Test 理解起来要稍微要复杂一些,前两种测试,都直接与 SUT 交流就可以了,但第三种情形 SUT 还需要和第三方(Something Else)联络。

举个栗子🍠来看一下:

你和妹子共进晚餐,通常情况下是直接和服务生交流,并不需要知道厨师是谁,也不需要与他交流,更不关心食物是怎么做出来的,最后你所能记住的只是妹子那娇美的面容和一段美妙而温存的晚餐回忆罢了。

醒醒,回到我们的测试主题中来,这里并不是你和妹子的美好时光,其实这一对顾客是 Boss 派来的卧底,用来考察服务生的各项服务指标。因此,顾客可以看做 Test 程序,而服务生则是 SUT。因为顾客的目的不是考察厨师,所以哪怕扫地大妈临时客串下厨师,顾客也不会在意

在实际的测试中,有一个指标不容忽视:那就是一定要快!,顾客想考察服务生 点餐到端盘 这一过程,如果启用真正的厨师,实际的做饭就会消耗相当长的时间。但我们并不想吃饭,只是想考察服务生的服务质量,那么有没有一种办法缩短厨师的做饭时间呢,我们可以假设一种 Fake 厨师,只要服务生向 Fake 厨师 提供餐单,立即就做好。

在程序世界中,同样的思想适用于测试一些网络请求,读取大文件等比较消耗时间的情形。

要达到这一目标,服务生必须放弃对厨师的控制,即服务生不需要知道关于厨师的信息

因为服务生的上菜服务依赖于厨师,现在我们用另外一个 Fake Cook 来替代原有的厨师,这种技术叫做 Dependency Injection

接下来看具体细节

一共四种类型的依赖注射,我们一一来看:

①. Extract and Override

@implementation ReminderID

- (NSNumber *)nextID
{
    NSNumber *result = [[NSUserDefaults standardUserDefaults] objectForKey:@"reminderID"];
    if(!result) {
        result = @0;
    }
    return result;
}

@end

思考上面这个方法,我们想要替换 [NSUserDefaults standardUserDefaults] 这个单例,该怎么做?

首先应该想办法提取出来

@implementation ReminderID

- (NSNumber *)nextID
{
    NSNumber *result = [[self userDefaults] objectForKey:@"reminderID"];
    if(!result) {
        result = @0;
    }
    return result;
}

- (NSUserDefaults *)userDefaults
{
    return [NSUserDefaults standardUserDefaults]
}

@end

我们用一个 userDefaults 方法将需要 fake 的单例给封装了起来,接下来就给了我们机会来替换掉他。然后我们在测试中不使用真正的 ReminderID,而是创建一个子类来使用,且在这个子类中重载 userDefaults 即需要 fake 的 model

@interface TestingReminderID : ReminderID
@end

@implementation TestingReminderID

- (NSUserDefaults *)userDefaults
{
    return nil; // whatever you want!
}

@end

这样就完成了对 [NSUserDefaults standardUserDefaults] 的替换

②. Method Injection

第二种方式是对方法的注射,如对方法参数的改造,这里推荐使用 AppCode 来重构,Xcode 还不支持对方法名和参数的重构

@implementation ReminderID

- (NSNumber *)nextID
{
    NSNumber *result = [[NSUserDefaults standardUserDefaults] objectForKey:@"reminderID"];
    if(!result) {
        result = @0;
    }
    return result;
}

@end

After Method Injection:

@implementation ReminderID

- (NSNumber *)nextIDFromUserDefaults:(NSUserDefaults *)userDefaults
{
    NSNumber *result = [userDefaults objectForKey:@"reminderID"];
    if(!result) {
        result = @0;
    }
    return result;
}

@end

我们将 Fake Model 直接通过方法参数传进来。

③. Property Injection

这个就更简单了,创建一个 property,然后覆盖他的 getter 方法即可

@interface ReminderID : NSObject

@property (nonatomic, strong) NSUserDefaults *userDefaults;

- (NSNumber *)nextID;

@end

@implementation ReminderID

- (NSNumber *)nextID
{
    NSNumber *result = [self.userDefaults objectForKey:@"reminderID"];
    if(!result) {
        result = @0;
    }
    return result;
}

- (NSUserDefaults *)userDefaults
{
    if (!_userDefaults) {
        _userDefaults = [NSUserDefaults standardUserDefaaults];
        // whatever you want!
    }
    return _userDefaults;
}

@end

④. Constructor Injection

最后一种是在类初始化方法中进行注射

@implementation ReminderID
{
    NSUserDefaults *_userDefaults
}

- (instancetype)initWithUserDefaults: (NSUserDefaults *)userDefaults
{
    self = [super init];
    if (self) {
        _userDefaults = userDefaults
    }
    return self
}

- (NSNumber *)nextID
{
    NSNumber *result = [_userDefaults objectForKey:@"reminderID"];
    if(!result) {
        result = @0;
    }
    return result;
}

@end

以上四种时机可以将 Fake Model 进行注射(替换原有数据源),一般推荐第四种 Constructor Injection,因为这种方式可以让 dependency 更加明晰,让你在初始化的时候就能清楚需要什么

继续回到 Interaction Test 的主题上来

既然我们知道 SUT 要和 Fake Model 进行交流,那么 Fake Model 的类型被分为了

  • Stub
  • Mock

这种分法是《The Art of Unit Testing》的作者提出来的

二者都是模拟外部依赖,以便测试能很快得到响应,而不同则在于:

  • Stub:Test 程序只和 SUT 进行测试交流,而并不关心 Fake Model
  • Mock:Test 程序先对 SUT 发出测试请求,接着跑到 Fake Model 那里去做验证,判断 SUT 传送到 Fake Model 的数据是否符合最初的预期

回到餐厅考核服务员的例子上来,stub 就是顾客发出点餐指令,服务员最后端上来的菜符合要求就是测试通过,整个过程和厨师无关,也不关心服务员与厨师之间的交流。 而 Mock 则是顾客向服务员发出点餐指令后,小跑到厨房,询问厨师是否收到了指令,并 比对 服务员提交的指令和自己的点餐指令是否一致。

Test 程序只关心 SUT 的相关对象的状态是否符合期待,并不关注 SUT 与 Fake Model 之间的交流

Test 程序跳过 SUT 转而向 Mock 来测试相关状态,例如,顾客直接向 Fake Cook 询问是否从服务生那里接受到了相关指令

二者还有一个区别就是,使用 Stub 时允许有许多个,而 Mock 却只能有一个

可以理解为,厨师是 Stub 的模式下,因为顾客(Test)不关心服务员(SUT)与厨师(Fake)之间的通讯,厨师可能有做凉菜的,做热菜的。但顾客不关心,顾客只要发出指令,服务员满足自己就好了。 而 厨师是 Mock 模式下,因为顾客(Test)对服务员(SUT)发出指令后,要亲自跑到厨房去做验证,这个时候如果有多个厨师(Fake),就很容易混乱,所以还是只有一个的好。

三、Networking 中的实践

考虑下面的代码如何测试

- (void)fetchResources
{
    AFHTTPRequestOperationManager *manager = [AFHTTPRequestOperationManager manager];
    [manager GET:@"http://example.com/resources.json"
        parameters:nil
            success:^(AFHTTPRequestOperation *operation, id responseObject) {/*handle success */}
            failure:^(AFHTTPRequestOperation *operation, NSError *error) {/* handle failure */}
    ];
}

第一步是 Inject dependency,依赖注射,任何情况下我们需要从这种单例中获取数据,一般都要进行替换,这里我们对 manager 进行 inject:

下面记录 manager Get... 方法的调用次数,因为该方法可能会被调用多次,其中某次或许就会出错

在某些情况下可能还会检查返回值是否符合期待(虽然本例中我们用不到)

我们可以对返回值进行检查,这种方式 manager 所代表的对象就成了 Stub,而在这个例子中,我们真正感兴趣的是从网络返回的数据,即 sucees block 中的第二个 responseObject 参数,我们可以捕获他,然后再和我们期望值进行比较,此刻 manager 就成为了 Mock

Let's Make a Fake

虽然有 OCMock 这种开源项目,但 Jon Reid 给出的建议还是自己写,毕竟想要理解其中的原理,还是自己上手来得快一些。

第一步 Stub the method,这里我们没有创建子类,而是直接从 NSObject 继承创建了一个新类 MockAFNetworkingGET ,这得益于 OC 是一门动态语言,我们不关心这个方法属于哪个类。在这里我们返回一个 nil

第二步,我们设置一个 callCount 属性来记录方法被调用的次数

第三步,Fake 一个返回值,之前直接返回了一个 nil,现在用 fakeReturnValue 来替代,这个属性如果没有被设置的话,一样返回 nil

第四步,我们想捕获该方法的所有参数,简单的方式就是在头文件中设置一堆属性,然后通过方法参数传递进来。有点类似于之前提到的 Constructor Injection

Let's Write a Test 1

让我们来写第一个测试,判断这个方法是否被调用了一次,还记得之前的 3A 原则么,注意 Assert 这一步 Test 直接和 mockGET 交流,所以这种 Fake 类型是 MOCK。

这种形式的测试在 Interaction Test 中属于比较脆弱的,虽然很强大,但不小心的话会出错

Let's Write a Test 2

第二个测试,模拟一个 JSON 响应(Response)

同样是 3A 法则,Arrange 并没有什么不同,而 Act 这步直接调用 mockGET.success(...) 假装 mockGET 的 success block 捕获了一个 json 结果,而这个 json 是我们亲手将 "Jon Reid" 编码后放进去的。这样所以最后我们比较 sut 获取的 name 和我们放进去的 name 是否相同。

功夫不负有心人,学习 TDD 终于给你回报了,在这里你不用真正的 server,就能测试相关的网络请求,甚至不用连接真正的网络,速度也是相当快。

Refactor Test Code:

还记得一开始介绍 TDD 那张图吗?重构也是其中重要一环。我们回顾下上面两个 Test,将其中重复的代码标记出来

在 iOS 的测试中,提供了 setUptearDown 分别对应着测试类在整个生命周期中的开始结束,上面标记出来的部分都是 3A 法则中的 Arrange,因此都可以放进测试类的 setUp 方法中

将一些初始化方法移走后, Test 看上去就清爽多了

注意观察第二个方法,如果我们现在有很多很多类型的 JSON 解析要来测试,难道还要写很多重复的方法么,当然不用,我们可以进一步抽象一下,将其中的 mockGET.success(...) 抽象出来

以上就是关于重构的一些例子,你所要做的就是尽量让你写的每一个 test 功能单一,简单到程序出错,你甚至不用 debug 就能找到问题所在

最后我们再来回顾一下 Make a Fake

这就是 Test Driven Development 在 iOS 中的一些基本要点,下一部分打算着重从 iOS 方面总结下 Test 的要点,包括 Unit Tests, Assertions, Asynchronous Task Tests , Performance Tests, 以及 Xcode 7 的新特性 UI Testing,敬请期待。


-EOF-

如果感觉此文对你有帮助,请随意打赏支持作者 😘

chengway

认清生活真相之后依然热爱它!

Subscribe to Talk is cheap, Show me the world!

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!