iOS 中的单元测试

3,132 阅读11分钟

作者:bool周  原文链接:iOS 中的单元测试

最近团队内部为了保证代码质量,要求单元测试覆盖率 80%+。在编写单元测试过程中,等到了一些收获,为此总结一下。

概述

Unit test 是保证代码质量的重要模块,为模块编写 Unit test 可以减少开发中的 bug。同时在重构代码时,如果有一定粒度的 Unit test 覆盖,可以降低重构风险,这些大家都深有体会。

1.核心观点
  • 开发必须重视 unit test,不仅是点缀和补充,要和功能开发放到同样主要地位,加入工作量评估.
  • 加入 daily build,build break 要追责.
  • 对于某些大的代码改动,发 merge request 之前要跑 unit test.
  • 不要写完了功能再补充 unit test,尽可能做到 TDD.
2.覆盖粒度

原则上,unit test 要尽可能覆盖所有 case,粒度拆分越细越好。但是实际代码中,并不是所有 case 都需要覆盖,有的覆盖了没有意义;还有些 case 无法覆盖到,所以做到 100% 并不可能。unit test 诣在提高代码质量,减少失误,不要为了提高覆盖率而去强行覆盖某个无意义的 case。

Unit Test 基础知识

1.Get Start

简单来说,创建 unit test 的 target,在这个 target 中创建对应的 test 文件,然后运行,测试,check 结果就可以了。但是有一些问题需要注意一下:

  • 每个 unit test 类中都会有一个 -[setUp] 方法和一个 -[tearDown] 方法。可以将需要公共初始化操作放在 -[setUp] 中,将需要重置或者销毁的操作放在 -[tearDown] 中。每个 test 方法的执行,都会 new 一个新的实例,并调用这两个方法。如果一个类中有多个 test 方法,这两个方法会被调用多次。

  • 在执行 unit test 时,第一需要将 target 选为 unit test 那个 target;第二需要 run 选项选为 “Test”,如下图。不选择为 “Test” 也会运行,但有时候会出一些错误,运行结果不准。
    启动项

  • 如果某个方法没有运行的”小菱形”,如下图。检查一下你这文件是否添加到 test target 里面(读取本地文件时,读取不到可能也是如此)。
    小菱形

2. Test Assertions 概览

在一些 test 方法中,会使用一些 XCTest Framework 提供的 assert 进行判断,这些 assert 主要分为以下几类:

  • Boolean Assertions,主要用来判断结果是 true 还是 false。例如 XCTAssertTureXCTAssertFalse
  • Nil and Non-nil Assertions,判断结果是否为 nil。例如 XCTAssertNilXCTAssertNotNil
  • Equality and Inequality Assertions,判断两个类或者值是否相等。例如 XCTAssertEqualXCTAssertEqualObjectsXCTAssertEqualWithAccuracy
  • Comparable Value Assertions,主要用于大小比较(>,<,>=,<=)。例如 XCTAssertGreaterThanXCTAssertCreaterThanOrEqual
  • Error Assertions,主要用于异常测试,判断一个表达是否会抛出异常,以及异常具体信息。例如 XCTAssertThrowsXCTAssertThrowsSpecific
  • Failing Unconditionally,想主动触发一个失败,或者标记一个失败。例如 XCTAssertFail
  • Asynchronous Tests and Expectations,这不是 assert,这是测试时需要的一些 exception,异步,KVO,Notification 等。例如 XCTestExpectationXCTKVOExpectationXCTNSNotificationExpectation

前几个都比较熟悉,这里说一下最后一项,有几个 expectation 对于我们来说比较陌生,主要用于 UI Test。在 Asynchronous Tests and Expectations 里面主要有如下几个类:

  • XCTKVOException,当监听一个对象的属性变化(KVO)时使用。例如:

- (void)testMethod {
  UIView *view = [UIView new]; // 监听对象
  XCTKVOExpectation *kvoExceptation = [[XCTKVOExpectation alloc] initWithKeyPath:@"tag" object:view];
    XCTWaiterResult result = [XCTWaiter waitForExpectations:@[kvoExceptation] timeout:3];
    XCTAssertTrue(result == XCTWaiterResultCompleted);
}

  • XCTNSNotificationExpectation/XCTDarwinNSNotificationExpectation,测试发通知时使用(NSNotification 和 Darwin NSNotification)。例如:

