单元测试

单测的意义

单测最终的目标是为了提前发现问题,提高代码质量。说到代码质量,衡量代码质量的主要方式如下图:

代码质量.jpg

可维护性: 代表的是代码自身在持续修改(比如修复bug或者需求变动)过程中,需要的成本。代码改动需要考虑可能的引发的其他各种问题的时候,容易引入新的bug,说明维护性比较差。

可扩展性: 一般来说,为了实现一个新的需求花费的成本。扩展性更高的代码应该是对老的代码改动很小,基本上通过重写一个函数或者实现一个接口来完成新功能的开发。如果开发一个新功能需要大改之前的代码甚至是重新实现一下之前的功能,说明之前的接口设计在可扩展性方面做得不是很好。

可读性: 代码的自我描述性是否高。通常如果是一段可读性高的代码,通过良好的逻辑封装和函数命名能够很快的让人明白它的业务逻辑,这样的就是可读性高的代码。往往大部分情况下,可读性高的代码注释的量也会相对较少一些。

可复用: 同样的逻辑是否重复出现在代码中。《重构》这本书中的定义差不多是同样的逻辑出现了3次及以上代表了需要通过类或者函数来进行封装,减少复制粘贴的情况发生。

可测试性: 我的理解就是能不能很方便的写出单元测试用例。实际上它代表的是代码结构设计的是否合理,比如一个函数封装的逻辑是否完全独立。设计不合理的代码是不好写单测的,下面将说举例说明。可测试性往往也是容易被忽略的一点。

单测的意义: 代码结构的优化

通常遇到的情况是:

现状.jpg

在一个viewController中会有各种view以及view对应的数据逻辑,通常是在一个函数中会有数据逻辑以及之后对view的刷新或者其他view逻辑的处理。往往这种情况如果是对这样的函数写单测,里面必然要把数据和view的逻辑一起验证,但是单测验证view的展示是否正确是比较困难的,特别是view层级结构复杂的情况下。遇到这种情形,首先要做的应该是分离data的逻辑,单独对data部分进行单测。只是为了说明问题,代码示例都比较简单。

下面的代码就是view和data逻辑封装到了一个函数中

- (void)fetchData
{
    [[DataManager manager] fetchData:^(id result) {
        [weakSelf hideLoading:YES];
        if(result){
            [weakSelf.data doSomething];
            [weakSelf.view doSomething];
         }
     } fail:^{

     }];
}

现在先将data部分逻辑封装起来,放到dataController中:

(void)fetch:(SuccessBlock)success fail:(FailBlock)fail
{
    [[DataManager manager] fetch:^(id result)
 {
        if(result){
            [weakSelf.data doSomething];
        }
        success(result);
    } fail:^{
        fail();
    }];
}

再让viewController去引用dataController:

 - (void)fetchGroupList
 {
    [self.dataController fetch:^(id result) 
        {
        if (result) {
        [weakSelf.view doSomething];
         }
     } fail:^{
     }];
 }

现在可以单独对dataController进行单元测试了,对于viewController的部分可以通过UI Test进行测试,相当于单测保证每个数据部分的函数测试,UI测试相当于流程测试,两者进行结合。

独立测试.jpg

所以,对于写单元测试来说,并不是为了写单元测试而写单元测试。首先对于一些可测试性差的代码先需要做的是重构,保证它有一个相对合理的可进行单元测试的结构,然后再进行单元测试的编写。

多层结构.jpg

刚才举的例子相对简单,对于一些复杂的模块或者业务,设计通常会分层或者分子模块。除了UI层,其他模块和层应该通过单元测试进行测试。这样做的好处是当测出来问题的时候,可以很快定位问题是在哪一部分,方便查找问题和分析问题。
所以整体来说,单测是可以促进代码结构的优化,好的代码结构也更易于写出单元测试,更容易进一步发现问题,帮助提高代码质量。

单元测试的意义: 函数的健壮性

1.参数问题

eg1:

 - (void)doSomething:(NSArray *)array {

        //逻辑1......
        NSMutableDictionary *dict = [NSMutableDictionary dictionary];
        [dict setObject:item forKey:@"name"];
        //逻辑2......
  }

eg2:

 - (void)doSomething2:(Person *)person {

        //逻辑1......
        NSMutableDictionary *dict = [NSMutableDictionary dictionary];
        [dict setObject:person.name forKey:@"name"];
        //逻辑2......
  }

上面两个例子说明的都是在某些情况下,因传入的参数导致崩溃的情况。在定义一个函数的时候,应该在理论上保证各种情况不出问题,可以兜底实现。不能依赖或者其期待总是传入正确的参数,比如这个函数未来可能会有其他人调用,但是此人不可能去详细了解这个函数内部实现,就有可能传入不合理参数。单元测试把点聚焦于某个函数,非常适合测试这种场景。

