(二) kiwi 實踐一二

??上一篇 初探 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源碼簡析

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

  • Spring Cloud為開發(fā)人員提供了快速構建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務發(fā)現,斷路器,智...
    卡卡羅2017閱讀 136,506評論 19 139
  • 前言 如果有測試大佬發(fā)現內容不對,歡迎指正,我會及時修改。 大多數的iOS App(沒有持續(xù)集成)迭代流程是這樣的...
    默默_David閱讀 1,772評論 0 4
  • 1.Creating mock objects 1.1Class mocks idclassMock=OCMCla...
    奔跑的小小魚閱讀 2,835評論 0 0
  • 我們?yōu)槭裁匆脺y試框架呢?當然對項目開發(fā)有幫助了,但是業(yè)內現狀是經常趕進度,所以TDD還是算了吧,BDD就測測數據...
    CrespoXiao閱讀 14,540評論 9 59
  • 該文章使用的API是OCMock老版本的API,新版本也兼容老版本的API,譯者在用到老版本的API處已經添加了對...
    木易林1閱讀 708評論 0 0

友情鏈接更多精彩內容