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");
}
私は同じ質問にぶつかり、自分に合った別の解決策を見つけました。
次のように、セマフォを使用して非同期操作を同期フローに変換するために、「古い学校」アプローチを使用します。
// 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];
デリゲートで。
この質問がほぼ1年前に質問され、回答されたことに感謝しますが、与えられた回答に反対せざるを得ません。非同期操作、特にネットワーク操作をテストすることは非常に一般的な要件であり、正しく動作するために重要です。この例では、実際のネットワーク応答に依存している場合、テストの重要な価値の一部が失われます。具体的には、テストは通信しているサーバーの可用性と機能の正確さに依存します。この依存関係によってテストが行われます
単体テストは、ほんの一瞬で実行する必要があります。テストを実行するたびに数秒のネットワーク応答を待つ必要がある場合、テストを頻繁に実行する可能性は低くなります。
単体テストは、主に依存関係のカプセル化に関するものです。テスト対象のコードの観点からは、次の2つのことが起こります。
デリゲートは、リモートサーバーからの実際の応答からであろうと、テストコードからの応答であろうと、応答がどこから来たかを気にしません。これを利用して、自分で応答を生成するだけで非同期操作をテストできます。テストははるかに高速に実行され、成功または失敗の応答を確実にテストできます。
これは、使用している実際のWebサービスに対してテストを実行するべきではないという意味ではありませんが、これらは統合テストであり、独自のテストスイートに属します。そのスイートの障害は、Webサービスに変更があるか、単に停止していることを意味する場合があります。それらはより壊れやすいため、ユニットテストを自動化するよりも、自動化するほうが価値が低い傾向があります。
ネットワーク要求に対する非同期応答をテストする方法については、いくつかのオプションがあります。メソッドを直接呼び出すことで、デリゲートを単独でテストできます(例:[someDelegate connection:connection didReceiveResponse:someResponse])。これは多少機能しますが、少し間違っています。オブジェクトが提供するデリゲートは、特定のNSURLConnectionオブジェクトのデリゲートチェーン内の複数のオブジェクトの1つにすぎない場合があります。デリゲートのメソッドを直接呼び出すと、チェーンのさらに上位にある別のデリゲートによって提供される重要な機能が失われる可能性があります。より良い代替手段として、作成したNSURLConnectionオブジェクトをスタブ化し、応答メッセージをデリゲートチェーン全体に送信させることができます。 NSURLConnectionを(他のクラスの中で)再オープンし、これを行うライブラリがあります。例: https://github.com/pivotal/PivotalCoreKit/blob/master/SpecHelperLib/Extensions/NSURLConnection%2BSpec.m
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!");
}
実行ループはもちろん永久に実行される可能性があります。後で改善します!
非同期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;
}];
});
done
がTRUE
になった場合にのみ続行するため、完了したら必ず設定してください。もちろん、必要に応じてヘルパーにタイムアウトを追加できますが、
これには注意が必要です。テストでランループを設定し、非同期コードにそのランループを指定する機能も必要になると思います。そうしないと、コールバックは実行ループで実行されるため、コールバックは発生しません。
ループ内で短い期間実行ループを実行することができると思います。そして、コールバックが共有ステータス変数を設定できるようにします。または、単にコールバックにランループを終了するように依頼することもできます。そうすれば、テストが終了したことがわかります。一定時間後にループをstoppngすることでタイムアウトをチェックできるはずです。その場合、タイムアウトが発生しました。
私はこれをやったことがありませんが、すぐに考えなければなりません。結果を共有してください:-)
@ 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;
}
}
AFNetworkingやASIHTTPRequestなどのライブラリを使用していて、リクエストをNSOperation(またはそれらのライブラリのサブクラス)で管理している場合、NSOperationQueueのtest/devサーバーに対して簡単にテストできます。
テスト中:
// create request operation
NSOperationQueue* queue = [[NSOperationQueue alloc] init];
[queue addOperation:request];
[queue waitUntilAllOperationsAreFinished];
// verify response
これは基本的に、操作が完了するまでrunloopを実行し、すべてのコールバックが通常どおりバックグラウンドスレッドで発生できるようにします。
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];
}
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");
}
ずっときれいで、メインスレッドをブロックしません。
これについてのブログ記事を書いたところです(実際、これは興味深いトピックだと思ったのでブログを始めました)。最終的にメソッドスウィズリングを使用して、必要な引数を使用して完了ハンドラーを待機せずに呼び出せるようにしました。これは単体テストに適しているように思えました。このようなもの:
- (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つのユニットではなくtwoユニットで発生します。
応答を要求に関連付けるには、2つのユニット間の実行を何らかの形でブロックするか、グローバルデータを維持する必要があります。実行をブロックすると、プログラムは正常に実行されず、グローバルデータを維持する場合は、それ自体にエラーが含まれている可能性のある無関係な機能を追加しています。どちらのソリューションも単体テストの概念に違反しており、アプリケーションに特別なテストコードを挿入する必要があります。単体テストの後、テストコードをオフにして、昔ながらの「手動」テストを行う必要があります。単体テストに費やされる時間と労力は、少なくとも部分的に無駄になります。