2.时序问题

 - (void)doSomething3 {

        //逻辑1......
        NSMutableArray *array = [NSMutableArray array];
        [array addObject:self.person];
        //逻辑2......
  }

person字段是对象的自己身字段,它的初始化并不在这个函数中。可能是在某个异步操作之后,当函数A被调用的时候,无法确定person被初始化了,如果person为空就会发生崩溃了,即便是现在没问题,不保证之后代码修改了person的初始化还在A函数之前发生。所以单元测试应该保证在A任何时候没问题,即便是发生时序的问题,可以通过断言或者日志给予开发人员提示。

意义.jpg

总的来说,编写单元测试除了上面说的代码结构的优化和函数健壮性测试的好处之外,在每次改动代码之后都可以通过单元测试来保证你的改动没有引入新的问题。额外的成本可能是如果有接口或者需求改动,你需要花一定的时间维护单元测试,对于相对稳定的项目来说,总体上利大于弊,因为需求整体上区域稳定。

编写单元测试基本原则

1.在可能的情况下,尽量都加上单测,提高测试覆盖率。

具体指的是部分方法可能是非常简单地,但是不能保证以后它不会发生变化,现在不加上可能以后也会忘记,时刻和懒惰对抗。

2.要考虑各种测试路径和边缘case

 - (void)fillParmas:(NSDictionary *)params {
        NSArray *d = parmas[@"data"];
      Person *p = parmas[@"person"];
      if ([d isKindOfClass:[NSArray class]]){
          self.d = d;
            }
      if ([p isKindOfClass:[Person class]]){
          self.person = p;
            }
  }

这是一个通过传入的字典类型的参数来对对象自身属性进行初始化的函数

    - (void)test_fillParmas
    { 
        //参数不存在
        NSDictionary *params =@{};
        [self.obj fillParmas:params];
        XCTAssertTrue(self.obj.d == nil && self.obj.p == nil, @"");
        [self clear];
        //参数存在,数据类型不对
        Person *p = [Person new];
        params = @{@"data": @{}, @"person": p};
        [self.obj fillParmas:params];
        XCTAssertTrue(self.obj.d == nil && self.obj.p != nil, @"");
        [self clear];
        //参数存在,数据类型不对
        params = @{@"data": @{}, @"person": @[]};
        [self.obj fillParmas:params];
        XCTAssertTrue(self.obj.d == nil && self.obj.p == nil, @"");
        [self clear];
        //参数存在,数据类型不对
        params = @{@"data": @[], @"person": @[]};
        [self.obj fillParmas:params];
        XCTAssertTrue(self.obj.d != nil && self.obj.p == nil, @"");
        [self clear];
        //参数存在,数据类型正确
        params = @{@"data": @[], @"person": p};
        [self.obj fillParmas:params];
        XCTAssertTrue(self.obj.d != nil && self.obj.p != nil, @"");
        [self clear];
    }

在对应的测试用例中处理了参数是否存在以及参数类型是否正确的各种情况

3.如果不能很好地编写单元测试,可以适当从代码结构入手,考虑重构代码。

工具:XCTest

上下文

通常测试用例的运行需要一些依赖的环境,包括参数和其他各种数据:

上下文.jpg

比如部分接口是依赖用户登录之后才能请求,就会带有token。有些函数逻辑依赖服务端返回的数据,依赖网络环境。类的全局字段和方法参数容易理解,之前的例子也有说明。
业务数据指的是当前用例运行的一些需要用到的后端的一些数据,比如做饭需要菜,锅和碗,需要先准备。在测试做饭的函数之后,先要调用后端准备锅和碗的函数,所以测试用例并不是在任何情况下保证能通过的,因为他有依赖。
临时数据指的是用例运行中产生的临时数据或者脏数据,比如一个测试用例可能需要测试多种状态的情况,每种状态都会对判定的产生影响,所以每种状态结束需要重置部分数据。

单元测试定义和测试流程

测试流程.jpg

XCode自带的单元测试框架XCTest,所有的测试类都继承于XCTestCase。在测试类中的测试方法和普通方法的区别是测试方法都是以test开头,每个测试方法左侧带一个菱形按钮,点击可运行。

每个测试方法都是独立运行的,并且每次只运行一个测试方法,但是测试方法的运行顺序并不一定是按照方法在源文件中定义的顺序。每个测试方法运行前和结束后都会对应的去调用setup和tearDown方法,一般来说如果有需要可以做一些准备或者资源清理的逻辑。

断言

断言.jpg

我常用的大概就是以上6种,expression代表一个表达式,...代表的是测试用例结束之后展示的日志信息。

