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

MissPuff 8年前

来自: http://feihu.me/blog/2016/unittest-for-framework-in-big-project/

17 Feb 2016 • 7 min. read •Comments

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

目录

  • 问题
  • 搭建SDK开发环境
    • 第三方库:EC3rdFramework
    • 开发中的SDK:ECSDK
    • 最终应用:ECApp
  • 添加单元测试
    • 编写测试用例
    • 让测试运行在App环境
    • 让测试运行在真机上
    • 特殊情况
  • 结尾

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

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

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

问题

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

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

  • AFNetworking算是一个独立的库,并没有依赖其它的第三方库
  • 不依赖复杂的App环境
  • 不依赖真机环境

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

  • 依赖其它的第三方库,如何处理这些依赖的问题?
  • 依赖的某些第三方库又必须运行在复杂的App环境中,如何让测试运行于App环境?
  • 某些方法必须在真机上才能运行,如何让测试运行于真机上?

这些问题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</pre>    </div>      

开发中的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];  }</pre>    </div>      

同前面类似,它的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</pre>    </div>      

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

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

最终应用: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;  }</pre>    </div>      

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

它的Podfile也很简单:

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

这里有一点需要注意的是,实际上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");  }</pre>    </div>      

测试用例很简单,我们来看看是否可以运行。再次选中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</pre>    </div>      

因为依赖了共同的库,所以将这抽出来成为一个单独的方法,接着在两个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的环境中,我们的尝试成功了。现在只剩下最后一个场景,如何让测试运行于真机上:

让测试运行在真机上

其实,在完成上一步的配置之后,测试已经从所谓的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进行测试的方方面面。

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

(全文完)

feihu2016.02.17 于 Shenzhen

</code></code></code></code></code></code></code></code></div>