The Anatomy of Asynchronous Code Testing in iOS

Asynchronous code testing can be really hard at first. In this article, we’ll dissect this concept to get a deeper understanding of it.

Ammar AlTahhan
HungerStation

--

White thread in a needle splitting into multi-colored threads
Photo by John Anvik on Unsplash

Testing is an essential part of running a high performant tech organization. The ability to apply your changes to a code base and to wait for only a few seconds before pushing it with full confidence and zero worries is priceless. However, having this robust release process is not an easy thing to achieve, and for sure is not a one-time objective to be checked off a to-do list, but rather something that requires constant efforts to stay in that state of confidence all the time.

One big challenge in testing software can be seen in applications relying on asynchronous executions like mobile applications for example, which by its nature is responsive and is always acting in reaction to the user input.
In this article, we’re going to talk about testing asynchronous code, the challenges one might face in writing them, and useful techniques to make testing async code much easier.

Expectations vs Reality 🔮

One of the most repetitive tasks in a mobile application is fetching data from a remote source. Let’s take this code for example and see how we can test it:

Simply trying to test for result’s data inside the completion block isn’t going to work:

Regardless of whether the loader is returning data or not, this test case will always pass, because the assertion line will never get executed.

The straightforward solution would be to use an expectation, to signal to the test runner that we’ll be waiting for an asynchronous callback, and so it should give us sometime before deciding on the test being a success or a failure.

Now the only way for this test to be successful is to pass the assertion and execute the line expectation.fulfill(), which the test runner will wait for a maximum of 1 second. If the timeout is reached without the expectation being fulfilled, the test will fail.

The Law of Inversion ➡️⬅️

In this example, let’s imagine that we’re testing some search functionality, in which the application is querying the server and rendering the search results in real-time while the user is typing.
In such cases, we’d usually use a technique called “debouncing”, where the search request is set as pending until we make sure the user has stopped writing, which is usually determined by a predefined amount of milliseconds with no new characters added (this is done in order to not flood the server with search requests, in case the user was a fast typer).

To test this scenario, we can use an inverted expectation, which is not supposed to be fulfilled, i.e. the test will fail as soon as that happens.

The beauty of this test case is that it has no assertions whatsoever, yet, it’s exactly testing the behavior we want to test. The first search query should be discarded, and the second request should be sent after 0.1 seconds. Pretty neat! 👌🏻

Mocking Injected Dependencies 💉

Another way to go around testing asynchronous implementation is by replacing it with another code executing synchronously.
Let’s take this example of a service class we’re testing, which is using an injected repository object and is waiting for a response to act on it:

What we can do to convert this asynchronous call of an external endpoint to be synchronous while running a test case, is to create a ‘Mock’ implementation of PaymentProcessingRepository that returns whatever data the test is expecting:

And inject it to the service class under testing on initialization:

As you can see, one key step here is to write and design systems with testability in mind, by making sure it is easy to inject dependencies to be able to replace them with different behaviors when needed, like replacing async calls with values returned immediately, as we saw in this example.

The Dispatcher 👮🏻‍♂️

Sometimes we’re forced to use the DispatchQueue APIs to execute tasks away from the main thread, to not block or hinder the user experience. Those dispatched tasks are usually sent to a background (or a custom) queue, after which the execution is sent -most of the time- back to the main queue to update the UI:

If you tried to test such an implementation by Dispatching test assertions to the same queues, you would be creating flaky tests that would almost never succeed two times in a row. Also, waiting for dispatched implementation by dispatched test assertions is only going to prolong the execution time of your supposedly lightning-fast unit tests.

To better inspect flaky tests and catch non-deterministic test failures, you can use “Until failure” Test Repetition Mode in your test plan. Or you can simply right click on the diamond beside the test case you and choose Run “testObservedFlagChanged()” Repeatedly...
For more information, check Diagnose unreliable code with test repetitions WWDC21 session.

We will use a small wrapper around DispatchQueue to test these internal dispatches. This way, we can abstract the concurrent nature of tasks execution, leaving only the type of DispatchQueue (main, background, etc..) to the system under testing to decide on.

Here, we’re defining the base protocol of a Dispatcher interface, which will be injected into models to be tested.
Now we only need two concrete implementations of this protocol, one for asynchronous and one for synchronous executions:

Now we can replace our direct calls to DispatchQueue APIs inside the view model with the asynchronous dispatcher:

And then, we can simply replace the implementation of Dispatcher inside our view model very easily:

As you can see, clean and logical flow of function calls and assertions, and most importantly, no expectations or waits are needed 💪🏼

Here you can find a gist containing the complete implementation of the Dispatcher protocol

Testing Async/Await 🤌🏼

As we’ve seen so far, testing asynchronous code (completion blocks, dispatched work items, and even Combine publishers) is not a straightforward task, mainly because XCTests are always executed serially, line by line.

But with the introduction of async/await in Swift, testing asynchronous and concurrent code is way easier than one can imagine.
Let’s say we have this view model with an asynchronous function calling an API:

To test this function, we can simply mark our test case as async as well:

This was really missed in previous asynchronous implementations. But with the new concurrency APIs in Swift, we can test asynchronous code easily with no need for expectations, timeouts, etc…

We can also reuse async/await APIs to test code with legacy completion blocks in a cleaner way.
Let’s revisit the first example of this article, the DataLoaderTests:

Here we used an expectation with a timeout to make sure the assertion is checked before the test conclusion.
To make this a bit more straightforward in testing, we can use withCheckedContinuation to wrap completion calls with async APIs:

As you can see, we’ve made use of Swift’s concurrency feature to remove the need for expectations and make the test case a little bit more readable.

Conclusion 📝

Asynchronous code is becoming increasingly important for writing responsive applications. But unfortunately, it’s adding an extra layer of complexity to the codebase, including its testability. Testing asynchronous code has always been a daunting task, but with the introduction of async/await in Swift 5.5, it’s now much easier to write and test asynchronous executions.

To learn more about the new Swift’s concurrency system, check out this page from the Swift Book. It’s explaining it in a nice and concise way with clear examples.

I hope this article has given you some ideas on how to test your asynchronous code. If you have any questions, comments, or feedback, feel free to write it down here in the comments section, or reach out to me on Twitter: @atahhan_

Ammar is a Software Engineer on-board since 2019. At HungerStation, Ammar is doing wizardry stuff with his Search and Discovery squad. He’s been as busy as a bee, crafting mobile apps, connecting pipelines to automate things, and working-out some back-end mysteries on the side.

--

--

Ammar AlTahhan
HungerStation

Software Engineer @ HungerStation. I do mobile, infra, web, and everything in between. Known to be occasionally funny. twitter.com/atahhan_