web-dev-qa-db-ja.com

非同期APIを単体テストする方法は?

Mac用Google Toolbox をXcodeにインストールし、指示に従ってユニットテストをセットアップしました here

すべてうまく動作し、すべてのオブジェクトで同期メソッドを完全にテストできます。ただし、実際にテストしたい複雑なAPIのほとんどは、デリゲートでメソッドを呼び出して結果を非同期に返します。たとえば、ファイルのダウンロードおよび更新システムへの呼び出しはすぐに戻り、ファイルのダウンロードが終了すると-fileDownloadDidComplete:メソッドを実行します。

これを単体テストとしてどのようにテストしますか?

TestDownload関数、または少なくともテストフレームワークがfileDownloadDidComplete:メソッドの実行を「待機」したいようです。

編集:私はXCode組み込みのXCTestシステムの使用に切り替えましたが、Githubの TVRSMonitor は非同期操作が完了するのを待つためにセマフォを使用する非常に簡単な方法を提供することを発見しました.

例えば:

- (void)testLogin {
  TRVSMonitor *monitor = [TRVSMonitor monitor];
  __block NSString *theToken;

  [[Server instance] loginWithUsername:@"foo" password:@"bar"
                               success:^(NSString *token) {
                                   theToken = token;
                                   [monitor signal];
                               }

                               failure:^(NSError *error) {
                                   [monitor signal];
                               }];

  [monitor wait];

  XCTAssert(theToken, @"Getting token");
}
64
Ben Clayton

私は同じ質問にぶつかり、自分に合った別の解決策を見つけました。

次のように、セマフォを使用して非同期操作を同期フローに変換するために、「古い学校」アプローチを使用します。

// create the object that will perform an async operation
MyConnection *conn = [MyConnection new];
STAssertNotNil (conn, @"MyConnection init failed");

// create the semaphore and lock it once before we start
// the async operation
NSConditionLock *tl = [NSConditionLock new];
self.theLock = tl;
[tl release];    

// start the async operation
self.testState = 0;
[conn doItAsyncWithDelegate:self];

// now lock the semaphore - which will block this thread until
// [self.theLock unlockWithCondition:1] gets invoked
[self.theLock lockWhenCondition:1];

// make sure the async callback did in fact happen by
// checking whether it modified a variable
STAssertTrue (self.testState != 0, @"delegate did not get called");

// we're done
[self.theLock release]; self.theLock = nil;
[conn release];

必ず呼び出す

[self.theLock unlockWithCondition:1];

デリゲートで。

52

この質問がほぼ1年前に質問され、回答されたことに感謝しますが、与えられた回答に反対せざるを得ません。非同期操作、特にネットワーク操作をテストすることは非常に一般的な要件であり、正しく動作するために重要です。この例では、実際のネットワーク応答に依存している場合、テストの重要な価値の一部が失われます。具体的には、テストは通信しているサーバーの可用性と機能の正確さに依存します。この依存関係によってテストが行​​われます

  • より壊れやすい(サーバーがダウンするとどうなりますか?)
  • 包括的でない(どのようにして一貫して障害応答、またはネットワークエラーをテストしますか?)
  • これをテストすることを想像するとかなり遅くなります:

単体テストは、ほんの一瞬で実行する必要があります。テストを実行するたびに数秒のネットワーク応答を待つ必要がある場合、テストを頻繁に実行する可能性は低くなります。

単体テストは、主に依存関係のカプセル化に関するものです。テスト対象のコードの観点からは、次の2つのことが起こります。

  1. メソッドは、おそらくNSURLConnectionをインスタンス化することにより、ネットワーク要求を開始します。
  2. 指定したデリゲートは、特定のメソッド呼び出しを介して応答を受け取ります。

デリゲートは、リモートサーバーからの実際の応答からであろうと、テストコードからの応答であろうと、応答がどこから来たかを気にしません。これを利用して、自分で応答を生成するだけで非同期操作をテストできます。テストははるかに高速に実行され、成功または失敗の応答を確実にテストできます。

これは、使用している実際のWebサービスに対してテストを実行するべきではないという意味ではありませんが、これらは統合テストであり、独自のテストスイートに属します。そのスイートの障害は、Webサービスに変更があるか、単に停止していることを意味する場合があります。それらはより壊れやすいため、ユニットテストを自動化するよりも、自動化するほうが価値が低い傾向があります。

