32

This is related to my previous question, but different enough that I figured I'd throw it into a new one. I have some code that runs async on a custom queue, then executes a completion block on the main thread when complete. I'd like to write unit test around this method. My method on MyObject looks like this.

+ (void)doSomethingAsyncThenRunCompletionBlockOnMainQueue:(void (^)())completionBlock {

    dispatch_queue_t customQueue = dispatch_queue_create("com.myObject.myCustomQueue", 0);

    dispatch_async(customQueue, ^(void) {

        dispatch_queue_t currentQueue = dispatch_get_current_queue();
        dispatch_queue_t mainQueue = dispatch_get_main_queue();

        if (currentQueue == mainQueue) {
            NSLog(@"already on main thread");
            completionBlock();
        } else {
            dispatch_async(mainQueue, ^(void) {
                NSLog(@"NOT already on main thread");
                completionBlock();
        }); 
    }
});

}

I threw in the main queue test for extra safety, but It always hits the dispatch_async. My unit test looks like the following.

- (void)testDoSomething {

    dispatch_semaphore_t sema = dispatch_semaphore_create(0);

    void (^completionBlock)(void) = ^(void){        
        NSLog(@"Completion Block!");
        dispatch_semaphore_signal(sema);
    }; 

    [MyObject doSomethingAsyncThenRunCompletionBlockOnMainQueue:completionBlock];

    // Wait for async code to finish
    dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
    dispatch_release(sema);

    STFail(@"I know this will fail, thanks");
}

I create a semaphore in order to block the test from finishing before the async code does. This would work great if I don't require the completion block to run on the main thread. However, as a couple folks pointed out in the question I linked to above, the fact that the test is running on the main thread and then I enqueue the completion block on the main thread means I'll just hang forever.

Calling the main queue from an async queue is a pattern I see a lot for updating the UI and such. Does anyone have a better pattern for testing async code that calls back to the main queue?

Community
  • 1
  • 1
Drewsmits
  • 1,374
  • 2
  • 14
  • 23
  • I've collected a few resources regarding async testing, including BJ Homer's answer, in a blog post. http://drewsmitscode.posterous.com/testing-asynchronous-code-in-objective-c – Drewsmits May 03 '12 at 00:07

6 Answers6

58

There are two ways to get blocks dispatched to the main queue to run. The first is via dispatch_main, as mentioned by Drewsmits. However, as he also noted, there's a big problem with using dispatch_main in your test: it never returns. It will just sit there waiting to run any blocks that come its way for the rest of eternity. That's not so helpful for a unit test, as you can imagine.

Luckily, there's another option. In the COMPATIBILITY section of the dispatch_main man page, it says this:

Cocoa applications need not call dispatch_main(). Blocks submitted to the main queue will be executed as part of the "common modes" of the application's main NSRunLoop or CFRunLoop.

In other words, if you're in a Cocoa app, the dispatch queue is drained by the main thread's NSRunLoop. So all we need to do is keep the run loop running while we're waiting for the test to finish. It looks like this:

- (void)testDoSomething {

    __block BOOL hasCalledBack = NO;

    void (^completionBlock)(void) = ^(void){        
        NSLog(@"Completion Block!");
        hasCalledBack = YES;
    }; 

    [MyObject doSomethingAsyncThenRunCompletionBlockOnMainQueue:completionBlock];

    // Repeatedly process events in the run loop until we see the callback run.

    // This code will wait for up to 10 seconds for something to come through
    // on the main queue before it times out. If your tests need longer than
    // that, bump up the time limit. Giving it a timeout like this means your
    // tests won't hang indefinitely. 

    // -[NSRunLoop runMode:beforeDate:] always processes exactly one event or
    // returns after timing out. 

    NSDate *loopUntil = [NSDate dateWithTimeIntervalSinceNow:10];
    while (hasCalledBack == NO && [loopUntil timeIntervalSinceNow] > 0) {
        [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
                                 beforeDate:loopUntil];
    }

    if (!hasCalledBack)
    {
        STFail(@"I know this will fail, thanks");
    }
}
Valeriy Van
  • 1,842
  • 16
  • 19
