上一篇 初探 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)。框架
(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調用後調用,可用於恢復測試實例的狀態或清理對象。ui
指望至關於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…