单元测试

单元测试,一个不断被强调,又不断被人忽略的话题。如何编写合适的测试用例?何时该进行单元测试?单元测试所体现的价值究竟是什么?可以说,有很多实际的困扰阻碍着一批人,使得这些人被卡在了单元测试的门外,万事起步难,而当你真正的理解了一件事情的意图,就能很容易的从各个方面入手了。单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。对于单元测试中单元的含义,一般来说,要根据实际情况去判定其具体含义,如C语言中单元指一个函数,Java里单元指一个类,图形化的软件中可以指一个窗口或一个菜单等。总的来说,单元就是人为规定的最小的被测功能模块。单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。

单元测试推荐使用框架 Quick

单元测试的价值

减少低级错误

这一点是毋庸质疑的,测试所存在的最主要价值就是帮我们解决错误,单元测试也是这样。当我们在对自己的代码进行测试时,能很容易的就排除掉一些非常低级的错误,起码我们能够保证,在一些正常的情况下,代码是可以正常工作的。

当经历的语言和平台越来越多,很多平台相关的特性有时候并不是靠感觉就能拿得准的,比如你并不清楚NSString对象的equalTo和equalToString这两个方法执行效果是否相同,那么你就有必要对使用到的代码进行测试去验证下,避免出现人为意识造成的低级Bug。

减少调试时间

可以说,在开发中我们有大部分的时间可能都是出于调试状态,减少调试时间,自然也就提高了产出率,而单元测试是否能提高产出率一直也是有点争议,不过它的确能够有效的减少调试时间。

在一个应用中,并不是所有需要调试的代码都在程序的入口点,所以,当我们需要调试时,会花费一些额外的时间来触发调试的代码。单元测试就能很好的解决这个问题,我们针对需要调试的代码,构建相关测试上下文,配合IDE,能方便快速的进行反复模拟、测试。

描述代码行为

很多书上都会说,代码就是最好的文档(当然是写得比较好的代码),注释需要能够精简,否则大片的注释会影响阅读。这点我是非常赞同的,而单元测试,作为代码的一等公民,我觉得它能更好的描述代码的行为。在撰写单元测试时,我们基本上都是假定某个方法,在某个特定的环境中,能够有预期的表现。如果这样的测试足够完善,那么,当我们去看别人测试时,就能很清楚他提供的方法是为了适应怎样的场景,能够更好的理解设计者的意图。

可维护性增强

当一个项目中单元测试的覆盖率很可观,后期在对代码进行修改时,能够很容易就知道是否破坏了老的业务逻辑,这样大大的降低了回归出错的可能性。当我们从测试那获得一个Bug时,可以通过测试用例去还原,当我们这个测试通过后,这个Bug也就解决了,而这个Bug Fix的测试用例也保证了以后这个Bug不会再次复现。

这会是一个很好的良性循环,我们的代码会越来越健壮,而我们可以把心思放在更多更有意义的事情上,比如重构。有了单元测试的保障,我们可以比较大胆的进行重构设计,当然,在重构时单元测试也会成为一种负担,我们可能需要同时重构单元测试,不过,相比于可靠性,这种负担还是非常值得去承受的。

改善设计

测试驱动设计,这在敏捷开发中是非常火热的名词,但我自身并不认为在一个较大型的项目中,能够完全按照这样的方式来驱动。虽然如此,但测试从一定的程度上能够改善设计,比如为了让一些类的某些行为中的细节得到充分测试(心里不再惴惴不安),我们就必须要对这些行为进行细分,于是我们开始提取方法,构建测试用例。这样,我们方法的行为会越来越单一,而良好的类设计中,正是需要这样的方法设计。

测试用例三部曲

如何比较好的来编写一个测试用例,对此,有很多不同的做法,而这也并没有一个标准,也不需要有一个标准。我们需要清楚一个测试用例存在的意义是什么,它是为了验证某个类的某个行为在某种上下文中能得到预期的结果,如果你的测试用例达到了这样的目的,那么如何写也都不算错。不过,为了能够统一单元测试的规范(这点在多人协同开发下非常重要),我们常常会把一个测试用例分为三个阶段:排列资源、执行行为、断言结果,一般我会习惯用Arrange、Act、Assert来表示,也会有用Given,When,Then来表示的,但意思都相同。

