上一篇 初探 iOS 單元測試 咱們簡述了單元測試的目的和本質,並介紹了XCTest的常見用法。XCTest做爲iOS單元測試底層的工具,能夠編寫出各類細微漂亮的測試用例,但直觀上來看,測試用例代碼量大,書寫繁瑣,方法及斷言可讀性較差,缺少Mock工具,各個測試方法是獨立的,不能表達出測試方法間的關係。必定程度上不能知足快速測試驅動開發的需求。 BDD做爲TDD的擴展,推崇用天然語言描述測試過程,非編寫人員也能很快看懂測試方法的指望、經過標準及各個方法上下文的關係。所以,開發人員能夠透過需求更加快捷簡單的設計、描述和編寫測試用例。kiwi做爲OC平臺上比較知名的測試框架,以衆多強大的C語言宏,巧妙的把本來獨立的XCTest測試方法穿插成了一段段用who..when..can/shoulld..描述的天然過程。ios
兩個業務類git
#import <Foundation/Foundation.h> typedef double ASScore; @interface ASRatingCalculator : NSObject @property (nonatomic, strong, readonly) NSArray *scores; - (void)inputScores:(NSArray<NSNumber *> *)scores; - (void)removeMaxAndMin; - (ASScore)maxScore; - (ASScore)minScore; - (ASScore)average; @end 複製代碼
#import "ASRatingCalculator.h" @interface ASRatingCalculator () @property (nonatomic, strong) NSMutableArray *mScores; @end @implementation ASRatingCalculator - (instancetype)init { if (self = [super init]) { self.mScores = [[NSMutableArray alloc] init]; } return self; } - (NSArray *)scores { return [self.mScores copy]; } - (void)inputScores:(NSArray<NSNumber *> *)scores { if (scores.count) { Class class = NSClassFromString(@"__NSCFNumber"); for (NSNumber *score in scores) { if (![score isKindOfClass:class] && [score doubleValue] >= 0.0f) { [NSException raise:@"ASRatingCalculatorInputError" format:@"input contains non-numberic object"]; return; } } [self.mScores removeAllObjects]; [self.mScores addObjectsFromArray:scores]; } } - (ASScore)minScore { if (self.mScores.count) { [self sortScoresAscending]; return [[self.mScores firstObject] doubleValue]; } return 0.0f; } - (ASScore)maxScore { if (self.mScores.count) { [self sortScoresAscending]; return [[self.mScores lastObject] doubleValue]; } return 0.0f; } - (void)removeMaxAndMin { if (self.mScores.count > 1) { [self sortScoresAscending]; [self.mScores removeObjectAtIndex:0]; [self.mScores removeLastObject]; } } - (ASScore)average { if (self.mScores.count > 0) { ASScore sum = 0.0; for (NSNumber *score in self.mScores) { sum += score.doubleValue; } return sum / self.mScores.count; } return 0; } #pragma - Private - (void)sortScoresAscending { if (self.mScores.count) { [self.mScores sortUsingComparator:^NSComparisonResult(id _Nonnull obj1, id _Nonnull obj2) { return [obj1 compare:obj2]; }]; } } @end 複製代碼
#import <Foundation/Foundation.h> @interface ASRatingService : NSObject - (BOOL)inputScores:(NSString *)scoresText; - (double)averageScore; - (double)averageScoreAfterRemoveMinAndMax; - (double)lastResult; @end 複製代碼
#import "ASRatingService.h" #import "ASRatingCalculator.h" @interface ASRatingService () @property (nonatomic, strong) ASRatingCalculator *calculator; @property (nonatomic, assign) BOOL hasRemoveExtremum; @property (nonatomic, strong) NSRegularExpression *regularExpression; @end @implementation ASRatingService - (instancetype)init { if (self = [super init]) { self.calculator = [[ASRatingCalculator alloc] init]; _regularExpression = [NSRegularExpression regularExpressionWithPattern:@"^\\d+((.?\\d+)|d*)$" options:NSRegularExpressionCaseInsensitive error:nil]; } return self; } - (BOOL)inputScores:(NSString *)scoresText { NSArray<NSString *> *scores = [scoresText componentsSeparatedByString:@","]; if (scores.count) { NSMutableArray *mScores = [[NSMutableArray alloc] init]; for (NSString *score in scores) { NSRange matchRange = [_regularExpression rangeOfFirstMatchInString:score options:NSMatchingReportCompletion range:NSMakeRange(0,score.length)]; if (!matchRange.length) { return NO; } [mScores addObject:@(score.doubleValue)]; } [self.calculator inputScores:mScores]; return YES; } return NO; } - (double)averageScore { [[NSUserDefaults standardUserDefaults] setDouble:self.calculator.average forKey:@"asrating_lastResult"]; return [self.calculator average]; } - (double)averageScoreAfterRemoveMinAndMax { if (!self.hasRemoveExtremum) { [self.calculator removeMaxAndMin]; _hasRemoveExtremum = YES; } [[NSUserDefaults standardUserDefaults] setDouble:self.calculator.average forKey:@"asrating_lastResult"]; return [self.calculator average]; } - (double)lastResult { return [[NSUserDefaults standardUserDefaults] doubleForKey:@"asrating_lastResult"]; } @end 複製代碼
兩個對應的測試類github
#import <Foundation/Foundation.h> #import "ASRatingCalculator.h" SPEC_BEGIN(ASRatingCalculatorTest) describe(@"ASRatingCalculatorTest", ^{ __block ASRatingCalculator *calculator; beforeEach(^{ calculator = [[ASRatingCalculator alloc] init]; }); afterEach(^{ calculator = nil; }); context(@"when created", ^{ it(@"should exist", ^{ [[calculator shouldNot] beNil]; [[calculator.scores shouldNot] beNil]; }); }); context(@"when input correctly", ^{ beforeEach(^{ [calculator inputScores:@[@3, @2, @1, @4, @8.5, @5.5]]; [[calculator.scores should] haveCountOf:6]; }); it(@"should have scores", ^{ [calculator inputScores:@[@4, @3, @2, @1]]; [[theValue(calculator.scores.count) should] equal:theValue(4)]; [[theBlock(^{ [calculator inputScores:@[@4, @3, @"ss", @"5"]]; }) should] raiseWithName:@"ASRatingCalculatorInputError"]; }); it(@"return average correctly", ^{ [[theValue([calculator average]) should] equal:theValue(4.0)]; [calculator inputScores:@[@100, @111.5, @46]]; [[theValue([calculator average]) should] equal:85.83 withDelta:0.01]; }); it(@"can sort correctly", ^{ [[theValue([calculator minScore]) should] equal:@1.0]; [[theValue([calculator maxScore]) should] equal:@8.5]; [[theValue([calculator average]) should] equal:theValue(4)]; }); it(@"can remove max and min correctly", ^{ [calculator removeMaxAndMin]; [[theValue([calculator minScore]) should] equal:@2.0]; [[theValue([calculator maxScore]) should] equal:theValue(5.5)]; [[theValue([calculator average]) should] equal:3.6 withDelta:0.1]; [calculator inputScores:@[@3]]; [calculator removeMaxAndMin]; [[theValue([calculator minScore]) should] equal:@3.0]; [[theValue([calculator maxScore]) should] equal:theValue(3)]; [[theValue([calculator average]) should] equal:3 withDelta:0.1]; }); }); }); SPEC_END 複製代碼
#import <Foundation/Foundation.h> #import "ASRatingService.h" #import "ASRatingCalculator.h" SPEC_BEGIN(ASRatingServiceTest) describe(@"ASRatingServiceTest", ^{ __block ASRatingService *ratingService; beforeEach(^{ ratingService = [[ASRatingService alloc] init]; }); afterEach(^{ ratingService = nil; }); context(@"when created", ^{ it(@"should exist", ^{ [[ratingService shouldNot] beNil]; [[[ratingService performSelector:@selector(calculator) withObject:nil] shouldNot] beNil]; [[[ratingService performSelector:@selector(regularExpression) withObject:nil] shouldNot] beNil]; }); }); context(@"when input correctly", ^{ it(@"should return Yes", ^{ [[theValue([ratingService inputScores:@"7.0,1,2,3"]) should] beYes]; [[theValue([ratingService inputScores:@"1,2,3,4/7.0"]) should] beNo]; [[theValue([ratingService inputScores:@"1,2,3/4,s"]) should] beNo]; [[theValue([ratingService inputScores:@"1,2,3 ,5,8"]) should] beNo]; [[theValue([ratingService inputScores:@"-1,2,3,5,8"]) should] beNo]; }); it(@"can return correct average and record", ^{ id mock = [ASRatingCalculator mock]; [ratingService stub:@selector(calculator) andReturn:mock withArguments:nil]; KWCaptureSpy *spy = [mock captureArgument:@selector(inputScores:) atIndex:0]; [[theValue([ratingService inputScores:@"7.5,9.6,6.2,9"]) should] beYes]; [[spy.argument shouldNot] beNil]; [mock stub:@selector(average) andReturn:theValue(8.07) withArguments:nil]; [[theValue([ratingService averageScore]) should] equal:8.07 withDelta:0.01]; [[theValue([ratingService lastResult]) should] equal:8.07 withDelta:0.01]; [mock stub:@selector(average) andReturn:theValue(8.25) withArguments:nil]; [mock stub:@selector(removeMaxAndMin)]; [[theValue([ratingService averageScoreAfterRemoveMinAndMax]) should] equal:8.25 withDelta:0.01]; [[expectFutureValue(theValue([ratingService lastResult])) shouldEventuallyBeforeTimingOutAfter(3)] beNonNil]; }); }); }); SPEC_END 複製代碼
SPEC_BEGIN(name)
SPEC_END
聲明和實現了一個名爲name的測試用例類;bash
(1) void describe(NSString *aDescription, void (^block)(void));
一個完整測試過程, 描述了要測試的類或一個主題 (who)。markdown
(2) void context(NSString *aDescription, void (^block)(void));
一個局部的測試過程, 描述了在什麼情形或條件下會怎麼樣或者是某種類型測試的歸納,內嵌於(1) describe block裏 (when)。框架
(3) void it(NSString *aDescription, void (^block)(void));
單個方法的測試過程,通常包含多個參數輸入結果輸出的驗證;內嵌於(2) context block裏 (it can/do/should...)。異步
(4) void pending_(NSString *aDescription, void (^ignoredBlock);
及宏pending(title, args...)
、xit(title, args...)
用於描述還沒有實現的測試方法。工具
(5) void beforeEach(void (^block)(void));
在其處於同一層級前的其餘所有block調用前調用;可初始化測試類的實例,並賦一些屬性知足其餘block的測試準備。oop
(6) void afterEach(void (^block)(void));
在其處於同一層級前的其餘所有block調用後調用,可用於恢復測試實例的狀態或清理對象。單元測試
指望至關於XCTest裏的斷言,匹配至關於一個個的判斷方法。經常使用should 或shouldNot把對象轉爲能夠匹配的接收者;而後使用特定匹配器的方法去得出匹配結果。
[[subject should] someCondition:anArgument...];
複製代碼
例如
[[calculator.scores should] haveCountOf:6];
複製代碼
若失敗,則
每個匹配器都基於KWMatcher
類,咱們能夠新建子類重寫
- (BOOL)evaluate
;返回對
someCondition:anArgument...
的匹配結果, 重寫
- (NSString *)failureMessageForShould
和
- (NSString *)failureMessageForShouldNot
爲測試失敗時提供更加精準的mioAUS信息。固然,kiwi已經爲咱們提供了衆多功能強大且符合天然語言描述方法的
matcher
,基本上已經符合咱們大部分的需求。
github.com/allending/K…
異步測試狀況下
[[expectFutureValue(theValue([ratingService lastResult])) shouldEventuallyBeforeTimingOutAfter(3)] beNonNil];
複製代碼
theValue(expr) => expectFutureValue(id) should => shouldEventuallyBeforeTimingOutAfter(timeout) 咱們能夠判斷若干秒後指望值的狀況。
當咱們編寫代碼的時候,類的複合是難以免的。若是一個複合類依賴了若干實現了細分功能的類,在細分類未徹底實現和測試驗證的狀況下,如何保證複合類這一層單元測試的可進行性和正確性呢?答案就是mock,假設其餘類的職能是正常的,符合預期的。 咱們上文的demo中已經包含了mock使用,一個ASRatingService
對象將持有一個ASRatingCalculator
對象並依賴於它的計算功能,假設ASRatingCalculator
的全部方法還未實現,在測試ASRatingService
的平均數功能時,咱們能夠。
it(@"can return correct average and record", ^{ ① id mock = [ASRatingCalculator mock]; ② [ratingService stub:@selector(calculator) andReturn:mock withArguments:nil]; ③ KWCaptureSpy *spy = [mock captureArgument:@selector(inputScores:) atIndex:0]; ④ [[theValue([ratingService inputScores:@"7.5,9.6,6.2,9"]) should] beYes]; ⑤ [[spy.argument shouldNot] beNil]; ⑥ [mock stub:@selector(average) andReturn:theValue(8.07) withArguments:nil]; ⑦ [[theValue([ratingService averageScore]) should] equal:8.07 withDelta:0.01]; [[theValue([ratingService lastResult]) should] equal:8.07 withDelta:0.01]; [mock stub:@selector(average) andReturn:theValue(8.25) withArguments:nil]; ⑧ [mock stub:@selector(removeMaxAndMin)]; [[theValue([ratingService averageScoreAfterRemoveMinAndMax]) should] equal:8.25 withDelta:0.01]; [[expectFutureValue(theValue([ratingService lastResult])) shouldEventuallyBeforeTimingOutAfter(3)] beNonNil]; }); 複製代碼
ASRatingCalculator 和 ASRatingService 兩個類都實現了inputScores:方法,ASRatingService直接使用了ASRatingCalculator計算出來的平均值,例子比較簡單。
① 爲ASRatingCalculator創建一個mock虛擬對象; ② 把ratingService的calcultor方法實現替換掉,方法返回咱們建立的mock對象; ③ 捕獲mock inputScore:方法的第一個參數,確認該方法後續是否被調用; ④ ratingService 調用本身的inputScores:; ⑤ 此時捕獲的參數應該不爲空,證實mock也響應了inputScores:; ⑥ 把mock 的求平均數方法替換掉,直接返回咱們指望中的值; ⑦ 測試ratingService的平均值是否正確; ⑧ 保證mock能響應removeMaxAndMin消息; stub: 能夠替換真實對象以及構造mock對象的方法實現,不用關注方法內部邏輯,保證輸入輸出是正確的; 假如mock對象運行期收到了不能識別的消息,請添加任意stub該方法,由於該對象並不能響應所mock類的全部消息,只會對你標記的selector作處理, 如stub,captureArgument:等。因此,在測試過程當中,能夠對依賴的類的實例會收到的消息所有作stub處理。
kiwi在易用性上是高於於XCTest的,其測試用例在運行期插入了不少XCTest方法,但在未徹底執行全部測試用例時,是沒法看到單個測試方法的,更沒法執行單個測試。kiwi的最小測試單位爲一個測試用例類,而XCTest的最小測試單位爲測試用例類的一個測試方法。
github.com/kiwi-bdd/Ki… github.com/allending/K… onevcat.com/2014/02/ios…