ネットワーク要求に対する非同期応答をテストする方法については、いくつかのオプションがあります。メソッドを直接呼び出すことで、デリゲートを単独でテストできます(例:[someDelegate connection:connection didReceiveResponse:someResponse])。これは多少機能しますが、少し間違っています。オブジェクトが提供するデリゲートは、特定のNSURLConnectionオブジェクトのデリゲートチェーン内の複数のオブジェクトの1つにすぎない場合があります。デリゲートのメソッドを直接呼び出すと、チェーンのさらに上位にある別のデリゲートによって提供される重要な機能が失われる可能性があります。より良い代替手段として、作成したNSURLConnectionオブジェクトをスタブ化し、応答メッセージをデリゲートチェーン全体に送信させることができます。 NSURLConnectionを(他のクラスの中で)再オープンし、これを行うライブラリがあります。例: https://github.com/pivotal/PivotalCoreKit/blob/master/SpecHelperLib/Extensions/NSURLConnection%2BSpec.m

44
Adam Milligan

St3fan、あなたは天才です。どうもありがとう!

これは私があなたの提案を使用して行った方法です。

「Downloader」は、完了時に起動するメソッドDownloadDidCompleteでプロトコルを定義します。実行ループを終了するために使用されるBOOLメンバー変数「downloadComplete」があります。

-(void) testDownloader {
 downloadComplete = NO;
 Downloader* downloader = [[Downloader alloc] init] delegate:self];

 // ... irrelevant downloader setup code removed ...

 NSRunLoop *theRL = [NSRunLoop currentRunLoop];

 // Begin a run loop terminated when the downloadComplete it set to true
 while (!downloadComplete && [theRL runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]);

}


-(void) DownloaderDidComplete:(Downloader*) downloader withErrors:(int) errors {
    downloadComplete = YES;

    STAssertNotEquals(errors, 0, @"There were errors downloading!");
}

実行ループはもちろん永久に実行される可能性があります。後で改善します!

19
Ben Clayton

非同期APIを簡単にテストできる小さなヘルパーを作成しました。最初のヘルパー:

static inline void hxRunInMainLoop(void(^block)(BOOL *done)) {
    __block BOOL done = NO;
    block(&done);
    while (!done) {
        [[NSRunLoop mainRunLoop] runUntilDate:
            [NSDate dateWithTimeIntervalSinceNow:.1]];
    }
}

次のように使用できます。

hxRunInMainLoop(^(BOOL *done) {
    [MyAsyncThingWithBlock block:^() {
        /* Your test conditions */
        *done = YES;
    }];
});

doneTRUEになった場合にのみ続行するため、完了したら必ず設定してください。もちろん、必要に応じてヘルパーにタイムアウトを追加できますが、

16
Holtwick

これには注意が必要です。テストでランループを設定し、非同期コードにそのランループを指定する機能も必要になると思います。そうしないと、コールバックは実行ループで実行されるため、コールバックは発生しません。

ループ内で短い期間実行ループを実行することができると思います。そして、コールバックが共有ステータス変数を設定できるようにします。または、単にコールバックにランループを終了するように依頼することもできます。そうすれば、テストが終了したことがわかります。一定時間後にループをstoppngすることでタイムアウトをチェックできるはずです。その場合、タイムアウトが発生しました。

私はこれをやったことがありませんが、すぐに考えなければなりません。結果を共有してください:-)

8
Stefan Arentz

@ St3fanのソリューションを詳しく説明するには、リクエストを開始した後にこれを試すことができます。

- (BOOL)waitForCompletion:(NSTimeInterval)timeoutSecs
{
    NSDate *timeoutDate = [NSDate dateWithTimeIntervalSinceNow:timeoutSecs];

    do
    {
        [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:timeoutDate];
        if ([timeoutDate timeIntervalSinceNow] < 0.0)
        {
            break;
        }
    }
    while (!done);

    return done;
}

別の方法:

//block the thread in 0.1 second increment, until one of callbacks is received.
    NSRunLoop *theRL = [NSRunLoop currentRunLoop];

    //setup timeout
    float waitIncrement = 0.1f;
    int timeoutCounter  = (int)(30 / waitIncrement); //30 sec timeout
    BOOL controlConditionReached = NO;


    // Begin a run loop terminated when the downloadComplete it set to true
    while (controlConditionReached == NO)
    {

        [theRL runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:waitIncrement]];
        //control condition is set in one of your async operation delegate methods or blocks
        controlConditionReached = self.downloadComplete || self.downloadFailed ;

        //if there's no response - timeout after some time
        if(--timeoutCounter <= 0)
        {
            break;
        }
    }
6
Daniel

AFNetworkingやASIHTTPRequestなどのライブラリを使用していて、リクエストをNSOperation(またはそれらのライブラリのサブクラス)で管理している場合、NSOperationQueueのtest/devサーバーに対して簡単にテストできます。

テスト中:

// create request operation

NSOperationQueue* queue = [[NSOperationQueue alloc] init];
[queue addOperation:request];
[queue waitUntilAllOperationsAreFinished];

// verify response

これは基本的に、操作が完了するまでrunloopを実行し、すべてのコールバックが通常どおりバックグラウンドスレッドで発生できるようにします。

6
Matt Connolly

https://github.com/premosystems/XCAsyncTestCase を使用すると非常に便利です

XCTestCaseに3つの非常に便利なメソッドを追加します

@interface XCTestCase (AsyncTesting)

- (void)waitForStatus:(XCTAsyncTestCaseStatus)status timeout:(NSTimeInterval)timeout;
- (void)waitForTimeout:(NSTimeInterval)timeout;
- (void)notify:(XCTAsyncTestCaseStatus)status;

@end

非常にクリーンなテストが可能になります。プロジェクト自体からの例:

- (void)testAsyncWithDelegate
{
    NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.google.com"]];
    [NSURLConnection connectionWithRequest:request delegate:self];
    [self waitForStatus:XCTAsyncTestCaseStatusSucceeded timeout:10.0];
}

- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{
    NSLog(@"Request Finished!");
    [self notify:XCTAsyncTestCaseStatusSucceeded];
}

- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error
{
    NSLog(@"Request failed with error: %@", error);
    [self notify:XCTAsyncTestCaseStatusFailed];
}
3
xverges

Thomas Tempelmannが提案したソリューションを実装しましたが、全体的には問題なく動作します。

しかし、落とし穴があります。テスト対象のユニットに次のコードが含まれているとします。

dispatch_async(dispatch_get_main_queue(), ^{
    [self performSelector:selector withObject:nil afterDelay:1.0];
});

テストが完了するまでメインスレッドにロックするように指示したため、セレクターが呼び出されることはありません。

[testBase.lock lockWhenCondition:1];

全体として、NSConditionLockを完全に取り除き、代わりに GHAsyncTestCase クラスを使用するだけで済みます。

これは私のコードでそれを使用する方法です:

@interface NumericTestTests : GHAsyncTestCase { }

@end

@implementation NumericTestTests {
    BOOL passed;
}

- (void)setUp
{
    passed = NO;
}

- (void)testMe {

    [self prepare];

    MyTest *test = [MyTest new];
    [test run: ^(NSError *error, double value) {
        passed = YES;
        [self notify:kGHUnitWaitStatusSuccess];
    }];
    [test runTest:fakeTest];

    [self waitForStatus:kGHUnitWaitStatusSuccess timeout:5.0];

    GHAssertTrue(passed, @"Completion handler not called");
}

ずっときれいで、メインスレッドをブロックしません。

2
bizz84
1
bickster

これについてのブログ記事を書いたところです(実際、これは興味深いトピックだと思ったのでブログを始めました)。最終的にメソッドスウィズリングを使用して、必要な引数を使用して完了ハンドラーを待機せずに呼び出せるようにしました。これは単体テストに適しているように思えました。このようなもの:

- (void)swizzledGeocodeAddressString:(NSString *)addressString completionHandler:(CLGeocodeCompletionHandler)completionHandler
{
    completionHandler(nil, nil); //You can test various arguments for the handler here.
}

- (void)testGeocodeFlagsComplete
{
    //Swizzle the geocodeAddressString with our own method.
    Method originalMethod = class_getInstanceMethod([CLGeocoder class], @selector(geocodeAddressString:completionHandler:));
    Method swizzleMethod = class_getInstanceMethod([self class], @selector(swizzledGeocodeAddressString:completionHandler:));
    method_exchangeImplementations(originalMethod, swizzleMethod);

    MyGeocoder * myGeocoder = [[MyGeocoder alloc] init];
    [myGeocoder geocodeAddress]; //the completion handler is called synchronously in here.

    //Deswizzle the methods!
    method_exchangeImplementations(swizzleMethod, originalMethod);

    STAssertTrue(myGeocoder.geocoded, @"Should flag as geocoded when complete.");//You can test the completion handler code here. 
}

ブログエントリ 気にする人のために。

1
akiraspeirs
0
Ilker Baltaci

私の答えは、概念的には単体テストは非同期操作のテストには適していないということです。サーバーへの要求や応答の処理などの非同期操作は、1つのユニットではなくtwoユニットで発生します。

応答を要求に関連付けるには、2つのユニット間の実行を何らかの形でブロックするか、グローバルデータを維持する必要があります。実行をブロックすると、プログラムは正常に実行されず、グローバルデータを維持する場合は、それ自体にエラーが含まれている可能性のある無関係な機能を追加しています。どちらのソリューションも単体テストの概念に違反しており、アプリケーションに特別なテストコードを挿入する必要があります。単体テストの後、テストコードをオフにして、昔ながらの「手動」テストを行う必要があります。単体テストに費やされる時間と労力は、少なくとも部分的に無駄になります。

0
David Casseres