libfeihu Blog

iOS中如何对具有复杂依赖的SDK在真机上进行单元测试

iOS中如何对具有复杂依赖的SDK在真机上进行单元测试

单元测试在软件开发中一直有着极其重要的地位,iOS的开发也不例外。随着App规模的不断膨胀,开发也逐渐的趋向模块化,开发者常常以库的形式封装功能,最后组成App。此时由于App结构变得复杂,各种库又可能存在着相互依赖的缘故,单元测试也随之变得复杂起来。开发者可能面临着一系列问题,比如:单元测试如何处理这些依赖?如何在真机上运行测试?如何在App所在的环境中运行测试?本文将用一个模拟的开发环境逐一进行讨论。

目录


在刚刚接触软件开发时,从未想过要写单元测试,总觉得自己写的代码质量很高,根本不需要测试。需要将宝贵的时间放到开发上,测试是测试人员的事情。后面才发现,经常因为一个小需求的增加,动了一处代码,结果其它地方出现重大问题,没测试到就上线了。甚至到了后面,代码复杂度越来越高,每动一处代码都提心吊胆,生怕有其它情形未考虑到,如履薄冰。经历了很多次惨痛教训之后才醒悟过来,单元测试是保证代码质量的不二法则。在《代码重构》一书中,每进行一步重构,作者都会先运行一遍单元测试,然后再进行后面的重构,因为只有这样,才能够保证重构之后代码的正确性,如果连正确都无法保证,重构有何意义?

Apple从Xcode 5开始,引入了最新的测试框架XCTest,非常完美的将测试与开发环境集成在了一起。关于如何使用XCTest,网上有非常多的介绍,大家可以看看Apple的官方文档,NSHipster也写过一篇文章:Unit Testing

随着开发者越来越重视单元测试,有人提出了TDD(测试驱动开发),并得到了很多开发者的推崇。这种思想会先根据需求或者接口来编写测试用例,然后才开始写业务代码,这样极大的保证了写出来的代码的正确性。关于在iOS上使用TDD,OneV写过一篇TDD的iOS开发初步以及Kiwi使用入门,有兴趣的可以去看看,这里不再展开介绍,本文集中讨论下面特定场景中的单元测试。

问题

iOS开发现在多数都使用CocoaPods进行第三方库的依赖管理,这样开发者们可以集中注意力放在自己模块的开发上面。比如著名的网络库AFNetworking。它的开源代码中也包含了单元测试,写得非常好,可以作为范例去学习。

但是由于AFNetworking本身的特点,决定了其单元测试环境其实是比较简单的,比如:

然而,很多时候,我们的开发环境比AFNetworking复杂得多,比如:

这些问题AFNetworking的测试用例都没有,而且默认创建的测试target都无法运行在这些环境中。如何利用XCTest来对以上复杂情形下的SDK进行单元测试?我们从模拟以上开发环境开始。

搭建SDK开发环境

首先我们来搭建一个满足以上复杂条件但却典型的开发环境:创建三个工程,其中ECApp是最终应用,它依赖了我们正在开发的ECSDK,而后者又依赖了第三方库EC3rdFramework。整个目录结构为:

.
├── EC3rdFramework
│   ├── EC3rdFramework.podspec
│   ├── EC3rdFramework.xcodeproj
│   ├── Podfile
│   ├── Sources
│   │   ├── ECFoo.h
│   │   └── ECFoo.m
│   └── SupportingFiles
├── ECApp
│   ├── ECApp
│   │   ├── AppDelegate.h
│   │   ├── AppDelegate.m
│   │   ├── Assets.xcassets
│   │   │   └── AppIcon.appiconset
│   │   │       └── Contents.json
│   │   ├── Base.lproj
│   │   │   ├── LaunchScreen.storyboard
│   │   │   └── Main.storyboard
│   │   ├── Info.plist
│   │   ├── ViewController.h
│   │   ├── ViewController.m
│   │   └── main.m
│   ├── ECApp.xcodeproj
│   └── Podfile
└── ECSDK
    ├── ECSDK.podspec
    ├── ECSDK.xcodeproj
    ├── Podfile
    ├── Sources
    │   ├── ECUsingFoo.h
    │   └── ECUsingFoo.m
    └── SupportingFiles

