Take away the ugliness of expectations for dealing with completions using async-like helpers and lambda functions.
I wish async
were ready in Swift.
OK, it’s ready as of Swift 5.5! But only for iOS 15 (at the time of writing), which doesn’t really help much for most app development, since at this point you should probably still support at least one version previous.
Completion handlers are just too ugly — they lead to “pyramids of doom”, and worse a lot of error handling that would be better handled by throwing errors.
For writing unit tests in XCTest
, there is at least some good news — you can clean up all those completions with a few simple helper functions.
Let’s say you have some function that does some mysterious async task, like reading from a database:
It’s hello world
, in two parts. And now you write a unit test for it. The usual way to do it is using expectations. You create anXCTestExpectation
, you make the database call and fulfill the expectation when it is successful, with some timeout — like this:
This is OK…ish:
XCTAssertEqual
calls.In the spirit of nice and flat async
calls, let’s unwrap it:
Note that theXCTAssertEqual
is not in the completion handler— this again puts you on the path of the pyramid of doom, with lots of nested async calls. So I prefer to have one expectation for each async function, so the hierarchy of function calls stays nice and flat.
This is on the right path, but it’s way too long. So let’s clean it up with a convenient helper function:
This is a pretty neat generic/template function. It takes as first argument any function which has a completion handler that returns a single object T
. It creates the expectation, calls the async function, steals the result from the completion handler, and tries to give it back to you.
Use it like this:
That is really slick and short! It reads more like the await
call for an async
function.
You can also specify the timeout with the second parameter for calls that take longer.
Most functions you have also take arguments. So how do those work in this case?
Enter lambda functions — for every such database call, before calling our asynclike_obj
method, create a lambda function to pass as the argument. For example:
A little less clean than when we don’t have arguments at all, but still much nicer than working with expectations and a nested hierarchy in our tests.
This is probably the biggest deal — Result
are the most common way to pass errors out of completion handlers:
But… they require handling the results in a switch case. And that is just. too. much. effort.
Enter a convenient helper:
This is very slick — the last line tries to get the object that’s passed in case of success
, or otherwise failure
gets stuck there.
Here’s how to use it:
As a final note, completions that just return void could be handled with the asynclike_obj
helper method. However, they can also be cleaned up a little into a non-throwing version:
Happy testing! Can’t wait for the age of async
to really take over.
Oliver K. Ernst
November 12, 2021