プログラマーだけん、がんばる

神の国、島根のプログラマー。サーバ、Rubyまわりの技術(Ruby on Rails, Rhodes etc..)やiOS, Androidなどの開発を行っていくうえで、役だったことなどを共有できればいいなと思います。

Xcode 6.3.2(Objective-C) + Quick + Nimble + KIF がうまく動かなかったハナシ

いつもはSpectaでテストをすることが多いのですが、
ミーハーな私は、巷でじわじわきているQuickを導入してみたが、うまくいかなかったので記します。

環境はこちら

  • Xcode 6.3.2 (訳あってObjective-C)
  • KIF (3.3.0):
  • KIF/Core (= 3.3.0)
    • KIF/Core (3.3.0)
  • Nimble (0.4.2)
  • Quick (0.3.1)

QuickとNimbleを用いてモデルのユニットテストを記述していた際にはうまく動作していたのですが、
いざUIテストの記述をしていた際におかしな動きになりました

なにが起こったか?

「KIFを使用してテストを行った際にきちんとエラーがキャッチされず、テストが失敗したことにならない」

なにが原因か?

そもそもKIFはテストのフレームワークに依存せずしない作りになっており、エラーの発生はフレームワーク側へ移譲する実装をしています。

If you want to use KIF with a test runner that does not subclass XCTestCase, your runner class just needs to implement the KIFTestActorDelegate protocol which contains two required methods.

(void)failWithException:(NSException *)exception stopTest:(BOOL)stop;
(void)failWithExceptions:(NSArray *)exceptions stopTest:(BOOL)stop;

In the first case, the test runner should log the exception and halt the test execution if stop is YES. In the second, the runner should log all the exceptions and halt the test execution if stop is YES. The exceptions take advantage of KIF's extensions to NSException that include the lineNumber and filename in the exception's userData to record the error's origin.

https://github.com/kif-framework/KIF#use-with-other-testing-frameworks

つまり、Quick側の以下のメソッドの中身が怪しい

(void)failWithException:(NSException *)exception stopTest:(BOOL)stop;
(void)failWithExceptions:(NSArray *)exceptions stopTest:(BOOL)stop;

さっそくQuickのソースコードを読んでみる。

/**
 This method is used to record failures, whether they represent example
 expectations that were not met, or exceptions raised during test setup
 and teardown. By default, the failure will be reported as an
 XCTest failure, and the example will be highlighted in Xcode.
 */
- (void)recordFailureWithDescription:(NSString *)description
                              inFile:(NSString *)filePath
                              atLine:(NSUInteger)lineNumber
                            expected:(BOOL)expected {
    if (self.example.isSharedExample) {
        filePath = self.example.callsite.file;
        lineNumber = self.example.callsite.line;
    }
    [super recordFailureWithDescription:description
                                 inFile:filePath
                                 atLine:lineNumber
                               expected:expected];
}


テストを失敗させ、XCode上のファイルにハイライトをつける処理を実装している。
一見間違ってなさそうに見えるので、かなり混乱しましたが、
「recordFailureWithDescription:inFile:atLine:expected:」を呼び出すのはselfに対してではなく、現在実行されているテストのXCTestCaseのインスタンスというのが正解です。

使用しているv0.3.1より上のバージョンにコミットが積まれていました。
ただしこの修正が含まれているバージョンを使用するには、Xcode 7 / Swift 2.0 である必要があります。

つまり詰んだ...orz

解決法

カテゴリで拡張するしかない?

そもそも自分の使い方が悪いんじゃないか、導入の仕方がまずい?など色々悩みましたが、
Spectaでやっていた際には起きなかった事象なので無理やりカテゴリで解決しました。。

#import <Quick/QuickSpec.h>
#import <Quick/Quick-Swift.h>
#import <Quick/QuickConfiguration.h>
#import <Quick/NSString+QCKSelectorName.h>
#import <objc/runtime.h>

@interface QuickSpec (WithObjC)
@property (nonatomic, strong) XCTestRun *testRun;
@property (nonatomic, strong) Example *example;
@end

@implementation QuickSpec (WithObjC)

static QuickSpec *currentSpec = nil;

@dynamic testRun, example;

- (void)setTestRun:(XCTestRun *)testRun {
  objc_setAssociatedObject(self, _cmd, testRun, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (XCTestRun *)testRun {
  return objc_getAssociatedObject(self, @selector(setTestRun:));
}

- (void)performTest:(XCTestRun *)run {
  self.testRun = run;
  [super performTest:run];
}

+ (SEL)addInstanceMethodForExample:(Example *)example {
  IMP implementation = imp_implementationWithBlock(^(QuickSpec *self){
    currentSpec = self;
    [example run];
  });
  const char *types = [[NSString stringWithFormat:@"%s%s%s", @encode(id), @encode(id), @encode(SEL)] UTF8String];
  SEL selector = NSSelectorFromString(example.name.qck_selectorName);
  class_addMethod(self, selector, implementation, types);
  
  return selector;
}

- (void)recordFailureWithDescription:(NSString *)description
                              inFile:(NSString *)filePath
                              atLine:(NSUInteger)lineNumber
                            expected:(BOOL)expected {
  if (self.example.isSharedExample) {
    filePath = self.example.callsite.file;
    lineNumber = self.example.callsite.line;
  }
  [currentSpec.testRun recordFailureWithDescription:description
                               inFile:filePath
                               atLine:lineNumber
                             expected:expected];
}

- (void)failWithException:(NSException *)exception stopTest:(BOOL)stop
{
  self.continueAfterFailure = !stop;
  
  NSException *copyException = [exception copy];
  [self recordFailureWithDescription:copyException.description
                              inFile:copyException.userInfo[@"SenTestFilenameKey"]
                              atLine:[copyException.userInfo[@"SenTestLineNumberKey"] integerValue]
                            expected:YES];
}

- (void)failWithExceptions:(NSArray *)exceptions stopTest:(BOOL)stop
{
  NSException *lastException = exceptions.lastObject;
  for (NSException *exception in exceptions) {
    [self failWithException:exception stopTest:(exception == lastException ? stop : NO)];
  }
}

@end

多分(間違いなく)もっといい方法がある気がしますが、
発生していたエラーは解決し、KIFでテストが失敗した際にもうまく動作するようになりました。
しばらくこれで様子をみます。

最適な解決法があればツッコミをお願いします。