BJ Homer
  • 47,750
  • 10
  • 111
  • 128
  • 1
    Once again, thanks for taking the time to answer! This looks like a pretty good pattern for OCUnit async testing. – Drewsmits Oct 20 '11 at 19:02
  • amongst my google searches, this was by far the best and easiest to implement solution for asynchronous testing that i found. – thgc May 10 '12 at 20:02
  • You should check that hasCalledBack == NO before failing right? Or is that while() loop supposed to return somehow when if works. – NKijak Aug 03 '12 at 17:42
  • Yeah, you'd want to change that. I just copied that directly from the OP's post. – BJ Homer Aug 03 '12 at 18:58
  • Thank you so much! So simple! – Misha Karpenko Sep 13 '12 at 14:23
  • I was working with another developer trying to figure out how to do this and completely forgot about calling the runloop directly. Awesome answer. – stuckj Nov 13 '13 at 17:45
  • This code assumes that I can pass a completion block to the code I am testing. I am not sure if I consider this as a viable approach at all. – p0lAris May 23 '15 at 17:12
  • Historical note: SenTest has been replaced by XCTest, which now includes support for asynchronous tests directly via the `XCTestExpectation` API. – BJ Homer Nov 03 '16 at 17:21
18

An alternate method, using semaphores and runloop churning. Note that dispatch_semaphore_wait returns nonzero if it times out.

- (void)testFetchSources
{
    dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);

    [MyObject doSomethingAsynchronousWhenDone:^(BOOL success) {
        STAssertTrue(success, @"Failed to do the thing!");
        dispatch_semaphore_signal(semaphore);
    }];

    while (dispatch_semaphore_wait(semaphore, DISPATCH_TIME_NOW))
        [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
                                 beforeDate:[NSDate dateWithTimeIntervalSinceNow:10]];

    dispatch_release(semaphore);
}
qwzybug
  • 553
  • 4
  • 14
7

Square included a clever addition to SenTestCase in their SocketRocket project that makes this easy. You can call it like this:

[self runCurrentRunLoopUntilTestPasses:^BOOL{
    return [someOperation isDone];
} timeout: 60 * 60];

The code is available here:

SenTestCase+SRTAdditions.h

SenTestCase+SRTAdditions.m

dstnbrkr
  • 4,237
  • 20
  • 23
7

BJ Homer's solution is the best solution so far. I've created some macro's built on that solution.

Check out the project here https://github.com/hfossli/AGAsyncTestHelper

- (void)testDoSomething {

    __block BOOL somethingIsDone = NO;

    void (^completionBlock)(void) = ^(void){        
        NSLog(@"Completion Block!");
        somethingIsDone = YES;
    }; 

    [MyObject doSomethingAsyncThenRunCompletionBlockOnMainQueue:completionBlock];

    WAIT_WHILE(!somethingIsDone, 1.0); 
    NSLog(@"This won't be reached until async job is done");
}

The WAIT_WHILE(expressionIsTrue, seconds)-macro will evaluate the input until the expression is not true or the time limit is reached. I think it is hard to get it cleaner than this

hfossli
  • 21,612
  • 9
  • 111
  • 127
3

The simplest way to execute blocks on the main queue is to call dispatch_main() from the main thread. However, as far as I can see from the docs, that will never return, so you can never tell if your test has failed.

Another approach is to make your unit test go into its run loop after the dispatch. Then the completion block will have a chance to execute and you also have the opportunity for the run loop to time out, after which you can deem the test failed if the completion block has not run.

JeremyP
  • 80,230
  • 15
  • 117
  • 158
2

Based on several of the other answers to this question, I set this up for convenience (and fun): https://github.com/kallewoof/UTAsync

Hope it helps someone.

Kalle
  • 12,846
  • 7
  • 58
  • 74