Writing SenTestCase for asynchronous callbacks
Let’s say you have a method definition looking like this, that you’d like to test:
[self doSomethingWithCompletion:^(BOOL finished){
[weakSelf doSomethingElse];
}];
A naïve way to test it would look like:
@interface NaiveTestCase : SenTestCase
@end
@implementation NaiveTestCase
- (void) testSomething {
[self doSomethingWithCompletion:^(BOOL finished){
STAssertTrue(finished, @"Operation must finish");
[weakSelf doSomethingElse];
}];
}
@end
This might even work, as long as the block is executed immediately and not concurrently, like in the case of -enumerateObjectsUsingBlock. However, if the block is a callback — for example, from API response over the Internet — it’ll usually not return immediately. And before the assertion statemaents are hit, your method ends, so the block is never really tested.
The initiative is to prevent the method from exiting before the callback is hit — prevent the method from exiting, unless explicitly told so — while at the same time not blocking the main thread, so the callbacks made on the main thread would still work.
One way to do this is to create an NSOperation subclass that takes a block, whose parameter is a callback block. Once code in the block is done, it calls the callback block provided by the operation, which mutates the operation’s internal state and make it return appropriate values for -isExecuting and -isFinished:
AsyncOperation *op = [AsyncOperation operationWithBlock: ^ (AsyncOperationCallback callback) {
[something doSomethingWithCompletion: ^ (BOOL didFinish) {
callback();
}];
}];
That’s a simplified version of IRAsyncOperation. (I’m pretty sure there’s other, better stuff ;) Then, put the operation on an implicitly created operation queue, don’t wait until finished:
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
[queue setMaxConcurrentOperationCount:1];
[queue addOperations:[NSArray arrayWithObject:op] waitUntilFinished:NO];
Since every single operation queue implicitly creates threads (the maximum number is defined by maxConcurrentOperationCount, so setting that to 1 guarantees serial execution), it moves the code which generates asynchronous callbacks on another thread for free.
Then, prevent the method from exiting early with a nice Run Loop hack:
while (queue.operationCount)
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:5]];
References
Some notes on threading (Brent Simmons) on Main Thread Gravity, and why it’s nice to always make the callback happen on the main thread.
Concurrency Programming Guide (Apple), just because.
Asynchronous Unit testing (CocoaBuilder): “OCUnit doesn’t run unit tests within a runloop itself. You should be able to run one yourself from within your [tests.]”
非同期で動作する OCUnit (SenTestingKit) を書いてみた (小野 将司) & akisute/SenAsyncTestCase