Unit Testing on iOS With Async/Await
In this blog
Asynchronous programming is an important and common concept in many languages and platforms. iOS development is no exception, and we commonly use async operations when interacting with servers, data storage and even updating the UI.
Despite how frequently we use these concepts, writing asynchronous code can be a frustrating and difficult experience. Not only is it an area prone to bugs, but these bugs can also be incredibly difficult to locate and fix. In the WWDC 2021 talk, "Meet async/await in Swift," they mention a scenario where a completion handler is never called which leads to a bug in certain execution paths.
What is async/await?
The Swift team debuted the new structured concurrency model, which includes the async/await pattern. Many other programming languages use this pattern, and it has been tested since its inception around 2011 in C# 5.0. Swift has the advantage of being a newer language to learn from other languages and adopt the best features for Swift developers.
In lieu of complicated nested completions or large chains of publishers, we now have two new keywords to simplify our asynchronous programming, async and await. The beauty of async/await in Swift is that it is built directly in to be used with other language keywords.
Note: Currently, async/await is only available with an iOS 15 deployment target, and this requires the Xcode 13 beta.
How do we use it?
Async/await can be used in lieu of completion handlers in many instances, and it will replace some of the applications of Combine (e.g. restful network calls). Async/await makes reasoning about asynchronous code much easier. For instance, think about performing synchronous network calls where one relies on the other. Now think about adding yet another call that relies on both of the previous responses. With completion handlers, this is a complicated problem, and it is often difficult to reason about your code after the fact. With async/await, the code reads just like standard procedural code and is very easy to digest comparatively.
The code for this article is located at https://github.com/wwt/testing-async-await/.
Now that we understand some of the use cases, consider the following example.
struct Repository: Decodable {
let name: String
}
struct NetworkClient {
static func repos(from urlString: String) async throws -> [Repository] {
guard let url = URL(string: urlString) else {
throw NSError(domain: "invalid url", code: -1)
}
let request = URLRequest(url: url)
let (data, _) = try await URLSession.shared.data(for: request)
let repos = try JSONDecoder().decode([Repository].self, from: data)
return repos
}
}
1. We throw an error here for testing. In a real application, we would propagate the error and surface as necessary.
2. We omit the second parameter, which is the URL response.
This code creates a URLRequest
with Apple's GitHub repository page; fetches the list of repositories; then decodes the data to list the names. Take a moment to appreciate how much simpler this code is with async/await compared to using completion handlers with the dataTask(with:)
method. Now that we have our simple client, let's explore what testing our client looks like.
Testing
Testing with async/await is much simpler than using completion handlers. We do not need to construct an XCTestExpectation
and wait for it to complete. We can simply await the result of a method call or async let
binding and make our assertions on the result.
Setup
Before we get into the actual tests, we are going to add an external framework to help with mocking network calls. We do not want to make real network calls in our tests so that we can isolate and test the behavior of our code rather than introducing variability in network connectivity. Add OHHTTPStubs
using your package manager of choice; we used Swift Package Manager for this project. The following code provides a fluent interface to create a StubAPIResponse
and allows us to control the response when given a particular URL.
import Foundation
import OHHTTPStubs
import OHHTTPStubsSwift
fileprivate extension Array {
mutating func popLastUnlessEmpty() -> Element? {
if count > 1 {
return popLast()
} else {
return last
}
}
}
public func matchesRequest(_ request: URLRequest) -> HTTPStubsTestBlock {
return { req in req.url?.absoluteString == request.url?.absoluteString
&& req.httpMethod == request.httpMethod }
}
class StubAPIResponse {
var results = [URLRequest: [Result<Data, Error>]]()
var responses = [URLRequest: [HTTPURLResponse]]()
var verifiers = [URLRequest: [((URLRequest) -> Void)]]()
var requests = [URLRequest]()
@discardableResult init(request: URLRequest,
statusCode: Int,
result: Result<Data, Error> = .success(Data()),
headers: [String: String]? = nil) {
thenRespondWith(request: request,
statusCode: statusCode, result: result,
headers: headers)
}
@discardableResult func thenRespondWith(request: URLRequest,
statusCode: Int,
result: Result<Data, Error> = .success(Data()),
headers: [String: String]? = nil) -> Self {
guard let url = request.url else { return self }
defer { requests.append(request) }
results[request, default: []].insert(result, at: 0)
responses[request, default: []].insert(HTTPURLResponse(url: url,
statusCode: statusCode,
httpVersion: "2.0",
headerFields: headers)!, at: 0)
verifiers[request, default: []].insert({ _ in }, at: 0)
guard !requests.contains(where: matchesRequest(request)) else { return self }
stub(condition: matchesRequest(request)) { [self] in
verifiers[request]?.popLastUnlessEmpty()?($0)
let response = responses[request]!.popLastUnlessEmpty()!
let result = results[request]!.popLastUnlessEmpty()!
switch result {
case .failure(let err): return HTTPStubsResponse(error: err)
case .success(let data): return HTTPStubsResponse(data: data,
statusCode: Int32(response.statusCode),
headers: response.allHeaderFields)
}
}
return self
}
@discardableResult func thenVerifyRequest(_ requestVerifier:@escaping ((URLRequest) -> Void)) -> Self {
guard let request = requests.last else { return self }
verifiers[request]?[0] = requestVerifier
return self
}
}
Additionally, we created a simple way to initialize a URLRequest
.
import Foundation
extension URLRequest {
enum HTTPMethod {
case get
case put
case post
case patch
case delete
}
init(_ method: HTTPMethod, urlString: String) {
let url = URL(string: urlString)
self.init(url: url!)
httpMethod = "\(method)".uppercased()
}
}
Now with the ability to mock our network request and response, we can begin testing.
Implementation
import XCTest
@testable import TestingAsyncAwait
final class NetworkClientTests: XCTestCase {
func testReposReturnsResult() async throws {
let urlString = "https://api.github.com/orgs/apple/repos"
let expectedRepos = [
Repository(name: "swift"),
Repository(name: "llvm-project"),
Repository(name: "swift-driver")
]
let data = try JSONEncoder().encode(expectedRepos)
StubAPIResponse(
request: .init(.get, urlString: urlString),
statusCode: 200,
result: .success(data)
).thenVerifyRequest {
XCTAssertEqual($0.url?.absoluteString, urlString)
}
let repos = try await NetworkClient.repos(from: urlString)
XCTAssertEqual(repos, expectedRepos)
}
}
This is all the code that is needed to test that the repos()
function returns what we expect from our mock network response.
We mark the test function as async throws
in order to avoid creating an async task explicitly, which would not work in this case because we want the test to await the result before performing assertions. To confirm this, remove the async keyword from the function declaration and wrap the test code inside of an async { }
block. Put a breakpoint on the assertion, and you'll notice that it is never called despite the test passing. False positives like this can be dangerous as you're not actually testing your code appropriately.
Note the usage of StubAPIResponse
to respond with the expectedRepos
when a network call is made with the appropriate request using urlString.
We bind the repos
variable to the result of the NetworkClient.repos()
call. It waits until the function returns a result or throws and interrupts execution. Once we receive the result, we assert that the array is not empty.
Now that we've tested the success case, let's test that the method throws an error when we give it a non-existent URL.
func testReposThrows() async throws {
let urlString = "https://api.github.com/notgoingtowork"
StubAPIResponse(
request: .init(.get, urlString: urlString),
statusCode: 404,
result: .failure(NSError(domain: "url not found", code: -1))
).thenVerifyRequest {
XCTAssertEqual($0.url?.absoluteString, urlString)
}
do {
_ = try await NetworkClient.repos(from: urlString)
XCTFail("This call should throw an error.")
} catch let error as NSError {
XCTAssertEqual(error.domain, "invalid url")
XCTAssertEqual(error.code, -1)
}
}
Unfortunately, this is the current solution to testing the error case as wrapping the entire call with XCTAssertThrowsError
does not compile. The following error results:
'async' call in an autoclosure that does not support concurrency.
When Apple supports concurrency in XCTAssert
, we can use the following concise syntax (or something very similar).
XCTAssertThrowsError(try await NetworkClient.repos(from: "fake url"))
In the Meet async/await in Swift WWDC 2021 session, Apple uses very similar syntax to above, so it is only a matter of time until external developers can do the same. If you would like to use similar syntax, for the time being, you can roll your own as follows.
extension XCTest {
func XCTAssertThrowsError<T: Sendable>(
_ expression: @autoclosure () async throws -> T,
_ message: @autoclosure () -> String = "",
file: StaticString = #filePath,
line: UInt = #line,
_ errorHandler: (_ error: Error) -> Void = { _ in }
) async {
do {
_ = try await expression()
XCTFail(message(), file: file, line: line)
} catch {
errorHandler(error)
}
}
}
The call side of this function has an extra await in front due to the async keyword. With our new assertion helper, the previous test is now as follows.
func testReposThrows() async throws {
let urlString = "https://api.github.com/notgoingtowork"
StubAPIResponse(
request: .init(.get, urlString: urlString),
statusCode: 404,
result: .failure(NSError(domain: "url not found", code: -1))
).thenVerifyRequest {
XCTAssertEqual($0.url?.absoluteString, urlString)
}
await XCTAssertThrowsError(try await NetworkClient.repos(from: urlString))
}
More on testing with structured concurrency
Apple introduced additional constructs with structured concurrency this year, and those include Tasks, Task Groups, async let bindings and Continuations. Thankfully, none of these require a large amount of setup to test.
- Testing a function with a
Task
is as simple as testing the async/await function we reviewed earlier. You simply await the result and make assertions. - Testing a
Task Group
is very similar as well, and it follows the same awaiting the result and making assertions pattern. async let
bindings can be tested the same way.Continuation
s are amazing ways to wrap closures to be used with async/await. Therefore, they can be tested the same way as async/await.
This is a very simplified overview of each of these constructs, and you should do your own research to determine the specific use cases that make sense for your team and project. Apple has done a great job in ensuring that testing comes easy with the addition of structured concurrency.
Wrapping up
Async/await is an incredible new language feature that will forever change how we write asynchronous code. It cleans up our code by removing many completion handlers, and it makes testing much simpler by removing expectations. In the future, we expect to see Apple supporting structured concurrency in many APIs where it does not currently exist.