iOS中如何對具有復雜依賴的SDK在真機上進行單元測試
來自: 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中修改兩處:
- Bundle Loader: Your/App/Path/ECApp.app/ECApp
- 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>