??上一篇 初探 iOS 單元測試 我們簡述了單元測試的目的和本質,并介紹了XCTest的常見用法。XCTest作為iOS單元測試底層的工具,可以編寫出各種細微漂亮的測試用例,但直觀上來看,測試用例代碼量大,書寫繁瑣,方法及斷言可讀性較差,缺乏Mock工具,各個測試方法是獨立的,不能表達出測試方法間的關系。一定程度上不能滿足快速測試驅動開發(fā)的需求。
??BDD作為TDD的擴展,推崇用自然語言描述測試過程,非編寫人員也能很快看懂測試方法的期望、通過標準及各個方法上下文的關系。因此,開發(fā)人員可以透過需求更加快捷簡單的設計、描述和編寫測試用例。kiwi作為OC平臺上比較知名的測試框架,以眾多強大的C語言宏,巧妙的把原本獨立的XCTest測試方法穿插成了一段段用who..when..can/shoulld..描述的自然過程。
先看一個簡單的demo
兩個業(yè)務類
ASRatingCalculator.h
#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
ASRatingCalculator.m
#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
ASRatingService.h
#import <Foundation/Foundation.h>
@interface ASRatingService : NSObject
- (BOOL)inputScores:(NSString *)scoresText;
- (double)averageScore;
- (double)averageScoreAfterRemoveMinAndMax;
- (double)lastResult;
@end
ASRatingService.m
#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
兩個對應的測試類
ASRatingCalculatorTest.m
#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
ASRatingServiceTest.m
#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的測試用例類;
行為
(1) void describe(NSString *aDescription, void (^block)(void)); 一個完整測試過程, 描述了要測試的類或一個主題 (who)。
(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的測試準備。
(6) void afterEach(void (^block)(void)); 在其處于同一層級前的其他全部block調用后調用,可用于恢復測試實例的狀態(tài)或清理對象。
期望與匹配
??期望相當于XCTest里的斷言,匹配相當于一個個的判斷方法。常常使用should 或shouldNot把對象轉為可以匹配的接收者;然后使用特定匹配器的方法去得出匹配結果。
[[subject should] someCondition:anArgument...];
例如
[[calculator.scores should] haveCountOf:6];
若失敗,則

??每一個匹配器都基于
KWMatcher類,我們可以新建子類重寫- (BOOL)evaluate;返回對someCondition:anArgument...的匹配結果, 重寫- (NSString *)failureMessageForShould 和- (NSString *)failureMessageForShouldNot為測試失敗時提供更加精準的mioAUS信息。當然,kiwi已經為我們提供了眾多功能強大且符合自然語言描述方法的matcher,基本上已經符合我們大部分的需求。https://github.com/allending/Kiwi/wiki/Expectations
異步測試情況下
[[expectFutureValue(theValue([ratingService lastResult])) shouldEventuallyBeforeTimingOutAfter(3)] beNonNil];
theValue(expr) => expectFutureValue(id)
should => shouldEventuallyBeforeTimingOutAfter(timeout)
我們可以判斷若干秒后期望值的情況。
Mock
??當我們編寫代碼的時候,類的復合是難以避免的。如果一個復合類依賴了若干實現了細分功能的類,在細分類未完全實現和測試驗證的情況下,如何保證復合類這一層單元測試的可進行性和正確性呢?答案就是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方法實現替換掉,方法返回我們創(chuàng)建的mock對象;
③ 捕獲mock inputScore:方法的第一個參數,確認該方法后續(xù)是否被調用;
④ ratingService 調用自己的inputScores:;
⑤ 此時捕獲的參數應該不為空,證明mock也響應了inputScores:;
⑥ 把mock 的求平均數方法替換掉,直接返回我們期望中的值;
⑦ 測試ratingService的平均值是否正確;
⑧ 保證mock能響應removeMaxAndMin消息;
stub: 可以替換真實對象以及構造mock對象的方法實現,不用關注方法內部邏輯,保證輸入輸出是正確的;
??假如mock對象運行期收到了不能識別的消息,請?zhí)砑尤我鈙tub該方法,因為該對象并不能響應所mock類的所有消息,只會對你標記的selector做處理, 如stub,captureArgument:等。所以,在測試過程中,可以對依賴的類的實例會收到的消息全部做stub處理。
一些吐槽
??kiwi在易用性上是高于于XCTest的,其測試用例在運行期插入了很多XCTest方法,但在未完全執(zhí)行所有測試用例時,是無法看到單個測試方法的,更無法執(zhí)行單個測試。kiwi的最小測試單位為一個測試用例類,而XCTest的最小測試單位為測試用例類的一個測試方法。
謝謝觀看,水平有限,歡迎指出錯誤
參考資料
https://github.com/kiwi-bdd/Kiwi
https://github.com/allending/Kiwi/wiki/Expectations
https://onevcat.com/2014/02/ios-test-with-kiwi/
下一篇 kiwi源碼簡析