天天看點

iOS 單元測試之常用架構 OCMock 詳解

作者:京東雲開發者

一、單元測試

1.1 單元測試的必要性

測試驅動開發并不是一個很新鮮的概念了。在日常開發中,很多時候需要測試,但是這種輸出是必須在點選一系列按鈕之後才能在螢幕上顯示出來的東西。測試的時候,往往是用模拟器一次一次的從頭開始啟動 app,然後定位到自己所在子產品的程式,做一系列的點選操作,然後檢視結果是否符合自己預期。

這種行為無疑是對時間的巨大浪費。于是有很多資深工程師們發現,我們是可以在代碼中構造一個類似的場景,然後在代碼中調用我們之前想要檢查的代碼,并将運作結果和設想結果在程式中進行比較,如果一緻,則說明我們的代碼沒有問題,由此就産生了單元測試。

1.2 單元測試的目的

單元測試的主要目的是發現子產品内部邏輯、文法、算法和功能錯誤。

單元測試主要是基于白盒測試驗證以下問題:

  • 驗證代碼與設計相符度。
  • 發現設計和需求中存在錯誤。
  • 發現在編碼過程中引入的錯誤。

單元測試關注的重點有以下部分:

iOS 單元測試之常用架構 OCMock 詳解

獨立路徑-對于基本執行路徑和循環進行測試,可能的錯誤有:

  • 不同資料類型的比較。
  • “差1錯”,即可能多循環或少循環一次。
  • 錯誤或不可能的終止條件。
  • 不适當的修改了循環變量。

局部資料結構-單元的局部資料結構是最常見的錯誤來源,應設計測試用例以檢查可能的錯誤:

  • 不一緻的資料類型。
  • 檢查不正确或不一緻的資料類型。

錯誤處理-比較完善的單元設計要能預見出錯的條件,并設定适當的錯誤處理,以便在程式出錯時,能對錯誤重新做安排,保證期邏輯上的正确性:

  • 出錯的描述難以了解。
  • 顯示的錯誤與實際的錯誤不符。
  • 對錯誤條件的處理不正确。

邊界條件-邊界上出現錯誤是最常見的錯誤現象:

  • 取最大最小值發生錯誤。
  • 控制流中的大于、小于這些比較值常出現錯誤。

單元接口-接口實際上就是輸入和輸出對應關系的集合,要對單元進行動态測試無非就是給這個單元一個輸入,然後檢查輸出是否和預期一緻。如果資料不能正常輸入和輸出,單元測試就無從談起,是以需要對單元接口進行如下的測試:

  • 被測單元的輸入、輸出在個數、屬性、順序是否和詳細設計中的描述一緻。
  • 是否修改了隻做輸入用的形式參數。
  • 限制條件是否通過形式參數來傳送。

1.3 單元測試依賴的兩個主要架構

OCUnit(即用 XCTest 進行測試)其實就是蘋果自帶的測試架構,主要是斷言使用,由于使用簡單本次文章不過多介紹。

OCMock主要功能是模拟某個方法或者屬性的傳回值,你可能會疑惑為什麼要這樣做?使用模型生成的模型對象,再傳進去不就可以了?答案是可以的,但是有特殊的情況,比如一些不容易構造或不容易擷取的對象,此時你可以建立一個虛拟的對象來完成測試。實作思想是根據要mock的對象的class來建立一個對應的對象,并且設定好該對象的屬性和調用預定方法後的動作(例如傳回一個值,調用代碼塊,發送消息等等),然後将其記錄到一個數組中,接下來開發者主動調用該方法,最後做一個verify(驗證),進而判斷該方法是否被調用,或者調用過程中是否抛出異常等。在單元測試開發中使用更多難點的也是對OCMock的使用方式不明确,本次文章主要講的就是這個 OCMock 的內建和使用方法。

二、OCMock 的內建與使用

2.1 OCMock 的內建方式

項目內建 OCMock 第三方庫,這個使用 pod 工具直接安裝OCMock架構即可。若使用 iBiu 工具安裝 OCMock 庫需在 podfile 檔案同級建立 Podfile.custom。

iOS 單元測試之常用架構 OCMock 詳解

使用普通的 pod 檔案相同格式添加 OCmock 如下:

source 'https://github.com/CocoaPods/Specs.git'
pod 'OCMock'           

2.2 OCMock 的使用方法

(一)置換方法(存根):告訴 mock 對象,當 someMethod 被調用,傳回什麼值

調用方式:

d jalopy = [OCMock mockForClass[Car class]];
OCMStub([jalopy goFaster:[OCMArg any] units:@"kph"]).andReturn(@"75kph");           

使用場景:

