How to Use iOS Expectations to Test Async Functions Without a Callback Method

Development iOS

Apple's testing framework has made great strides in recent years. It has become mature to the point where test-driven development (TDD) is not just feasible, but enjoyable. The introduction of expectations solved one of the biggest obstacles when it came to testing: async operations. Follow along as we talk about the common use case for async methods with completion blocks (aka XCTestExpectation). We'll also look at how we can use XCTestExpectation to test async processes that don’t have a callback method.

How Expectations Improve TDD

Prior to expectations, testing any code that had an async component to it either required a webwork of code that itself probably needed testing, or more sanely, the use of a third-party testing library (e.g. Kiwi, specta). Though expectations were late to the party, those genius developers over at Apple added them in a graceful, intuitive way that made them seem like they had been a part of the test family for generations.

How graceful? Let’s take this snippet for example:

[MyAPIHandler queryAPI:queryString completionHandler:^(NSArray *)resultsArray {
    XCTAssert(resultsArray.count > 0, @"Query method returns a populated array."
}];

The result from the above will be worse than the test failing, as the test method will exit even though there’s a completion handler with an assert that will tell you that the test has passed and everything’s cool. Using expectations, however, we just add a few lines of code:

XCTestExpectation *expectation = [self expectationWithDescription:@"Query timed out."];

[MyAPIHandler queryAPI:queryString completionHandler:^(NSArray *)resultsArray {
    XCTAssert(resultsArray.count > 0, @"Query method returns a populated array."
    [expectation fulfill];
}];

[self waitForExpectationsWithTimeout:5.0 handler:^(NSError *error) {
    if (error) {
      NSLog(@"Error: %@", error);
    }
}];

With the expectation set on the first line, we can safely run our method. When we receive our response in the completion handler, our assertion is tested and we call fulfill on the expectation. Until fulfill is called the test method is considered finished, so our method has plenty of time to launch its query and return an array. Then we hit our assertion and call fulfill whether the assertion is true or not.

What if the query hangs there? Isn’t that...

  • A failing test?
  • Going to hold up the rest of the tests?

Well, that’s what our last little bit of code does. waitForExpectationsWithTimeOut:handler: does exactly as its overly verbose, yet on the nose name implies. Pass it a double and it’ll wait that many seconds for the expectation to be fulfilled. If that time (in this case, five seconds) passes, you’ll get a failure message letting you know the expectation timed out.

Modern apps have become network dependent, lightning fast UI beasts, which means a lot of the grunt work is done asynchronously - either waiting for a network response, or shoved to a background thread. Expectations offer an intuitive way to test these methods, while giving you one less reason to avoid building tests into your apps.

How to Test Async Functions Without a Callback Method

What if I didn't bring a completion handler to this testing party? Expectations work perfectly for async functions that have a completion handler of some type. That's not quite the case for those async functions that don’t have blocks. How can we test these without resorting to adding a block just for the purpose of testing?

We could, of course, go back to the DDBE (Dreary Days Before Expectations) and leverage semaphores or dispatch groups to solve this. But then we’d be moving away from the “Joy of TDD” to the overly complex tests we've been trying to avoid from the beginning. Well, fear not! Put Grand Central Dispatch back in its cave and sit around the warm glow of expectations yet again!

Let’s say we have a data source class for our table view whose init kicks off a method that queries our remote db and populates an array with the results. We want to test that, after calling init the resulting object’s array is populated, but we don’t want to test that right after calling init. So:

DataSource *dataSource = [[DataSource alloc] init];
[dataSource loadData];
XCTestExpectation *expectation = [self expectationWithDescription:@"Dummy expectation"];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        XCTAssert(dataSource.dataArray.count > 0, @"Data source has populated array after initializing and, you know, giving it some time to breath, man.");
        [expectation fulfill];
        });
[self waitForExpectationsWithTimeout:20.0 handler:nil];

By combining an expectation with dispatch_after, you have a way to apply tests to a method or property that relies on private async operations without needing to expose methods or jerry-rig a completion handler just for testing purposes.

Concluding Note

The team at Apple has done a great job in making expectations an intuitive way to test async operations. Integrating them into your testing process is painless, while those who are just starting out with testing will probably assume that XCTestExpectation has been there since day one.

Jaz is an app developer who enjoys creating tasteful interactions, fleets of world-dominating bots, and....puns.

You made it this far so...