概述

本文为多篇iOS单元测试文章总述和实践总结。前部分分析iOS开发单元测试的目的和原则,后部分讲述Kiwi和OHHTTPStubs框架的基本用法及问题记录。

单元测试目的:

  • 使重构更简单——重构后快速通过单元测试回归旧有功能
  • 避免代码恶化——设计API考虑更全面,对提高设计和可扩展性有帮助
  • 提供可执行的说明和文档
  • 降低开发软件的代价——更快速地编译和修改代码,降低开发时间和风险

测试对象:

  • 与UIView无关的类,例如:ViewModel层和工具类
  • 待测试类的所有Public API均需要测试

不应该测试什么:

  • 不测私有方法,私有方法数量多,修改不需要专门通知
  • 避免stub私有方法(理由同上,且私有方法如果已被修改或删除,仍然对其按旧逻辑stub单元测试就不再精确)
  • 避免stub外部库,第三方代码不应该直接在测试中出现,因为如果出现第三方库这个替换的情况,则单元测试也均需要修改(也说明了分层设计、对第三方库进一步封装的重要性)
  • 可不考虑ViewController和View的测试,因为改动频繁,维护测试代码代价高,对象创建依赖xib、Storyboard也不方便

测试需要具备的特性:

  • 执行快速、可自动执行
  • 能隔离——不依赖外部因素或其它测试结果,要可以假设其它需要依赖的类和方法均已通过单元测试
  • 可重复——每次运行都应该产生相同的结果
  • 自带检测——包含断言,不需要人为干预即显示测试结果
  • 需要后期维护——公开API的逻辑修改、接口增减,均需要对相应执行失败的测试用例进行更新

使用框架

XCTest测试框架

与Xcode直接集成,项目默认已添加,使用方便,测试结果展示直观。
测试用例被分到继承XCTestCase的不同子类中去。每个以test为开头的方法都是一个测试用例。

缺点:

  • 书写性和读写性不好
  • 测试用例过多时,各个测试方法是割裂的,在测试文件中找到某个特定的测试和弄明白这个测试在做什么不容易
  • 测试都是由断言完成的,很多时候断言的意义不是特别明确
  • 测试的描述都是加在断言之后的,夹杂在代码中,难以寻找阅读
  • 难以mock或stub

Kiwi测试框架

iOS平台经典的行为驱动开发(BDD)测试框架,基于XCTest封装,解决了上述XCTest的缺点。

Kiwi提倡通过将测试语句转换成类似自然语言的描述,开发人员可以使用更符合大众语言的习惯来书写测试,在项目交接或者修改的时候更加顺利。

详细用法可参考:https://github.com/kiwi-bdd/Kiwi/wiki

测试文件

一个测试文件所包含一组对于类行为的描述,习惯上使用需要测试的目标类来作为名字,并以Spec作为文件名后缀,例如:XXViewModelSpec.m为测试XXViewModel类的测试文件。

测试文件的创建:

  1. 安装Xcode Kiwi模板:下载模板文件Xcode Templates,运行install-templates.sh安装;
  2. 按路径Xcode-New-File-Kiwi Spec创建
  3. 编辑好测试代码后,Cmd+U快捷键可运行单元测试,左侧导航栏的Test navigator会展示测试结果

Kiwi文件结构和简单用法

// XXViewModelSpec.m文件内容

SPEC_BEGIN(XXViewModelSpec) //括号内为测试文件名

// describe即Give,第一个参数为要测试的类名
// 1个测试文件建议只有一个describe
describe(@"XXViewModel", ^{
        // context即When,第一个参数描述测试的条件,block内为测试的具体内容
        // 1个describe内可包含多个context
    context(@"when created", ^{
        __block XXViewModel *viewModel = nil;
        
        // 每个测试用例均在执行前均会执行beforeEachblock内的代码
        beforeEach(^{
            viewModel = [[XXViewModel alloc] init];
        });
        
        // 每个测试用例均在执行后均会执行afterEach内的代码
        afterEach(^{
            viewModel = nil;
        });
        
        // it的block内容即为测试用例
        // it即Then,第一个参数描述测试的期望结果,在block内对代码执行情况进行验证
        it(@"should not be nil", ^{
                // shouldNot、beNil均为kiwi的方法宏,
                // 测试期望的检测一般采用:[[testObje should] doSomething] 或[[testObje shouldNot] doSomething] 的形式,
                // should和doSomething部分的可填内容参考:https://github.com/kiwi-bdd/Kiwi/wiki/Expectations
            [[viewModel shouldNot] beNil];
        });
        
        // 1个context内可包含多个it,即多条测试用例
        it(@"should have 2 sections", ^{
                    // theValue时kiwi的语法糖,负责将简单数据类型转换为NSNumber
            [[theValue([viewModel numberOfSections]) should] equal:@(2)];
        });        
    });
};
    
SPEC_END

BDD测试格式:

BDD测试用例的描述一般采用:Given..When..Then格式。

依次表示:
Give:测试用例的测试说明。
When:具体测试条件。
Then:期望的测试结果。

比如,上述单元测试用例,可以用自然语言描述为:

Given a XXViewModel, when created, it should not be nil.

Given a XXViewModel, when created, it should have 2 sections

这样测试用例表意明确,具有了文档性,出错时方便定位修改。

相关概念和用法:

Stub:

简单的方法替换,stub一个对象的指定方法后,可以该方法返回任何事先规定好的值。
用处:对创建麻烦、需要大量配置才能使用、方法的返回结果不固定的类或对象,直接stub指定方法,让方法返回固定值,以使测试继续下去。

    context(@"when select count of termStudyInfo", ^{
        
        beforeEach(^{
        // stub之后任何地方调用[EduTermStudyInfo countOfTermStudyInfoWithLearnerId:],返回值均为3,
        // 而真正的[EduTermStudyInfo countOfTermStudyInfoWithLearnerId:]方法将不会得到调用
           [EduTermStudyInfo stub:@selector(countOfTermStudyInfoWithLearnerId:) andReturn:theValue(3)];
        });
        
        it(@"the count should always be 3", ^{
            [[theValue([EduTermStudyInfo countOfTermStudyInfoWithLearnerId:110]) should] equal:theValue(3)];
        });
    });

注意:每条测试用例(it)的结尾,stub都会被清空。

Mock:

对创建新的对象代价较高或流程复杂的类,直接调用mock方法可创建一个模拟的该类对象(mock对象),这个对象可调用该类的任意方法。

// 调用nullMock创建了一个虚拟的XXSectionDataSource对象,可接收XXSectionDataSource类的任何消息,但不做实际的响应
XXSectionDataSource *mockLiveSection = [XXSectionDataSource nullMock];
// 想要让[mockLiveSection viewType]总是返回XXViewTypeLive,则需要stub该mock对象的viewType方法
    [mockLiveSection stub:@selector(viewType) andReturn:theValue(XXViewTypeLive)];
    
    // 如果不使用nullMock而是使用了mock方法来创建mock对象,则向该mock对象发送没有stub过的消息时,会crash
    XXCellDataSource *mockCell = [XXCellDataSource nullMock];
    [mockCell stub:@selector(viewType) andReturn:theValue(XXViewTypeLive)];
    [mockLiveSection stub:@selector(cellDataSources) andReturn:@[mockCell, mockCell]];

异步测试:

对于网络请求等异步操作进行测试时,判断测试结果是否满足期望,需要使用expectFutureValue+shouldEventually 替换同步测试的should。
expectFutureValue+shouldEventually出现时,会block该线程最长1s的时间,直到期望结果得到满足表示测试通过,或者超时表示测试失败。
具体原理可参考:https://github.com/kiwi-bdd/Kiwi/issues/532

describe(@"XXViewModel", ^{

    context(@"when request lern data", ^{
        __block XXViewModel *viewModel = nil;
        __block BOOL isExecuteSuccess = NO;
        
        beforeEach(^{
            viewModel = [[XXViewModel alloc] init];
            [viewModel requestLearnData:^{
                isExecuteSuccess = YES;
            } failure:nil target:self];
        });

        it(@"should eventually call successBlock", ^{
            //此处为异步测试判断网络请求是否成功
            [[expectFutureValue(theValue(isExecuteSuccess)) shouldEventually] beYes];
        });
});

OHHTTPStubs框架

基于NSURLProtocol实现的网络情况stub框架。stub指定的网络请求后,可自定义网络请求返回的内容,相当于Charles的local map功能。
作用:固定网络请求返回内容,方便依据请求返回做相应功能的测试。具体后端返回的正确性测试应该交由专门的API测试进行。

用法:一般在beforeEach内进行如下stub:

// 所有url中包含learn/v1的网球请求,均返回learn_v1.json文件中的内容
[OHHTTPStubs stubRequestsPassingTest:^BOOL(NSURLRequest * _Nonnull request) {
        return [request.URL.absoluteString containsString:@"learn/v1"];
    } withStubResponse:^OHHTTPStubsResponse * _Nonnull(NSURLRequest * _Nonnull request) {
        NSString *filePath = OHPathForFile(@"learn_v1.json", self.class);
        NSData *data = [NSData dataWithContentsOfFile:filePath];
        return [OHHTTPStubsResponse responseWithData:data statusCode:kK12TestHTTPSuccessCode headers:nil];
        // 模拟请求失败,返回一个NSError
        //return [OHHTTPStubsResponse responseWithError:[NSError errorWithDomain:@"kK12TestSystemErrorDomain" code:-1 userInfo:nil]];
    }];

问题记录:

  • 测试启动时Kiwi库报EXC_BAD_ACCESS。解决:修改Kiwi为master最新版本:https://github.com/kiwi-bdd/Kiwi/pull/649

  • 测试启动时宏或第三方库头文件报错。解决:pch文件没有链接

  • 在RACBacktrace的RACBacktraceBlock,debug时经常EXC_BAD_ACCESS。解决:升级ReactiveCocoa版本。

参考文章

参考文档:

《Kiwi的wiki文档》https://github.com/kiwi-bdd/Kiwi/wiki/Specs
《TDD的iOS开发初步以及Kiwi使用入门》https://onevcat.com/2014/02/ios-test-with-kiwi/
《Kiwi 使用进阶 Mock, Stub, 参数捕获和异步测试》https://onevcat.com/2014/05/kiwi-mock-stub-test/
《Difference between ExpectFutureValue and ShouldEventually》https://github.com/kiwi-bdd/Kiwi/issues/532
《MagicalRecord Kiwi测试Demo》https://gist.github.com/timd/4953078
《UNIT TESTING WITH CORE DATA》http://www.cimgf.com/2012/05/15/unit-testing-with-core-data/
《stub、mock、receive的Demo》https://gist.github.com/sgleadow/4029858
《OHHTTPStubs介绍》http://andrew-anlu.github.io/blog/2016/04/22/ohhttpstubsjie-shao/