- (void)testMethod {
  XCTNSNotificationExpectation *notificationExpectation = [[XCTNSNotificationExpectation alloc] initWithName:@"kNotificationName"];
    XCTWaiterResult result = [XCTWaiter waitForExpectations:@[notificationExpectation] timeout:3];
    XCTAssertTrue(result == XCTWaiterResultCompleted);
}

  • XCTNSPredicateExpectation,测试谓词表达式时使用,例如:

  NSNumber *str = @123;
  NSPredicate *predicate = [NSPredicate predicateWithFormat:@"SELF BETWEEN {100, 200}"];
  XCTNSPredicateExpectation *predicateExpectation2 = [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:str];
  XCTWaiterResult result = [XCTWaiter waitForExpectations:@[predicateExpectation2] timeout:3];
  XCTAssertTrue(result == XCTWaiterResultCompleted);

3.运行 unit test 快捷键

运行 unit test 试的方式有很多,可能直接点击 run 按钮运行;可以在 Test navigator 中选择运行全部 or 单个类 or 单个 case;也可以在源码中点击运行等。当然也可以通过快捷键,这里介绍一些快捷键:

  • run 所有 unit test: Command + U
  • 只 build unit test: Shift + Command + U
  • 只 run,不 build unit test: Control + Command + U
  • 只 run 一个 case (当前光标停留的这个 case): Control + Option + Command + U
4.断点调试

在 Breakpoint navigator 中添加一个 ‘Test Failure Breakpoint’ 断点,当出现失败时,就会出停下,方便调试。

test breakpoint

5.Code Coverage

通过 code coverage 可以查看每个模块 unit test 的覆盖率,甚至可以具体到每个类里面,每个 case 的覆盖率。可以在 scheme 菜单中开启 code coverage。


查看结果具体如下:


在代码中可以查看方法是否被覆盖到,如下图中,红色代表未被覆盖,绿色代表被覆盖,绿色中的数字代表在测试过程中这段代码被命中的次数。可以通过 Editor -> Hide/Show Code Coverage 打开和关闭覆盖信息。

hitCode

6.命令行运行 unit test

这目前没有什么实际用途,在这里只是简单提一下。通过以下命令格式可以直接运行 unit test:

xcodebuild test [-workspace <your_workspace_name>]

                [-project <your_project_name>]

                -scheme <your_scheme_name>

                -destination <destination-specifier>

                [-only-testing:<test-identifier>]

                [-skip-testing:<test-identifier>]

eg: xcodebuild test -workspace MTBusinesskitDev.xcworkspace -scheme MTBusinessKitTests -destination 'platform=iOS Simulator,name=iPhone 7'

想了解更多信息可以查看 How do I run unit tests from the command line?

Practics

1.测试路径
  • 既要考虑正确路径,还要考虑非正确路径,故意创建一些错误 case。例如在 MTBAdLoadInfoTest 中,故意创造了几组错误数据进行测试。

  • 测试多路径。很多的类中,可能有多条 case,需要覆盖完全。例如 MTBMeituBusinessAdRequest中,根据 load from cache or load from web,phase1 or phase2,缓存是否有效等情况,组合起来会有多种 case,组要考虑周全,完全覆盖到。

  • 考虑边界情况。例如在 MTBBatchReportDataManager 类中,测试“是否超过 15 天”(-[checkDateIsPast:] 方法) 时。出了需要测试未超过 15 天和超过 15 天的 case,还需要测试恰好 15 天的 case。
2.测试一些私有方法及使用到私有属性

测试过程中,可能需要调用或者测试一些私有方法,也有可能需要使用一些私有属性。这时可以新建一个 private category 文件,将一些私有方法和属性放到这个 category 中。然后将这个文件引入到 test case.m 中即可。

原则上这个 private category 文件只放在 test target 中,并且只被 test case.m 引入。

例如在测试 MTBBusinessAdPreload 类时,添加了 MTBBusinessAdPreload+Private.h 文件,内容如下:

@interface MTBPreloadModel ()
+ (instancetype)preloadModelWithInfo:(NSDictionary *)info parsingError:(NSString *__autoreleasing *)errorStr;
@end

@interface MTBBusinessAdPreload (Pirvate)

@property (nonatomic, strong) NSMutableArray <NSDictionary *> *resourceToDownloadDic;

// preload 相关方法
- (MTBPosition *)createPositionWithAdIndexInfo:(MTBAdIndexInfo *)adIndexInfo;
- (void)replaceRoundAndIdeaIDWithPreloadData:(MTBPreloadModel *)preloadModel;
- (NSDictionary <NSString *, NSArray *> *)replaceCreativesWithPreloadData:(MTBPreloadModel *)preloadModel;

// download 相关方法
- (void)downloadMaterials:(NSDictionary *)resourcesToDownload;
// cache 操作相关方法
- (void)cacheResourceToDownload:(NSDictionary *)dic;
- (NSMutableDictionary *)cachedResourceToDownload;
- (void)removeResourceFromCache:(NSString *)creativeId;
@end

3.异步方法测试

测试异步逻辑,系统提供了专门的 API。所有涉及 通知、观察者、listener 等回调机制的 API 都可以写 case,不同的平台各自有支持。例如在 MTBAnalyticsReportDataTest 中的异步测试:

- (void)testReportAdInfo {
    
...

  XCTestExpectation *exception1 = [self expectationWithDescription:@"Report Data"];
  [[MTBAnalyticsReportData shared] logEventWithReportInfo:allParams completion:^(NSError *error, BOOL success) {
      XCTAssertNil(error);
      XCTAssertTrue(success);
      [exception1 fulfill];
  }];
  
  XCTestExpectation *exception2 = [self expectationWithDescription:@"Report nil"];
  [[MTBAnalyticsWebService shared] reportAdInfo:nil completion:^(NSError *error, BOOL success) {
      XCTAssertTrue(error.code == 1010);
      XCTAssertFalse(success);
      [exception2 fulfill];
  }];
  
  [self waitForExpectationsWithTimeout:8.0 handler:nil];
}

