(二) kiwi 實踐一二

  上一篇 初探 iOS 單元測試 咱們簡述了單元測試的目的和本質,並介紹了XCTest的常見用法。XCTest做爲iOS單元測試底層的工具,能夠編寫出各類細微漂亮的測試用例,但直觀上來看,測試用例代碼量大,書寫繁瑣,方法及斷言可讀性較差,缺少Mock工具,各個測試方法是獨立的,不能表達出測試方法間的關係。必定程度上不能知足快速測試驅動開發的需求。   BDD做爲TDD的擴展,推崇用天然語言描述測試過程,非編寫人員也能很快看懂測試方法的指望、經過標準及各個方法上下文的關係。所以,開發人員能夠透過需求更加快捷簡單的設計、描述和編寫測試用例。kiwi做爲OC平臺上比較知名的測試框架,以衆多強大的C語言宏,巧妙的把本來獨立的XCTest測試方法穿插成了一段段用who..when..can/shoulld..描述的天然過程。ios

先看一個簡單的demo

兩個業務類git

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
複製代碼

兩個對應的測試類github

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的測試用例類;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

  當咱們編寫代碼的時候,類的複合是難以免的。若是一個複合類依賴了若干實現了細分功能的類,在細分類未徹底實現和測試驗證的狀況下,如何保證複合類這一層單元測試的可進行性和正確性呢?答案就是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…

相關文章
相關標籤/搜索