How to unit test asynchronous APIs?

2019-01-08 04:04发布

I have installed Google Toolbox for Mac into Xcode and followed the instructions to set up unit testing found here.

It all works great, and I can test my synchronous methods on all my objects absolutely fine. However, most of the complex APIs I actually want to test return results asynchronously via calling a method on a delegate - for example a call to a file download and update system will return immediately and then run a -fileDownloadDidComplete: method when the file finishes downloading.

How would I test this as a unit test?

It seems like I'd want to the testDownload function, or at least the test framework to 'wait' for fileDownloadDidComplete: method to run.

EDIT: I've now switched to using the XCode built-in XCTest system and have found that TVRSMonitor on Github provides a dead easy way to use semaphores to wait for async operations to complete.

For example:

- (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");
}

13条回答
我想做一个坏孩纸
2楼-- · 2019-01-08 04:06

I wrote a little helper that makes it easy to test asynchronous API. First the helper:

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

You can use it like this:

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

It will only continue if done becomes TRUE, so make sure to set it once completed. Of course you could add a timeout to the helper if you like,

查看更多
不美不萌又怎样
3楼-- · 2019-01-08 04:11

St3fan, you are a genius. Thanks a lot!

This is how I did it using your suggestion.

'Downloader' defines a protocol with a method DownloadDidComplete that fires on completion. There's a BOOL member variable 'downloadComplete' that is used to terminate the run loop.

-(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!");
}

The run-loop could potentially run forever of course.. I'll improve that later!

查看更多
smile是对你的礼貌
4楼-- · 2019-01-08 04:16

This is tricky. I think you will need to setup a runloop in your test and also the ability to specify that runloop to your async code. Otherwise the callbacks won't happen since they are executed on a runloop.

I guess you could just run the runloop for s short duration in a loop. And let the callback set some shared status variable. Or maybe even simply ask the callback to terminate the runloop. That way you you know the test is over. You should be able to check for timeouts by stoppng the loop after a certain time. If that happens then a timeout ocurred.

I've never done this but I will have to soon I think. Please do share your results :-)

查看更多
Anthone
5楼-- · 2019-01-08 04:16

To elaborate on @St3fan's solution, you can try this after initiating the request:

- (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;
}

Another way:

//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;
        }
    }
查看更多
我只想做你的唯一
7楼-- · 2019-01-08 04:19

I just wrote a blog entry about this (in fact I started a blog because I thought this was an interesting topic). I ended up using method swizzling so I can call the completion handler using any arguments I want without waiting, which seemed good for unit testing. Something like this:

- (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. 
}

blog entry for anyone that cares.

查看更多
登录 后发表回答