有些异步测试,可能需要验证线程是否安全。例如:

  MTBReqeust *request = [MTBReqeust new];
  [request loadData:^(id data){
    XCTAssertTrue([NSThread mainThread]);
  }

除此以外, 不仅要写独立调用异步 API 的 case, 还可以考虑对同一个对象多次调用该 API 的并发逻辑.

因为对于异步API, 我们设计时总是隐含地给他制定了一个内在的重复调用时的回调逻辑, 一般对于异步 API , 我们在调用了该 API 方法后 callback 没回调的情况下, 又对同一个对象再次调用了该 API 时的相应逻辑(ie. 并发模型), 会是以下几类之一

  • API 每次调用, 总会在将来确定触发一个 callback, 多次调用间互不影响, 调用几次就会 callback 几次
  • 新的调用被忽略, 已存在的调用继续执行 (在前面的例子里, 第二次调用 request 方法会立刻同步返回false), 旧调用在将来某时刻触发 callback
  • 旧的调用立刻被自动 cancel/旧的调用会立刻触发 failure callback, 新的调用正常执行, 在将来某时刻触发 callback
  • 新旧两次调用合并成一次调用/旧的 callback 被新的 callback 接管, 旧的 callback 不再触发, 在新的调用完成时再进行1次回调
  • API 不允许第一个调用没 callback 前就触发新的调用, 如果出现这种情况立刻抛出异常

如果在写 case 之前从来没考虑过这个问题, 那么可能使用这个 API 时已经有隐藏的危险了
无论我们的 API 采用哪一种并发调用策略, 都可以编写对应的 case 来严格验证这个问题, 此处不赘述.

4.模拟操作

在写一些 case 时,有时候我们无法创造真实的场景,这时就需要进行模拟。

(1)模拟系统通知

视频在收到 home 出去时需要暂停. 我们有2种方式

  • 1是模拟系统发 notification. 这种方式简单, 但是可能会影响一些其他逻辑
  • 2重构视频类的接口, 把 func pause()方法扩展成 func pause(cause:) 其中 cause 参数表示了pause的原因, 例如包括”主动点击”, “页面消失”, “进如后台”等等, 然后把一部分原先在 pause外的逻辑移到方法里面来, 对传入的不同参数进行不同的处理. 这样在test case里只需要用不同的 cause 参数调用pause方法即可.

(2)模拟时间流逝

在开屏的一些逻辑中,home 出去回来,需要根据上次展示时间判断,是否有必要展示开屏;在批量上报逻辑中,测试数据是否过期时,需要创造一个过期时间。例如在 MTBSplashAdManagerTest 中,在现在基础上减去 200s 并传入,这样程序执行时拿到的时间就是一个 “过去” 时间:

- (void)testAppWillEnterForeground4 {
    [MTBSplashStatus setAppeared:NO];
    self.manager.splashShownCountInWarmStart = 0;
    self.manager.isIntervalLargerThanSetting = YES;
    self.manager.hasPendingDisplayTask = NO;
    // 倒退 200s,再次进入 APP 时距离上次超过 120s,展示开屏。
    NSTimeInterval interval = [[NSDate date] timeIntervalSince1970] - 200;
    self.manager.lastLeaveDate = [NSDate dateWithTimeIntervalSince1970:interval];
    [self.manager appWillEnterForeground];
    XCTAssertFalse(self.manager.isColdStart);
}

5.网络相关测试

原则上,本地单元测试不依赖网络情况。因为打包时跑单元测试,打包机网络可能会挂掉,或者服务器挂掉。这是单元测试跑不过,打包就会挂掉。因此需要针对一些网络请求进行模拟。

在测试一些接口解析时,需要一些 json 数据,平时这些数据是从服务端请求。而在测试过程中,可以直接读取本地数据。例如在测试 preload,load,setting 接口时,都是从本地读取数据。

除此之外,还可以继承并重写 MTBSimpleHttpFetcher 类中的一些方法,指定返回数据。本质上和从本地读取数据是一样的,例如:

@interface MTBSimpleHttpFetchreMock:MTBSimpleHttpFetcher

@end

@implementation MTBSimpleHttpFetchreMock

- (id<MTBCancellable>)loadResource:(MTBRemoteResource *)resource
                       reportQueue:(dispatch_queue_t)reportQueue
                        completion:(MTBRequestCompletion)completion {
                        ...
   dispatch_async(reportQueue, ^{
    id data = [NSObject new]; // 自定义 data,也可以自定义 error 等。
    completion(data,data,nil);
   }
                          
                        ...
}
@end

然后通过创建 MTBSimpleHttpFetchreMock 类,调用 -[loadResource:reprotQueue:completion:] 方法模拟网络拉取。

总结

1. unit test 需要与 APP 本身隔离,unit test 执行不应该影响到 APP 本身逻辑
  • iOS的unit test是否需要host到app这个问题, 尽量不要, 但目前因为bundle info取不到只能要host, 后面应该拆离。
  • test case执行的process和main app不是一个process, 但是对于某些系统提供的全局UI对象, 如UIApplication和视图层级, 却是统一的, 因此要避免test case对main app的UI进行操作. 如果执行case一定会操作到的, 需要考虑用其他方式屏蔽.
  • 通过在scheme中增加test时的环境变量, 可以判断某个方法被调用到时所在的环境是main app进程还是test case代码的进程.
  • 注重test case退出时的数据清理, 都在XCTestCase
    • func teardown()
    • static func teardown()
    • waitForExpectations(timeout:handler:)第二个参数
  • 涉及到复杂持久化存储的, 如果不太容易和main app数据分开来, 或者无法单独清理test数据, 那么应该在数据存储源头上就指定不同的路径, 通过上面提到的方式把这部分数据源抽象出来, 让test case可以重新指定另一个测试用的数据源。
2.unit test对开发的启示
  • 模块高内聚低耦合, 一个类干明确的1件事
  • 多用组合(相比之于继承)
  • 不要滥用单例, 滥用单例会导致很难对这个类单独编写test case