上一篇 初探 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…