排列资源

排列资源,便是提供一切测试方法所需要的东西,而这些东西便称之为资源。这些资源包括:

  1. 方法输入的参数

  2. 方法所执行的特定上下文

这个阶段相当于准备阶段,一切都是为了这个用例中执行行为而准备,如果没有任何需要准备的数据,这个阶段是可以被忽略的。

这里我们以测试 NSMutableDictionary的 dic setObject: forKey: 为例,那么在排列资源阶段,我们的代码如下:

  • (void)test_setObject$forKey { // arrange NSString *key = @”test_key”; NSString *value = @”test_value”; NSMutableDictionary *dic = [NSMutableDictionary new]; } 关于测试用例的命名,比较推崇这样的写法:

test_测试方法签名_测试上下文

由于Objective-C的方法签名比较奇怪,为了可读性,建议使用$进行分割,比如这个示例中的test_setObject$forKey,或者附带上下文的test_setObject$forKey_when_key_is_nil。

执行行为

当准备阶段完毕后,便进入要测试行为的执行阶段,在这个阶段,我们会使用准备好的资源,并记录下行为输出以供下个阶段使用。这里的行为输出不一定就是方法执行的返回值,很多时候我们要测试的方法并没有任何返回值,但一个方法执行后,总归有一个预期的行为发生,即便是空方法也是(什么也不会改变),而这个行为预期便是测试行为的输出。

加入执行行为的代码:

  • (void)test_setObject$forKey { // arrange NSString *key = @”test_key”; NSString *value = @”test_value”; NSMutableDictionary *dic = [NSMutableDictionary new];

    // act [dic setObject:value forKey:key]; }

断言结果

最后一步,也是核心的一步,它决定着一个 测试用例的成功与否,我们需要在这一步断言执行行为的输出是否达到预期。确定一个行为的输出,我们可能需要多次断言,这里需要遵循一个原则:** 先执行的断言,不应该以以后断言的成功为前提。** 以上原则很重要,这对快速排除Bug会很有帮助。现在,我们来看下针对 NSMutableDictionary 的这个完整测试用例:

  • (void)test_setObject$forKey { // arrange NSString *key = @”test_key”; NSString *value = @”test_value”; NSMutableDictionary *dic = [NSMutableDictionary new];

    // act [dic setObject:value forKey:key];

    // assert XCTAssertNotNil([dic objectForKey:key]); XCTAssertEqual([dic objectForKey:key], value); }

可以看到,最后我们先断言是否为空,再断言是否相等,后者是在前者成功的前提下才可能不失败。如果颠倒顺序,就很难尽早的发现错误原因,我们应该下意识的将这种断言的依赖关系排序正确,就像我们在很多语言里使用 try…catch 时,我们会排列好异常捕获的顺序。

普通逻辑功能测试–XCTAssert

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
我们定位XCTAssertNotNil跳转到声明文件XCTestAssertions.h文件,包含很多判断,全部是宏定义方式:
 XCTFail(format…) 生成一个失败的测试;

XCTAssertNil(a1, format...)为空判断,a1为空时通过,反之不通过;

XCTAssertNotNil(a1, format…)不为空判断,a1不为空时通过,反之不通过;

XCTAssert(expression, format...)当expression求值为TRUE时通过;

XCTAssertTrue(expression, format...)当expression求值为TRUE时通过;

XCTAssertFalse(expression, format...)当expression求值为False时通过;

XCTAssertEqualObjects(a1, a2, format...)判断相等,[a1 isEqual:a2]值为TRUE时通过,其中一个不为空时,不通过;

XCTAssertNotEqualObjects(a1, a2, format...)判断不等,[a1 isEqual:a2]值为False时通过;

XCTAssertEqual(a1, a2, format...)判断相等(当a1和a2是 C语言标量、结构体或联合体时使用,实际测试发现NSString也可以);

XCTAssertNotEqual(a1, a2, format...)判断不等(当a1和a2是 C语言标量、结构体或联合体时使用);

XCTAssertEqualWithAccuracy(a1, a2, accuracy, format...)判断相等,(double或float类型)提供一个误差范围,当在误差范围(+/-accuracy)以内相等时通过测试;

XCTAssertNotEqualWithAccuracy(a1, a2, accuracy, format...) 判断不等,(double或float类型)提供一个误差范围,当在误差范围以内不等时通过测试;

XCTAssertThrows(expression, format...)异常测试,当expression发生异常时通过;反之不通过;(很变态) XCTAssertThrowsSpecific(expression, specificException, format...) 异常测试,当expression发生specificException异常时通过;反之发生其他异常或不发生异常均不通过;

XCTAssertThrowsSpecificNamed(expression, specificException, exception_name, format...)异常测试,当expression发生具体异常、具体异常名称的异常时通过测试,反之不通过;

XCTAssertNoThrow(expression, format…)异常测试,当expression没有发生异常时通过测试;

XCTAssertNoThrowSpecific(expression, specificException, format...)异常测试,当expression没有发生具体异常、具体异常名称的异常时通过测试,反之不通过;

XCTAssertNoThrowSpecificNamed(expression, specificException, exception_name, format...)异常测试,当expression没有发生具体异常、具体异常名称的异常时通过测试,反之不通过

特别注意下XCTAssertEqualObjects和XCTAssertEqual。

XCTAssertEqualObjects(a1, a2, format...)的判断条件是[a1 isEqual:a2]是否返回一个YES。

XCTAssertEqual(a1, a2, format...)的判断条件是a1 == a2是否返回一个YES。

性能测试

性能测试主要使用 measureBlock 方法 ,用于测试一组方法的执行时间,通过设置baseline(基准)和stddev(标准偏差)来判断方法是否能通过性能测试。

1
2
3
4
5
6
7
- (void)testDuration{
    [self measureBlock:^{
        for (int i=0; i<1000; i++) {
            NSLog(@"abc");
        }
    }];
}

设置时间耗时标准如下图:

图片1

我们来看看这几个参数都是啥意思:

  • Baseline 计算标准差的参考值

  • MAX STDD 最大允许的标准差
  • 底部点击1,2…10可以看到每次运行的结果。

点击Edit,我们点击Average的右边的Accept,来让本次运行的平均值设置为baseline,然后然后MAX STDD改为40%。再运行这个测试用例,你会发现测试成功了。

异步测试

异步测试的逻辑如下,首先定义一个或者多个XCTestExpectation,表示异步测试想要的结果。然后设置timeout,表示异步测试最多可以执行的时间。最后,在异步的代码完成的最后,调用fullfill来通知异步测试满足条件。具体案例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (void)testAsync {
    //1: 创建XCTestExpectation对象
    XCTestExpectation  *expectation = [self expectationWithDescription:@"test succees"];
    //2.异步代码
    [[HLLNetQZTC alloc] queryAnonymousStatusWithisAnonymous:true CompletionHandler:^(NSData *data, NSDictionary *responseDictionary, NSError *error) {
        NSLog(@"%@",responseDictionary);
        //3: 对结果进行判断
        XCTAssertNil(error);
        //其他操作 ....
        
        //4: 异步结束调用fulfill,告知请求结束
        [expectation fulfill];
    }];
    //5: 如果15秒内没有收到fulfill方法通知调用次方法
    //超时后执行一些操作:
    [self waitForExpectationsWithTimeout:12 handler:^(NSError * _Nullable error) {
        XCTAssertNil(error,@"time out");
    }];
    //6: 对象被回收
    XCTAssertNil(expectation, @"expect should be nil");
}

Mock

为了能够不受依赖类的实例影响,我们可以将依赖的行为抽象成接口,依赖类去实现这样一个接口,最终可以通过构造函数或者其他方式注入进来。我们通过单元测试,又将设计推导到了另一个高度:依赖抽象而不是实现具体细节。 通过接口隔离依赖后,在单元测试里,我们可以撰写一些用于测试的模拟实现,也就是我们实现这样一个接口,但只是为了测试某种行为去实现它,这便是所谓的 ** Mock**。

手动实现一个个Mock是非常耗时的,为了测试不同的行为,我们可能需要不同的Mock对象,幸好几乎每一平台的单元测试都会有相应得Mock框架,Objective-C也不例外,推荐使用的两个框架:

1. OCMockito

2. OHHTTPStubs(专门用来mock网络请求数据),使用方法入下:

没有使用stub的请求:

1
2
3
4
5
6
7
    NSString* urlString = @"http://images.apple.com/support/assets/images/products/iphone/hero_iphone4-5_wide.png";
    NSURLRequest* req = [NSURLRequest requestWithURL:[NSURL URLWithString:urlString]];
    
    // This is a very handy way to send an asynchronous method, but only available in iOS5+
    [[[NSURLSession sharedSession] dataTaskWithRequest:req completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
         self.imageView.image = [UIImage imageWithData:data];
    }] resume];

使用stub拦截请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
	//初始化
     static id<OHHTTPStubsDescriptor> imageStub = nil; // Note: no need to retain this value, it is retained by the OHHTTPStubs itself already :)

        // Install
        imageStub = [OHHTTPStubs stubRequestsPassingTest:^BOOL(NSURLRequest *request) {
            // 用来规定被拦截的请求的url规则, 下面示例标示拦截以.png结尾的url, 对其他url没有影响
            return [request.URL.pathExtension isEqualToString:@"png"];
        } withStubResponse:^OHHTTPStubsResponse *(NSURLRequest *request) {
            // 拦截来请求后,返回用来代替请求结果的数据, 一般是存放在本地的图片或者文字
            return [[OHHTTPStubsResponse responseWithFileAtPath:OHPathForFile(@"stub.jpg", self.class)
                                                     statusCode:200
                                                        headers:@{@"Content-Type":@"image/jpeg"}]
                    requestTime:[self shouldUseDelay] ? 2.f: 0.f
                    responseTime:OHHTTPStubsDownloadSpeedWifi];
        }];
        imageStub.name = @"Image stub";

单元测试基本原则

做到真正的单元测试

不知道大家有没有认真想过,这种测试为什么要叫Unit Test?顾名思义,是针对Unit来进行测试,也就是针对基本的单元进行测试。所以要做到真正的单元测试,你需要保证你每个测试用例所针对的仅仅是一个基本的单元,而不是很多复杂依赖的综合行为。

关于行为测试

在面向对象的程序设计中,一般最基本的单元就是一个类的方法,所以在单元测试中,我们要面对的就是针对这些方法编写合适的测试用例。方法就是一个类的对外行为,针对方法的测试也可以看做是针对一个类的行为测试,在编写测试用例时,我们不应该考虑一个行为的中间产出,我们应该将关注点放在最终的测试执行结果上。

关于行为测试,目前已经有一套相关的理论和相应的测试框架,可以参考 行为驱动开发

关于隔离依赖

前面也提到了,我们要的是针对一个基本单元的测试,这样的要求会促使我们改善设计。我们应该竟可能让类方法的职责单一,这会方便我们撰写测试用例。理想中,每个类都是独立的,但现实里,一个类很少会没有依赖关系,而在编写测试用例时,我们不应该将依赖的类行为纳入到该类的测试用例中,被依赖的类应该是经过了单独测试,我们需要假定它是完全合理正确的。

接口模拟与集成测试

为什么我们需要通过模拟去测试类的行为?既然这个类有依赖,何不将他依赖的具体实现直接使用在测试用例里?这样单元测试和运行时效果还会更加接近。

相信很多人都有过上面这样的疑问,其实根本原因还是很简单的:关注点更单一。怎样才能做好一件事情,那就要足够的专注,任何所谓的成功都离不开专注。单元测试专注于一个单元的测试,它不是多个单元糅合在一起,这样才能保证变化点都集中在被测试的单元中,才能体现出更好的维护价值。 那么,当我们几乎将所有类的公开行为都进行了单元测试,这时候我们就应该去编写集成测试了,集成测试与单元测试的关注点不同,它关心的是实现类在特定场景下交互的最终结果,可以说集成测试会更加动态,它可以模拟很多业务场景,而单元测试相对比较静态,它只是用来验证一个动作的正确性。

所以,在优良的测试项目中,单元测试会和集成测试分开,当然现实中不一定会这么做。就比如我们测试 REST API 时,单元测试应该回去模拟网络返回的数据,而集成测试才会真实的发送网络请求,很多时候我们都直接使用了后者,这样做感觉很方便,而好坏就留给大家自己去斟酌吧。

单元的测试的基本实践

  • 单元测试不依赖主工程
  • 单元测依赖主工程