Skip to content

Instantly share code, notes, and snippets.

@dpfannenstiel
Created March 25, 2022 03:00
Show Gist options
  • Save dpfannenstiel/ec62d6fda058ac04e071e946585ba39d to your computer and use it in GitHub Desktop.
Save dpfannenstiel/ec62d6fda058ac04e071e946585ba39d to your computer and use it in GitHub Desktop.
How to test async code from sync tests.
//
// TestAwaitTests.swift
// TestAwaitTests
//
// Created by Dustin Pfannenstiel on 3/21/22.
//
import XCTest
@testable import TestAwait
/// Testing async functions.
class TestAwaitTests: XCTestCase {
/// An error to be thrown
enum Err: Error { case oops }
/// A type that may be used for testing async functions
struct Thing {
/// Number of nanoseconds to wait
static let nanoseconds: UInt64 = 5_000_000_000
/// Any value to be tested
let value = true
/// Begin an async task that sleeps
/// - returns: Always returns 1, but the contract is for an optional value for testing purposes
func doSomething() async throws -> Int? {
try await Task.sleep(nanoseconds: Self.nanoseconds)
return 1
}
/// Begin an async task that sleeps
/// - returns: Void
func doNothing() async throws {
try await Task.sleep(nanoseconds: Self.nanoseconds)
}
/// Begin an async task that sleeps
/// - returns: Never, but the contract is for an optional Int for testing purposes
/// - throws: Err.oops every time the method is called
func returnThrow() async throws -> Int? {
try await Task.sleep(nanoseconds: Self.nanoseconds)
throw Err.oops
}
/// Begin an async task that sleeps
/// - returns: Never, but the contract is for Void for testing purposes
/// - throws: Err.oops every time the method is called
func doThrow() async throws {
try await Task.sleep(nanoseconds: Self.nanoseconds)
enum Err: Error { case oops }
throw Err.oops
}
}
/// This example is **specifically** trying to demonstrate how to test async function
/// when using continue after failure. Setting the value here will set the value
/// for all tests.
override func setUp() {
super.setUp()
continueAfterFailure = false
}
/// A sample validation function.
/// Tests might pass a `Thing` to this function to validate numerous properties on it
/// in repeated tests.
/// In this case `value` is asserted `false` when it is known to be `true`.
/// Since this example is intended to evaluated `continueAfterFailure`, the assert true
/// should never be called if the tests are working as expected.
func validation(thing: Thing, file: StaticString = #file, line: UInt = #line) {
XCTAssertFalse(thing.value)
XCTAssertTrue(thing.value)
}
/// Baseline describes how async tests function in most example code.
/// The async function runs, executing all assertions. This behavior is believed
/// to be incorrect.
func testBaseline() async throws {
let thing = Thing()
let result = try await thing.doSomething()
print("print 1")
XCTAssertNil(result)
print("print 2")
XCTAssertNotNil(result)
print("print 3")
validation(thing: thing)
throw Err.oops
}
/// This method uses an `awaitWith` function to send a regular method into an async operation.
/// The result is expected to be `1` causing a failure and exit with the first assertion.
func testAwaitWith() throws {
let thing = Thing()
let result = try awaitWith { try await thing.doSomething() }
print("print 1")
XCTAssertNil(result)
print("print 2")
XCTAssertNotNil(result)
print("print 3")
validation(thing: thing)
throw Err.oops
}
/// This method uses an `awaitWith` function to throw before any assertions are executed.
/// The `awaitWith` expects to return a value which is trapped by `_ =`. No assertion is called.
func testAwaitThrow() throws {
let thing = Thing()
_ = try awaitWith { try await thing.returnThrow() }
let result: Int? = 1
print("print 1")
XCTAssertNil(result)
print("print 2")
XCTAssertNotNil(result)
print("print 3")
validation(thing: thing)
throw Err.oops
}
/// An async function that returns a Void calls a different `awaitWith`.
/// A result value is set specifically to confirm that the assertion fails and the function
/// exits correctly despite the asynchronous operation.
func testAwaitNothing() throws {
let thing = Thing()
try awaitWith {
try await thing.doNothing()
}
let result: Int? = 1
print("print 1")
XCTAssertNil(result)
print("print 2")
XCTAssertNotNil(result)
print("print 3")
validation(thing: thing)
throw Err.oops
}
/// An async function that returns a Void calls a different `awaitWith`, but the async function throws.
/// No assertions should be executed.
func testDoThrow() throws {
let thing = Thing()
try awaitWith {
try await thing.doThrow()
}
let result: Int? = 1
print("print 1")
XCTAssertNil(result)
print("print 2")
XCTAssertNotNil(result)
print("print 3")
validation(thing: thing)
throw Err.oops
}
}
/// An extension on XCTestCase to support async testing.
extension XCTestCase {
/// A Void return function that bridges to Swift Concurrency operations.
/// - parameters:
/// - testName: Name of the calling function, will be used to create an expectation
/// - file: Name of the file calling the function, for assertion logging
/// - line: Line where the function was called, for assertion logging
/// - timeout: Duration that the expectation should wait, default is 10 seconds
/// - task: The operation to run asynchronously.
/// - throws: Any error thrown by `task`
func awaitWith(
testName: String = #function,
file: StaticString = #file,
line: UInt = #line,
timeout: TimeInterval = 10,
task: @escaping () async throws -> Void
) throws {
_ = try bridgeTask(testName: testName, file: file, line: line, timeout: timeout, task: task)
}
/// A generically typed return function that bridges to Swift Concurrency operations.
/// - parameters:
/// - testName: Name of the calling function, will be used to create an expectation
/// - file: Name of the file calling the function, for assertion logging
/// - line: Line where the function was called, for assertion logging
/// - timeout: Duration that the expectation should wait, default is 10 seconds
/// - task: The operation to run asynchronously.
/// - returns: The result of `task`
/// - throws: Any error thrown by `task`
func awaitWith<T>(
testName: String = #function,
file: StaticString = #file,
line: UInt = #line,
timeout: TimeInterval = 10,
task: @escaping () async throws -> T
) throws -> T {
try bridgeTask(testName: testName, file: file, line: line, timeout: timeout, task: task)
}
/// A task bridging function to call async code from sync tests
/// - testName: Name of the calling function, will be used to create an expectation
/// - file: Name of the file calling the function, for assertion logging
/// - line: Line where the function was called, for assertion logging
/// - timeout: Duration that the expectation should wait
/// - task: The operation to run asynchronously.
private func bridgeTask<T>(
testName: String,
file: StaticString,
line: UInt,
timeout: TimeInterval,
task: @escaping () async throws -> T
) throws -> T {
var value: T?
var error: Error?
let errorHandler: (Error) -> Void = { error = $0 }
let handler: (T) -> Void = { value = $0 }
// Create an expectation
let expectation = expectation(description: testName)
// Begin a task
Task.detached {
do {
// run the task
let result = try await task()
// store the results in `value`
handler(result)
} catch {
// store any error in `error`
errorHandler(error)
}
// Fulfill the expectation
expectation.fulfill()
}
// Wait for the expectation to resolve or timeout
wait(for: [expectation], timeout: timeout)
// Throw any error
if let error = error {
throw error
}
// Return the result if any
return try XCTUnwrap(value, file: file, line: line)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment