iOS中如何對具有復雜依賴的SDK在真機上進行單元測試

MissPuff 8年前發布 | 30K 次閱讀 iOS開發 單元測試 移動開發

來自: 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

開發中的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的環境中,我們的嘗試成功了。現在只剩下最后一個場景,如何讓測試運行于真機上:

讓測試運行在真機上

其實,在完成上一步的配置之后,測試已經從所謂的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></code></div>

 本文由用戶 MissPuff 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!