XCTFail: 用在当你觉得已经失败了,立刻判断为失败的结果的时候。
XCTAssertNil: 当expression为nil的时候通过
XCTAssertNotNil: 当expression为非nil的时候通过
XCTAssert和XCTAssertTrue: 当expression为true的时候通过
XCTAssertFalse: 当expression为false的时候通过
下面是代码的示例:

- (void)test_fillParams
{
    NSDictionary *params =@{};
    [self.obj fillParmas:params];
    if (self.obj.d != nil) {
        XCTFail(@"self.obj.d 不可能存在");
    }
}
- (void)test_fillParams
{
    NSDictionary *params =@{};
    [self.obj fillParmas:params];
    XCTAssertNil(self.obj.d, @"d 应该为 nil");
    XCTAssertNil(self.obj.p, @"p 应该为 nil");
    
    Person *p = [Person new];
    params = @{@"data": @{}, @"person": p};
    [self.obj fillParmas:params];
    XCTAssertNil(self.obj.d, @"d 应该为 nil");
    XCTAssertNotNil(self.obj.p, @"p 不应该为 nil");
    
//省略一部分代码…
   
    params = @{@"data": @[], @"person": p};
    [self.obj fillParmas:params];
    XCTAssertTrue(self.obj.d != nil && self.obj.p != nil, @"");
}

处理异步任务

异步.jpg

通常都会遇到测试一个异步任务,XCTest提供了XCTestExpectation对象,它有一个fullfill方法,可以在fullfill调用的时候触发XCTestCase的waitForExpectationsWithTimeout: handler:方法,通知异步任务已经完成。这里需要说明的是后者的调用除了fullfill触发还有超时的时候会触发。
比如:

- (void)test_A
{
    __block bool hasPass = NO;
    XCTestExpectation *expectation = [self expectationWithDescription:@"A"];
    
    [self.obj doSomething:@"123" success:^(id _Nonnull result) 
{
        hasPass = YES;
        [expectation fulfill];
    } fail:^{
        [expectation fulfill];
    }];
    
    [self waitForExpectationsWithTimeout:5 handler:^(NSError * _Nullable error) {
        if (!hasPass) {
            XCTFail(@"");
        } else {
            XCTAssertTrue(YES, @"");
        }
    }];
}

每次运行的测试范围

测试范围.jpg

每次可以只运行一个方法或者一个类中的所有用例,也可以是整个测试target中的所有测试用例

查看运行结果

单个方法.jpg
多个测试.jpg

对于一个测试用例来说,运行完成之后,左侧菱形按钮通过会变绿色,不通过会变红色
对于多个测试用例,可以在左侧导航栏最后一个tab下面展示最近测试过的情况,里面包含了通过和不通过等各种情况。

代码覆盖率

代码覆盖率.jpg

上图展示了当前测试下代码的测试覆盖率,要展示这个需要先设置一下Product->Edit Schemal -> Test -> code coverage
点击图中某个方法右侧的箭头,可以跳转到具体的类的某个方法,红色部分为当前测试未测试的部分:

代码覆盖率代码.jpg

实际上代码覆盖率很难到100%,因为每次测试都是在某种确定的状态下,函数内部的逻辑会有if else分支。必然会有部分情况不会被测试到,能想到的办法是每个函数多测几次针对不同的状态。

遇到的问题

在实施单测过程中也遇到了一些问题:

1.编译测试的Target,报了很多业务对象找不到的错误:
主工程pch头文件引用了很多业务类型,这些业务类型不能直接被测试target中引用到,办法是在测试target中新建了一个headers.h,再把主工程pch内容拷贝过去

2.第三方库头文件找不到:
通过pod引入三方库,pod install之后相应头文件会被加到测试target的search path中

3.报了undefine symbol:swift.string等等100多种错误:
新建一个Swift文件,会提示让你在测试target中添加project_Bridging-Header.h文件

4.如何测试私有方法:
在不破坏主工程类的封装的情况下,给每个对应的需要测试的类添加一个category头文件,在头文件里声明这些私有方法,让它编译通过。

总结

1.测试用例本身是需要通过后期暴露的问题不断优化的,并不是一劳永逸

2.从一些bug能够落实到具体的用例设计上,优化用例,避免以后出现相同的问题

3.从单测中发现的一些问题,比如Nil插入数组/字典的情况,加入编码规范强化防御式编程意识,从源头上避免问题发生

4.流程优化:对用例分级,一些必须通过的用例可以加入到每次CI打包跑一下,失败情况下分析日志来定位问题。平时开发也可以通过脚本本地运行,提高效率。

最后的问题

XCTest中的异步内部原理是如何实现的? 感兴趣的可以思考一下