Created
March 25, 2022 03:00
-
-
Save dpfannenstiel/ec62d6fda058ac04e071e946585ba39d to your computer and use it in GitHub Desktop.
How to test async code from sync tests.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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