第三方库:EC3rdFramework

EC3rdFramework是我们开发的ECSDK所依赖的第三方库,其中包含一个ECFoo类,含有三个方法,分别模拟三种场景:

// ECFoo.m
// 模拟不依赖任何环境
- (BOOL)methodDependsOnNothing {
    return YES;
}

// 模拟依赖应用的环境
- (BOOL)methodDependsOnAppEnv {
    NSNumber *appInitialized = [[NSUserDefaults standardUserDefaults] objectForKey:@"AppInitialized"];
    if (appInitialized) {
        NSLog(@"running in app env");
        return YES;
    } else {
        NSLog(@"NOT running in app env");
        return NO;
    }
}

// 模拟依赖真实设备
- (BOOL)methodMustBeRunningOnDevice {
#if TARGET_IPHONE_SIMULATOR
    NSLog(@"running on simulator");
    return NO;
#else
    NSLog(@"running on device");
    return YES;
#endif
}

三个方法非常简单的模拟了三种典型的场景,满足条件时才会返回YES,代码很简单。对于依赖应用环境的场景,是通过App设置的一个标志位来判断,后面ECApp部分会看到这个标志位的设置。

其podspec如下:

Pod::Spec.new do |s|
  s.name                = "EC3rdFramework"
  s.version             = "1.0.0"
  s.requires_arc        = true
  s.source_files        = [ '**/Sources/**/*.h', '**/Sources/**/*.m']
  s.ios.deployment_target = '7.0'
end

开发中的SDK:ECSDK

ECSDK为我们所开发的SDK,它同EC3rdFramework一样,也是一个静态库,包含ECUsingFoo类,与前面的ECFoo类包含相同的方法,每个方法直接调用ECFoo中对应的方法,这样做是为了模拟依赖第三方库的场景

// ECUsingFoo.m
#import <EC3rdFramework/ECFoo.h>
// ...
- (BOOL)methodDependsOnNothing {
    ECFoo *foo = [ECFoo new];
    return [foo methodDependsOnNothing];
}

- (BOOL)methodDependsOnAppEnv {
    ECFoo *foo = [ECFoo new];
    return [foo methodDependsOnAppEnv];
}

- (BOOL)methodMustBeRunningOnDevice {
    ECFoo *foo = [ECFoo new];
    return [foo methodMustBeRunningOnDevice];
}

同前面类似,它的podspec如下,区别在于它多了对第三方库的依赖:

Pod::Spec.new do |s|
  s.name                = "ECSDK"
  s.version             = "1.0.0"
  s.requires_arc        = true
  s.source_files        = [ '**/Sources/**/*.h', '**/Sources/**/*.m']
  s.dependency          'EC3rdFramework'
  s.ios.deployment_target = '7.0'
end

也是因为这个依赖,还需要一个Podfile:

target "ECSDK" do
  pod 'EC3rdFramework', :path => '../EC3rdFramework'
end

最终应用:ECApp

ECApp为使用ECSDK的App,它启动之后立刻调用ECSDK中暴露的接口:

// AppDelegate.m
#import <ECSDK/ECUsingFoo.h>
// ...
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // Override point for customization after application launch.

    [[NSUserDefaults standardUserDefaults] setObject:[NSNumber numberWithBool:YES] forKey:@"AppInitialized"];
    [[NSUserDefaults standardUserDefaults] synchronize];

    ECUsingFoo *foo = [ECUsingFoo new];
    [foo methodDependsOnNothing];
    [foo methodDependsOnAppEnv];
    [foo methodMustBeRunningOnDevice];
    return YES;
}

方法的前两行,先设置了App环境的标识,前面ECFoo中便是依赖于此标识来判断是否处于App的环境中。

它的Podfile也很简单:

target "ECApp" do
  pod 'EC3rdFramework', :path => '../EC3rdFramework'
  pod 'ECSDK', :path => '../ECSDK'
end

这里有一点需要注意的是,实际上ECApp不会直接去依赖EC3rdFramework,它是被ECSDK依赖,按理说不需要加到Podfile中,CocoaPods会帮我们处理这种依赖。但由于EC3rdFramework并非已经发布的第三方库,如果不加上这一句的话,在pod install时会出现下面的错误:

[!] Unable to find a specification for EC3rdFramework depended upon by ECSDK

CocoaPods会去已经发布的库中去寻找,而不是本地。同时由于CocoaPods也不支持在podspec中像Podfile中一样,通过:path => ../EC3rdFramework指定本地路径,StackOverflow这里有讨论,所以采用这种变通方法。但这并不影响我们演示。

在ECApp路径下执行pod install之后,然后编译运行,将会得到以下日志:

2016-02-16 21:27:32.032 ECApp[30005:1064278] running in app env
2016-02-16 21:27:32.032 ECApp[30005:1064278] running on simulator

表示库已经正常调用,运行于App环境中的模拟器上。我们需要进行单元测试的开发环境搭建完成。

添加单元测试

环境搭好之后,接下来,为ECSDK添加单元测试。由于Xcode集成了XCTest,所以添加单元测试非常简单,依次选择菜单项:New/Target/iOS/Test/iOS Unit Testing Bundle,这里我们的测试target为ECSDKTests。完成后,在ECSDK工程中会生成对应的target和源文件,可以看到工程中有一个ECSDKTests.m文件,这是Xcode默认生成的测试用例,是来打酱油的,什么事都没做。选中ECSDKTests这个Scheme,按下⌘ U(注意,这里是U,而不是平时所用的B和R)编译并运行测试,因为此时是默认的空测试用例,所以测试很顺利的完成:

测试通过

编写测试用例

为了测试ECSDK中提供的方法,我们需要为其添加新的测试用例。三种场景,只有返回YES时才算通过测试,由此表示测试可以运行于这些环境中:

// ECSDKTests.m
#import "ECUsingFoo.h"
// ...
- (void)testMethodDependsOnNothing {
    ECUsingFoo *foo = [ECUsingFoo new];
    XCTAssert([foo methodDependsOnNothing], @"The method must be running in ANY env");
}

- (void)testMethodDependsOnAppEnv {
    ECUsingFoo *foo = [ECUsingFoo new];
    XCTAssert([foo methodDependsOnAppEnv], @"The method must be running in app env");
}

- (void)testMethodMustBeRunningOnDevice {
    ECUsingFoo *foo = [ECUsingFoo new];
    XCTAssert([foo methodMustBeRunningOnDevice], @"The method must be running on device");
}

测试用例很简单,我们来看看是否可以运行。再次选中ECSDKTests这个Scheme,⌘ U编译运行,此时出现以下错误:

Undefined symbols for architecture x86_64:
  "_OBJC_CLASS_$_ECUsingFoo", referenced from:
      objc-class-ref in ECSDKTests.o
ld: symbol(s) not found for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

错误信息显示链接时找不到ECUsingFoo方法,后者是定义在ECSDK工程中,表示测试target需要依赖ECSDK。处理依赖有多种方法:可以在Build Settings中添加库及其对应路径。还有一种更好的办法,利用CocoaPods,在它的Podfile中增加一个target即可,这样可以保证ECSDKTests和ECSDK的依赖完全一致。新的Podfile像这样:

def import_common_pods
    pod 'EC3rdFramework', :path => '../EC3rdFramework'
end

target "ECSDK", :exclusive => true do
    import_common_pods
end

target "ECSDKTests", :exclusive => true do
    import_common_pods
    pod 'ECSDK', :path => '.'
end

因为依赖了共同的库,所以将这抽出来成为一个单独的方法,接着在两个target中调用。由于测试target是依赖于ECSDK,所以还需要加上:pod 'ECSDK', :path => '.'。重新pod install⌘ U,编译问题解决,测试可以正常运行。

