(二) kiwi 实践一二

  上一篇 初探 iOS 单元测试 我们简述了单元测试的目的和本质,并介绍了XCTest的常见用法。XCTest作为iOS单元测试底层的工具,可以编写出各种细微漂亮的测试用例,但直观上来看,测试用例代码量大,书写繁琐,方法及断言可读性较差,缺乏Mock工具,各个测试方法是独立的,不能表达出测试方法间的关系。一定程度上不能满足快速测试驱动开发的需求。
  BDD作为TDD的扩展,推崇用自然语言描述测试过程,非编写人员也能很快看懂测试方法的期望、通过标准及各个方法上下文的关系。因此,开发人员可以透过需求更加快捷简单的设计、描述和编写测试用例。kiwi作为OC平台上比较知名的测试框架,以众多强大的C语言宏,巧妙的把原本独立的XCTest测试方法穿插成了一段段用who..when..can/shoulld..描述的自然过程。

先看一个简单的demo

两个业务类

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调用后调用,可用于恢复测试实例的状态或清理对象。

期望与匹配

  期望相当于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方法实现替换掉,方法返回我们创建的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的最小测试单位为测试用例类的一个测试方法。

谢谢观看,水平有限,欢迎指出错误

参考资料

https://github.com/kiwi-bdd/Kiwi
https://github.com/allending/Kiwi/wiki/Expectations
https://onevcat.com/2014/02/ios-test-with-kiwi/

下一篇 kiwi源码简析

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容