Testing async/await exceptions

Photo by Andrew Neel / Unsplash

We all know how testing your code is important for stable releases and early bug detection. We should test happy paths, corner cases, and thrown errors.

Old school

Before async/await coroutines were introduced in Swift, we had to use expectations to test asynchronous code.

Apple provides an excellent tool for testing throwing functions -
XCTAssertThrowsError(expression:). This nifty method will fail the unit test IF the error was not thrown.

With iOS 8 we got the next iteration of error handling option -
@frozen enum Result<Success, Failure> where Failure : Error, with this in place, we can just use the switch in the unit test code and assert if a proper error is thrown.

New school

Fast forward a couple of years, and Apple introduces coroutines! This fancy async/await way of working with a multithreaded environment took our community by storm! No more "Piramide of doom" code with completion blocks inside completion blocks, and so on.

We have to test our new code. The testing happy path is pretty straightforward. We can mark the test method as async, await for expected value, and assert it. For error throwing, we didn't get XCTAssertThrowsError async. This is why I implemented it for you!

import Foundation
import XCTest

func XCTAssertThrowsErrorAsync<T, R>(
    _ expression: @autoclosure () async throws -> T,
    _ errorThrown: @autoclosure () -> R,
    _ message: @autoclosure () -> String = "This method should fail",
    file: StaticString = #filePath,
    line: UInt = #line
) async where R: Comparable, R: Error  {
    do {
        let _ = try await expression()
        XCTFail(message(), file: file, line: line)
    } catch {
        XCTAssertEqual(error as? R, errorThrown())
    }
}

This couple of lines can look intimidating but don't worry, I'll go line by line.

  1. @autoclosure the annotation will wrap any expression passed as a parameter to closure. This allows us to evaluate value when it is needed, not when the method is called. This means we have control!
  2. The method takes one async expression that provides value, one that will provide an error type, and a message.
  3. file and line have a default value - macros providing line and file name, this will help Xcode to provide failure feedback in the proper place in the code.
  4. The functions body uses simple do catch flow to test code. We await for value, and if the code doesn't throw an error, failure occurs. If the code throws an error, we check if the error matches our expectations.

Usage

Testing errors is now easy:

func testIfMyCodeThrows() async throws {
        await XCTAssertThrowsErrorAsync(
            try await myThrowingFunc(),
            Error.myExpectedError
        )
    }

Usage mimics all other XCTest assertion, so it fit right in!

Conclusions

This simple method will reduce duplicated code in your codebase, I hope you like it!

Artur Gruchała

Artur Gruchała

I started learning iOS development when Swift was introduced. Since then I've tried Xamarin, Flutter, and React Native. Nothing is better than native code:)
Poland