4

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

 3 years ago
source link: https://feihu.me/blog/2016/unittest-for-framework-in-big-project/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

17 Feb 2016 • 7 min. read • 0 Comments

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算是一个独立的库,并没有依赖其它的第三方库
  • 不依赖复杂的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

开发中的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进行测试的方方面面。

另外,推荐一个全面讲解单元测试的网站——GURU,里面提供了免费的教程,除了单元测试以外,还有很多其它的内容。

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

(全文完)

feihu
2016.02.17 于 Shenzhen



About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK