Testing async/await exceptions
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.
@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!- The method takes one async expression that provides value, one that will provide an error type, and a message.
file
andline
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.- 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!