DEV Community

Gualtiero Frigerio
Gualtiero Frigerio

Posted on

Test asynchronous code

One of the advantages of the introduction of async await in Swift 5.5 is the ability to test asynchronous code in a clear and concise way, and even if we’re not ready to introduce async await in our code base, we can at least start adopting it in our tests.
Before you start reading I leave you the links to some of the articles I’ve written before: introduction to async await and create your own AsyncSequence give you an introduction to async await while testing in Xcode is about writing unit tests.
As usual, all the code you see here is available on GitHub, the project is the same as the two previous articles about async await.

Test async functions

Let’s start with async functions. Turns out you can mark the functions inside a XCTestCase as async, so you can use await inside them.
Testing an async function is pretty straightforward: you can await for its return value and call the usual XCT assert functions.

func testGetDataAsync() async {
    let data = await RESTClient.getData(atURL: testURLUsers)
    XCTAssertNotNil(data)
}
Enter fullscreen mode Exit fullscreen mode

That was easy, but we’re not done yet.
As I mentioned at the beginning, we can start adopting async await in our tests, even if the code base is still using completion handler based API or Combine.
Let’s see an example of a completion handler API

func testGetDataCompletion() {
    let expectation = expectation(description: "testGetDataCompletion")
    RESTClient.getDataCompletion(atURL: testURLUsers) { data in
        XCTAssertNotNil(data)
        expectation.fulfill()
    }

    waitForExpectations(timeout: 1.0)
}
Enter fullscreen mode Exit fullscreen mode

to test a completion based API, we need to use an expectation and wait for it with a timeout.
While this usually works, it is not ideal. We need to set a timeout and place waitForExpectations after the asynchronous call, then we can fulfil the expectation inside the closure.
Testing a Combine API requires a similar approach

func testGetDataFuture() {
    let expectation = expectation(description: "testGetDataFuture")
    let publisher = RESTClient.getData(atURL: testURLUsers)
        .catch { error in
            Just<data>(Data())
        }
        .eraseToAnyPublisher()
    publisher.sink { data in
        expectation.fulfill()
    }
    .store(in: &cancellables)

    waitForExpectations(timeout: 1.0)
}
Enter fullscreen mode Exit fullscreen mode

we still need an expectation in order to be able to fulfill it in the sink closure. If the closure isn’t called before the timeout, the test will fail.

Refactor existing tests

All right, we’re not happy with expectation, so let’s see how we can refactor those tests to use async await. There is a way to convert closure based APIs into async await, via the continuation system, and that’s what we’re going to use.

func testGetDataCompletionNoExpectation() async {
    let data: Data? = await withCheckedContinuation { continuation in
        RESTClient.getDataCompletion(atURL: testURLUsers) { data in
            continuation.resume(returning: data)
        }
    }

    XCTAssertNotNil(data)
}
Enter fullscreen mode Exit fullscreen mode

I’ve already described how continuation works in introduction to async await so refer to that article for further details.
In this example I used withCheckedContinuation (there is the throwing version as well) to call a completion handler based api, then I can resume the execution returning the data.
We’re still waiting for the completion handler to be called, but this time we don’t need an expectation with an arbitrary timeout.
We can use the same approach for a Combine API

func testGetUsersNoExpectation() async {
    let dataSource = DataSource(baseURL: baseURLString)
    let users: [User]? = await withUnsafeContinuation { continuation in
        dataSource.getUsersWithMergedData().sink { users in
            continuation.resume(returning: users)
        }
        .store(in: &cancellables)
    }
    XCTAssertNotNil(users)
}
Enter fullscreen mode Exit fullscreen mode

Test an AsyncSequence

In my previous article about AsyncSequence I talked about creating a custom AsyncSequence or use an AsyncStream instead of a custom implementation and this is how to test them

func testPicturesStream() async {
    let picturesTestArray = [pictureToTest]
    let picturesLoader = PicturesLoader(withPictures: picturesTestArray)
    var loaded = 0
    for await picture in picturesLoader.getPicturesStream() {
        XCTAssertNotNil(picture.image)
        loaded += 1
    }
    XCTAssertEqual(loaded, picturesTestArray.count)
}
Enter fullscreen mode Exit fullscreen mode

If you remember, you can iterate via a for loop (or an iterator) through a AsyncSequence or a AsyncStream. The test function has to be marked as async, like in the previous examples, but this time we need to test two things. The first, is that at least the first result is valid via XCTAssertNotNil(picture.image), in the test we’re passing just one image so the for loop will be execute once.
You may wonder why I keeping track of the loaded pictures and check that the count is equal to the length of the array of pictures. In this particular example, the function doesn’t throw. If something goes wrong, not pictures are returned and we reach the end of the for loop. If we don’t keep the count, we don’t know if at least one picture was loaded.

Conclusion

I hope you share my enthusiasm about being able to test asynchronous code with the new async await addition in Swift 5.5
I don’t know about you but I’ve never liked the expectation based tests and the continuation API seems like a much better choice to wait for an asynchronous function to end.
Now, let’s write more test, and happy coding 🙂

Original article

Discussion (0)