本文投稿文章,作者:Sindri的小巢(簡書)
前言
維基百科對單元測試的定義如下:
在計算機編程中,單元測試(英語:Unit Testing)又稱為模塊測試, 是針對程序模塊(軟件設計的最小單位)來進行正確性檢驗的測試工作。程序單元是應用的最小可測試部件。
在過程化編程中,一個單元就是單個程序、函數、過程等;對於面向對象編程,最小單元就是方法,包括基類(超類)、抽象類、或者派生類(子類)中的方法。
根據不同場景,單元的定義也不一樣,通常我們將C語言的單個函數或者面向對象語言的單個類視作測試的單元。在使用單元測試的過程中,我們要知道這一點:
單元測試並不是為了證明代碼的正確性,它只是一種用來幫助我們發現錯誤的手段
單元測試不是萬能藥,它確實能幫助我們找到大部分代碼邏輯上的bug,同時,為了提高測試覆蓋率,這能逼迫我們對代碼不斷進行重構,提高代碼質量等。
內置單元測試框架
在Xcode4.x中集成了測試框架OCUnit,根據測試的目的大致可以將單元測試分為這三類:
性能測試:測試代碼執行花費的時間
邏輯測試:測試代碼執行結果是否符合預期
異步測試:測試多線程操作代碼
在我們新建項目的時候,已經默認選擇創建單元測試的框架,除了Unit Tests之外還有一個UI Tests是iOS9推出的新特性,針對UI界面的單元測試框架。在創建項目之後,會自動生成一個appName+Tests的文件夾目錄,下面存放著單元測試的文件
一個標准的測試類文件代碼如下。其中setUp會在每一個測試用例開始前調用,用來初始化相關數據;tearDown在測試用例完成後調用,可以用來釋放變量等結尾操作;testPerformanceExample中的會將方法中的block代碼耗費時長打印出來;最後的testExample用來執行我們需要的測試操作,正常情況下,我們不使用這個方法,而是創建名為test+測試目的的方法來完成我們需要的操作:
測試用例
在每個測試用例方法的左側有個菱形的標記,點擊這個標記可以單獨的運行這個測試方法。如果測試通過沒有發生任何斷言錯誤,那麼這個菱形就會變成綠色勾選狀態。使用快捷鍵command+U直接依次調用所有的單元測試。另外,可以在左側的文件欄中選中單元測試欄目,然後直觀的看到所有測試的結果。同樣的點擊右側菱形位置的按鈕可以運行單個測試方法或者文件:
單元測試總覽
另外,為了保證單元測試的正確性,我們應當保證測試用例中只存在一個類或者只發生一個類變量的屬性修改。下面是我們測試中常用的宏定義:
XCTAssertNotNil(a1, format…) 當a1不為nil時成立 XCTAssert(expression, format...) 當expression結果為YES成立 XCTAssertTrue(expression, format...) 當expression結果為YES成立; XCTAssertEqualObjects(a1, a2, format...) 判斷相等,當[a1 isEqualTo: a2]返回YES的時候成立 XCTAssertEqual(a1, a2, format...) 當a1==a2返回YES時成立 XCTAssertNotEqual(a1, a2, format...) 當a1!=a2返回YES時成立
邏輯測試
筆者新建了一個用以測試的model類,該類提供了三個接口。需要注意的是,在邏輯測試的某個操作步驟前後,應該有對應的數據發生了改變,這樣才能夠方便我們進行測試:
@interface LXDTestsModel : NSObject @property (nonatomic, readonly, copy) NSString * name; @property (nonatomic, readonly, strong) NSNumber * age; @property (nonatomic, readonly, assign) NSUInteger flags; + (instancetype)modelWithName: (NSString *)name age: (NSNumber *)age flags: (NSUInteger)flags; - (instancetype)initWithDictionary: (NSDictionary *)dict; - (NSDictionary *)modelToDictionary; @end
在測試用例中,我定義了一個testModelConvert方法用來測試模型跟json之間的轉換是否正確:
- (void)testModelConvert { NSString * json = @"{\"name\":\"SindriLin\",\"age\":22,\"flags\":987654321}"; NSMutableDictionary * dict = [[NSJSONSerialization JSONObjectWithData: [json dataUsingEncoding: NSUTF8StringEncoding] options: kNilOptions error: nil] mutableCopy]; LXDTestsModel * model = [[LXDTestsModel alloc] initWithDictionary: dict]; XCTAssertNotNil(model); XCTAssertTrue([model.name isEqualToString: @"SindriLin"]); XCTAssertTrue([model.age isEqual: @(22)]); XCTAssertEqual(model.flags, 987654321); XCTAssertTrue([model isKindOfClass: [LXDTestsModel class]]); model = [LXDTestsModel modelWithName: @"Tessie" age: dict[@"age"] flags: 562525]; XCTAssertNotNil(model); XCTAssertTrue([model.name isEqualToString: @"Tessie"]); XCTAssertTrue([model.age isEqual: dict[@"age"]]); XCTAssertEqual(model.flags, 562525); NSDictionary * modelJSON = [model modelToDictionary]; XCTAssertTrue([modelJSON isEqual: dict] == NO); dict[@"name"] = @"Tessie"; dict[@"flags"] = @(562525); XCTAssertTrue([modelJSON isEqual: dict]); }
邏輯測試的目的是為了檢測在代碼執行前後發生的變化是否符合預期,因此可以說80%左右的單元測試都是邏輯測試。最開始筆者學習單元測試的時候總有一種無從下手的感覺,但是當你從無形抽象的邏輯操作找到了數據變化的規律的時候,對應的單元測試就能很快的寫出來了。
性能測試
相較於上面的邏輯測試,性能測試的地位有些尴尬。在現今的開發環境下,我們已經能通過 instrument工具很好的查找到項目中的代碼耗時點,性能測試就有種棄之可惜,食之無味的感覺了。但是為了本文的完整性,還是將這個補充完畢。筆者在測試model類中添加了類方法,用來隨機生成100個類實例對象,並且在每次創建對象後讓線程休眠一段時間來模擬耗時操作:
+ (NSArray*)randomModels { NSMutableArray * models = @[].mutableCopy; NSArray * names = @[ @"SindriLin", @"Bison", @"XiongZengHui", @"ZengChengChun", @"Tessie" ]; NSArray * ages = @[ @15, @20, @25, @30, @35 ]; NSArray * flags = @[ @123, @456, @789, @012, @234 ]; for (NSUInteger idx = 0; idx < 100; idx++) { LXDTestsModel * model = [LXDTestsModel modelWithName: names[arc4random() % names.count] age: ages[arc4random() % ages.count] flags: [flags[arc4random() % flags.count] unsignedIntegerValue]]; [models addObject: model]; [NSThread sleepForTimeInterval: 0.01]; } return models; }
運行測試用法後控制台會輸出下面的信息,其中紅框中表示執行代碼總耗時,在此demo中總共運行了11.015秒的時長
性能測試輸出
雖然性能測試的定位確實有些雞肋,但是另一方面,直接使用單元測試來獲取某段代碼的執行時間要比使用instrument快的多。通過性能測試直觀的獲取執行時間後,我們可以根據需要來決定是否將這些代碼放到子線程中執行來優化代碼(很多時候,數據轉換會占用大量的CPU計算資源)
異步測試
由於單元測試是在主線程中進行的,因此異步操作的測試在執行完畢之前,往往已經結束了。為了實現異步測試,筆者采用while()的方式無限循環等待,為了實現這個效果,我在LXDTestsModel頭文件中添加了一個NSData類型的屬性以及一個異步操作的接口方法,通過判斷這個屬性值來實現效果:
- (void)asyncConvertToData { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSDictionary * modelJSON = nil; for (NSInteger idx = 0; idx < 20; idx++) { modelJSON = [self modelToDictionary]; [self setValuesWithDictionary: modelJSON]; [NSThread sleepForTimeInterval: 0.001]; } _data = [NSJSONSerialization dataWithJSONObject: modelJSON options: NSJSONWritingPrettyPrinted error: nil]; }); }
上面的代碼在系統創建的默認等級的子線程中執行了一段耗時代碼,最後把json轉換成NSData數據保存在自身的屬性中。對應的異步測試代碼如下:
- (void)testAsync { NSDictionary * dict = @{ @"name": @"SindriLin", @"age": @22, @"flags": @987654321 }; LXDTestsModel * model = [[LXDTestsModel alloc] initWithDictionary: dict]; XCTAssertNotNil(model); [model asyncConvertToData]; while (model.data == nil) { CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.01, YES); NSLog(@"waiting"); } XCTAssertNotNil(model.data); NSLog(@"convert finish %@", model.data); }
同樣的,如果你的異步操作是網絡請求,那麼在執行的回調外對獲取的數據類型加上__block修飾,然後判斷這個獲取的數據是否不為空來停止循環。另外最重要的是你必須在你的死循環中加入CFRunLoopRunInModel這個函數的調用來保證即便是在等待的情況下,你的主線程仍然能處理其他的事情。
__block BOOL complete = NO; __block NSData * data = nil; [network POST: @"http://xxxxxxx" parameters: nil completion: ^(NSData * receiveData) { data = receiveData; complete = YES: }]; while (!complete) { CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.01, YES); NSLog(@"requesting"); }
尾言
最開始筆者一度認為單元測試是個比較考驗技術的東西,但恰恰相反的,單元測試的使用與概念是相當簡單的一個東西,難點在於不知道怎麼用,這就需要我們持續的使用練習才能更好的服務於我們的開發。此外,常用的第三方框架例如YYModel、AFNetworking、Alamofire等等優秀框架中也有對框架自身編寫的單元測試,學習仿寫這些單元測試也是快速提升自己的一種手段。
很多時候,我們的項目中難免發生多個類之間的交互處理,而這種操作非常的不好調試。單元測試的原則之一就在於我們用來測試的代碼要求功能很單一,這其實與良好的代碼設計的思想是非常相符的。一方面來說,良好的代碼結構設計可以讓我們的測試用例的構建更加快速簡單;反過來單元測試逼著我們去想辦法減少類之間的耦合以此來減少甚至排除測試的干擾。無論如何,如果你想成為更好的開發者,單元測試是我們快速提升代碼認知的重要手段之一。