1. 驗證 A 方法時,A 方法内部使用 B 方法的傳回值但是 B 方法内部邏輯比較複雜,這時需要使用 stub 方法去存根 B 方法的傳回值。代碼實作類似下面代碼實作固定 funcB 的傳回值,做到在不影響源代碼的條件下,擷取滿足測試需要的參數。

方法進行存根前

- (NSString *)getOtherTimeStrWithString:(NSString *)formatTime{
    NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
    [formatter setDateStyle:NSDateFormatterMediumStyle];
    [formatter setTimeStyle:NSDateFormatterShortStyle];
    [formatter setDateFormat:@"YYYY-MM-dd HH:mm:ss"]; //(@"YYYY-MM-dd hh:mm:ss") ----------設定你想要的格式,hh與HH的差別:分别表示12小時制,24小時制
    //設定時區選擇中原標準時間
    NSTimeZone* timeZone = [NSTimeZone timeZoneWithName:@"Asia/Beijing"];
    [formatter setTimeZone:timeZone];
    NSDate* date = [formatter dateFromString:formatTime]; //------------将字元串按formatter轉成nsdate
    //時間轉時間戳的方法:
    NSInteger timeSp = [[NSNumber numberWithDouble:[date timeIntervalSince1970]] integerValue] * 1000;
    return [NSString stringWithFormat:@"%ld",(long)timeSp];
}           

使用stub(mockObject getOtherTimeStrWithString).andReturn(@"1000")存根後類似于以下效果

- (NSString *)getOtherTimeStrWithString:(NSString *)formatTime{
    
    return @"1000";
    
    NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
    [formatter setDateStyle:NSDateFormatterMediumStyle];
    [formatter setTimeStyle:NSDateFormatterShortStyle];
    [formatter setDateFormat:@"YYYY-MM-dd HH:mm:ss"]; //(@"YYYY-MM-dd hh:mm:ss") ----------設定你想要的格式,hh與HH的差別:分别表示12小時制,24小時制
    //設定時區選擇中原標準時間
    NSTimeZone* timeZone = [NSTimeZone timeZoneWithName:@"Asia/Beijing"];
    [formatter setTimeZone:timeZone];
    NSDate* date = [formatter dateFromString:formatTime]; //------------将字元串按formatter轉成nsdate
    //時間轉時間戳的方法:
    NSInteger timeSp = [[NSNumber numberWithDouble:[date timeIntervalSince1970]] integerValue] * 1000;
    return [NSString stringWithFormat:@"%ld",(long)timeSp];
}           

2. 代碼正常流程經過測試已經很健壯了,但是一些錯誤的流程并不容易發現但是是可能存在的,例如邊緣值資料,單元測試中可以使用存根對資料進行模拟,測試代碼在特殊資料情況下的運作情況。

注:stub()也可以不設定傳回值,驗證可行,猜測可能是傳回的nil或者void,是以不帶傳回值的方法也可以進行方法存根。

(二)生成 Mock 對象,目前有三種方式。

通過對Person類的talk方法進行測試舉例,其中也涉及Men類以及Animaiton類,以下是三個類的相關源碼。

Person類

@interface Person()
@property(nonatomic,strong)Men *men;
@end


@implementation Person
-(void)talk:(NSString *)str
{
    [self.men logstr:str];
    [Animaiton logstr:str];
    
}
@end           

Men類

@implementation Men
-(NSString *)logstr:(NSString *)str
{
    NSLog(@"%@",str);
    return str;
}
@end           

Animaiton類

@implementation Animaiton
+(NSString *)logstr:(NSString *)str
{
    NSLog(@"%@",str);
    return str;
}
-(NSString *)logstr:(NSString *)str
{
    NSLog(@"%@",str);
    return str;
}
@end           

對talk方法進行單測時需要對person類進行mock,以下是通過三種不同的方式生成mock對象,對三種方式的調用方法,使用場景都做了介紹,最後對每種方式的優缺點也做了一個表格友善差別。

Nice Mock

NiceMock 建立的 mock 對象在進行方法測試時會優先調用執行個體方法,若未找到執行個體方法,會繼續調用同名的類方法。是以該方法可以用來生成mock對象去測試類方法也可以測試對象方法。

使用方式:

- (void)testTalkNiceMock {
    id mockA = OCMClassMock([Men class]);
    Person *person1 = [Person new];
    person1.men = mockA;
    [person1 talk:@"123"];
    OCMVerify([mockA logstr:[OCMArg any]]);
}           

使用場景:

Nice mock 是比較友好的,當一個沒有存根的方法被調用時他不會引起一個異常會驗證通過。如果你不想自己對很多的方法進行存根,那麼使用 nice mock。在上方的舉例中mockA調用testTalkNiceMock時,Men類中的+(NSString *)logstr:(NSString *)str不會執行列印操作。在調用過程中因為同時存在同名的logstr:類方法和執行個體方法,會優先調用執行個體方法。

Strict Mock

使用方式:

測試case如下,mockA是Strict Mock生成要調用testTalkStrictMock方法,則Mock生成要調用testTalkStrictMock方法則該方法要使用stub進行存根,否則最後的OCMVerifyAll(mockA)就會抛出異常。

- (void)testTalkStrictMock {
    id mockA = OCMStrictClassMock([Person class]);
    OCMStub([mockA talk:@"123"]);
    [mockA talk:@"123"];
    OCMVerifyAll(mockA);
}           

使用場景:

這種方式建立的 mock 對象,如果調用未 stub(stub 代表存根)的方法,會抛出一個異常。這需要保證在 mock 的生命周期中每一個獨立調用的方法都是被存根的,這種方法使用比較嚴格,很少使用。

Partial Mock

這樣建立的對象在調用方法時:如果方法被 stub,調用 stub 後的方法,如果方法沒有被 stub,調用原來的對象的方法,該方法有限制隻能 mock 執行個體對象。

使用方式:

- (void)testTalkPartialMock {
    id mockA = OCMPartialMock([Men new]);
    Person *person1 = [Person new];
    person1.men = mockA;
    [person1 talk:@"123"];
    OCMVerify([mockA logstr:[OCMArg any]]);
}           

使用場景:

當調用一個沒有被存根的方法時,會調用實際對象的該方法。當不能很好的存根一個類的方法時,該技術是非常有用的。調用testTalkPartialMock時Men類中的+(NSString *)logstr:(NSString *)str會執行列印操作。

三種方式的差異表格:

iOS 單元測試之常用架構 OCMock 詳解

(三)驗證方法的調用

調用方式:

OCMVerify([mock someMethod]);
OCMVerify(never(),    [mock doStuff]); //從沒被調用
OCMVerify(times(n),   [mock doStuff]);   //調用了N次
OCMVerify(atLeast(n), [mock doStuff]);  //最少被調用了N次
OCMVerify(atMost(n),  [mock doStuff]);           

使用場景:

在單元測試中可以驗證某個方法是否執行,以及執行了幾次。

延時驗證調用:

OCMVerifyAllWithDelay(mock, aDelay);           

使用場景:該功能用于等待異步操作會比較多,其中aDelay為預期最長等待時間。

(四)添加預期

調用方式:

準備資料:

NSDictionary *info = @{@"name": @"momo"};
id mock = OCMClassMock([MOOCMockDemo class]);           

添加預期:

OCMExpect([mock handleLoadSuccessWithPerson:[OCMArg any]]);           

可以預期不執行:

OCMReject([mock handleLoadFailWithPerson:[OCMArg any]]);           

可以驗證參數:

// 預期 + 參數驗證
OCMExpect([mock handleLoadSuccessWithPerson:[OCMArg checkWithBlock:^BOOL(id obj) {
    MOPerson *person = (MOPerson *)obj;
    return [person.name isEqualToString:@"momo"];
}]]);           

可以預期執行順序:

// 預期下列方法順序執行
[mock setExpectationOrderMatters:YES];
OCMExpect([mock handleLoadSuccessWithPerson:[OCMArg any]]);
OCMExpect([mock showError:NO]);           

可以忽略參數(預期方法執行時):

OCMExpect([mock showError:YES]).ignoringNonObjectArgs; // 忽視參數           

執行:

[MOOCMockDemo handleLoadFinished:info];           

斷言:

OCMVerifyAll(mock);           

可以延遲斷言:

OCMVerifyAllWithDelay(mock, 1); // 支援延遲驗證           

最後的 OCMVerifyAll 會驗證前面的期望是否有效,隻要有一個沒調用,就會出錯。

(五)參數限制

調用方式:

OCMStub([mock someMethodWithAnArgument:[OCMArg any]])
OCMStub([mock someMethodWithPointerArgument:[OCMArg anyPointer]])
OCMStub([mock someMethodWithSelectorArgument:[OCMArg anySelector]])           

使用場景:在使用 OCMVerify()方法驗證某個方法是否調用是使用,單元測試會驗證方法參數是否一緻,如果不一緻就是提示驗證失敗,此時如果隻關注方法調用,并不關注參數即可使用[OCMArg any]傳參。

(六)網絡接口的模拟

顧名思義可以 mock 網絡接口的資料傳回,測試不同資料下代碼的走向以及準确性。

調用方式:

id mockManager = OCMClassMock([JDStoreNetwork class]);
[orderListVc setComponentsNet:mockManager];
[OCMStub([mockManager startWithSetup:[OCMArg any] didFinish:[OCMArg any] didCancel:[OCMArg any]]) andDo:^(NSInvocation *invocation) {   


    void (^successBlock)(id components,NSError *error) = nil;   
    
    [invocation getArgument:&successBlock atIndex:3];  
    
    successBlock(@{@"code":@"1",@"resultCode":@"1",@"value":@{@"showOrderSearch":@"NO"}},nil);
    }];           

以上就是在調用 setComponentsNet 方法内部調用了接口,該方法就可以在調用接口後模拟需要的傳回資料,successBlock 中的就是傳回的測試資料。本方式是通過擷取接口調用的方法簽名,擷取 successBlock 成功回調傳參并手動調用。同樣可以模拟接口失敗的情況,隻需擷取到簽名中的對應的失敗回調就可以實作了。

使用場景:書寫單元測試方法時涉及網絡接口的模拟,通過該方式 mock 接口傳回結果。

(七)恢複類

置換類方法後,可以将類恢複到原來的狀态,通過調用 stopMocking 來完成。

調用方式:

id classMock = OCMClassMock([SomeClass class]);
/* do stuff */
[classMock stopMocking];           

使用場景:

正常對執行個體對象置換後,mock 對象釋放後會自動調用 stopMocking,但是添加到類方法上的 mock 對象會跨越了多個測試,mock 的類對象在置換後不會 deallocated,需要手動來取消這個 mock 關系。

(八)觀察者模拟-建立一個接受通知的執行個體

調用方式:

- (void)testPostNotification {   
Person *person1 = [[Person alloc] init];   
id observerMock = OCMObserverMock();   
//給通知中心設定觀察者    
[[NSNotificationCenter defaultCenter] addMockObserver: observerMock name:@"name" object:nil];    
//設定觀察期望    
[[observerMock expect] notificationWithName:@"name" object:[OCMArg any]];    //調用要驗證的方法    
[person1 methodWithPostNotification];    
[[NSNotificationCenter defaultCenter] removeObserver:observerMock];    
// 調用驗證   
OCMVerifyAll(observerMock);}           

使用場景:

建立一個 mock 對象,可以用來觀察通知。mock 必須注冊以接收通知。

(九)mock協定

調用方式:

id protocolMock = OCMProtocolMock(@protocol(SomeProtocol));
/*嚴格的協定*/
id classMock = OCMStrictClassMock([SomeClass class]);
id protocolMock = OCMStrictProtocolMock(@protocol(SomeProtocol));
id protocolMock = OCMProtocolMock(@protocol(SomeProtocol));
/*嚴格的協定*/
id classMock = OCMStrictClassMock([SomeClass class]);
id protocolMock = OCMStrictProtocolMock(@protocol(SomeProtocol));           

調用場景:當需要建立一個執行個體,讓其具有協定的所定義的功能時使用。

2.3 mock使用限制

對于同個方法,先stub後expect是不行的:因為先stub的話,所有的調用都會變成stub,這樣子即使過程調用該方法,最後OCMVerifyAll驗證也會失敗;解決的辦法是,在OCMExpect上順便stub,比如:OCMExpect([mock someMethod]).andReturn(@"a string"),或者将stub置于expect之後。

部分模拟不适用于某些類:如NSString和NSDate,這些”toll-free bridged”的類,否則會抛出異常。

某些方法不能stub:如:init、class、methodSignatureForSelector、forwardInvocation這些。

NSString與NSArray的類方法不能stub,否則無效。

NSObject的方法調用不能驗證,除非在子類中重寫。

蘋果核心類的私有方法調用不能被驗證,如以_開頭的方法。

延時驗證方法調用不支援,暫時隻支援期望-運作-驗證模式的延時驗證。

OCMock不支援多線程。

三、最後

希望這篇文章和例子已經陳述清楚了一些 OCMock 最通用的用法。OCMock 站點:http://ocmock.org/features/ 是一個最好的學習 OCMock 的地方。mock 是單調的但是對于一個應用程式卻是必須的。如果一個方法很難用 mock 來測試,這個迹象表明你的設計需要重新考慮了。

參考連結:

OCMock 官網:https://ocmock.org/features/

OCMock3 參考:https://www.cnblogs.com/xilifeng/p/4690280.html#header-c18

iOS測試系列:http://blog.oneinbest.com/2017/07/27/iOS%E6%B5%8B%E8%AF%95%E7%B3%BB%E5%88%97-%E4%B8%89-OCMock%E7%9A%84%E4%BD%BF%E7%94%A8/

作者:京東零售 王中文

來源:京東雲開發者社群

繼續閱讀