两个用例失败

但现在面临两个新的问题,因为现在测试只能运行于模拟器上,而且并非是App的环境,所以后面两个测试无法通过。

如果我们直接将Scheme选成真机上运行,一按⌘ U便会弹出以下错误提示:

> Logic Testing on iOS devices is not supported. You can run logic tests on the Simulator.

暂时无法运行于真机上。

让测试运行在App环境

我们先来看如何让测试运行于App环境中。Apple在开发文档中提过两个概念,一个叫Logic Tests,另外一个叫Application Tests,前者表示简单的逻辑测试,只能够运行在模拟器中,我们刚创建的测试target正是前面一种。这也是为何选择真机时,会弹出上面错误提示的原因。

文档中提到了如何配置Application Tests的方法,但是很遗憾,因为这篇文档是针对旧的OCTest框架,现在Xcode采用了新的XCTest框架,所以已经是”Retired”状态:

Retired Document

Important: This version of Unit Testing Guide has been retired. The replacement document focuses on the new testing features and workflow provided by Xcode 5 and later revisions. For information covering the same subject area as this page, please see Testing with Xcode.

新的文档中也没有再提这两个概念。但由于XCTest的前身就是OCTest,是否配置的方法也是相通的?是否将测试target变成Application Tests之后,就可以运行在App环境中?抱着试一试的想法,按照废弃文档中的方法来配置测试target。

在General配置页面,里面有一个Host Application,这个便表示测试是否可以运行于App中。但由于当前测试的是一个静态库,无法选择想要运行的App,此时需要用通过其它途径来指定。在ECSDKTests的Build Settings中修改两处:

  1. Bundle Loader: Your/App/Path/ECApp.app/ECApp
  2. Test host: $(BUNDLE_LOADER)

再次运行,发现ECApp的应用先启动,随后测试用例开始执行。因为ECApp在启动之后便配置了App环境的标志位,所以环境依赖的测试用例可以正常通过,测试已经可以运行于App的环境中,我们的尝试成功了。现在只剩下最后一个场景,如何让测试运行于真机上:

App环境测试成功

让测试运行在真机上

其实,在完成上一步的配置之后,测试已经从所谓的Logic Tests就转变成了Application Tests,而后者对运行的环境是没有限制的。直接将Scheme设置成真机,先编译一下ECApp,再运行一次测试,所有的测试可以通过:

设备测试成功

特殊情况

注意:事情并不会总是这么顺利,有时候由于一个App过于庞大,各个库的podspec写得不是很规范,不是所有依赖的Libraries都写在了podspec中,有些被放在Build Phases里面,系统库尤为常见。这样就导致即使我们按照前面介绍的都配置好了,还是无法让测试target编译通过,在链接时会出现各种各样的找不到符号的错误。此时需要手动去添加这些库到测试target的Build Phases中。至于需要添加哪些,只有根据编译时的错误逐一添加了。而且有一点需要注意:有时库的Status需要是Optional,否则最后链接的时候也会出错。下面是一个真实测试用例在Build Phases中所依赖的库:

系统库

它一共依赖了41个系统库,每一个都是在编译出错时,查到缺少的符号所在的库来添加的,是个体力活:-)。

结尾

至此,我们的测试用例已经可以运行于上面描述的几种典型的复杂环境,其实最重要的步骤只有两步,第一步是设置依赖,处理各种编译错误;第二步是设置Build Settings,将测试转成Application Tests,让测试能够运行于App环境。

我们搭建的环境和真实的环境相比起来,复杂度还存在一定的差距,在编译测试target时会出现各种各样奇怪的问题,本文无法一一例举,靠大家根据实际情况处理了。

如果想对Xcode的测试有一个系统的了解,强烈建议大家去阅读文档Testing with Xcode,非常详细的介绍了用Xcode进行测试的方方面面。

新的一年,以这篇简单的文章作为起始,祝大家新年快乐!

(全文完)

feihu
2016.02.17 于 Shenzhen