Skip to content

[REVIEW] Add Async Support to XCTest #326

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Sep 23, 2021
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ xcuserdata
*.xcscmblueprint
.build/
Output/
Tests/Functional/.lit_test_times.txt
46 changes: 46 additions & 0 deletions Sources/XCTest/Private/XCTestCase.TearDownBlocksState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See http://swift.org/LICENSE.txt for license information
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
// XCTestCase.TeardownBlocksState
// A class which encapsulates teardown blocks which are registered via the `addTearDownBlock(_block:)` method.
// Supports async and sync throwing methods

extension XCTestCase {
final class TeardownBlocksState {

private var wasFinalized = false
private var blocks: [() throws -> Void] = []

@available(macOS 12, *)

// We don't want to overload append(_:) below because of how Swift will implicitly promote sync closures to async closures,
// which can unexpectedly change their semantics in difficult to track down ways.
//
// Because of this, we chose the unusual decision to forgo overloading (which is a super sweet language feature <3) to prevent this issue from surprising any contributors to corelibs-xctest
func appendAsync(_ block: @Sendable @escaping () async throws -> Void) {
self.append {
try awaitUsingExpectation { try await block() }
}
}

func append(_ block: @escaping () throws -> Void) {
XCTWaiter.subsystemQueue.sync {
precondition(wasFinalized == false, "API violation -- attempting to add a teardown block after teardown blocks have been dequeued")
blocks.append(block)
}
}

func finalize() -> [() throws -> Void] {
XCTWaiter.subsystemQueue.sync {
precondition(wasFinalized == false, "API violation -- attempting to run teardown blocks after they've already run")
wasFinalized = true
return blocks
}
}
}
}
9 changes: 9 additions & 0 deletions Sources/XCTest/Public/XCAbstractTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ open class XCTest {
perform(testRun!)
}

/// Async setup method called before the invocation of `setUp` for each test method in the class.
@available(macOS 12.0, *)
open func setUp() async throws {}

/// Setup method called before the invocation of `setUp` and the test method
/// for each test method in the class.
open func setUpWithError() throws {}
Expand All @@ -68,6 +72,11 @@ open class XCTest {
/// for each test method in the class.
open func tearDownWithError() throws {}

/// Async teardown method which is called after the invocation of `tearDownWithError`
/// for each test method in the class.
@available(macOS 12.0, *)
open func tearDown() async throws {}

// FIXME: This initializer is required due to a Swift compiler bug on Linux.
// It should be removed once the bug is fixed.
public init() {}
Expand Down
141 changes: 112 additions & 29 deletions Sources/XCTest/Public/XCTestCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ open class XCTestCase: XCTest {
private let testClosure: XCTestCaseClosure

private var skip: XCTSkip?
private let teardownBlocksState = TeardownBlocksState()

/// The name of the test case, consisting of its class name and the method
/// name it will run.
Expand Down Expand Up @@ -194,26 +195,24 @@ open class XCTestCase: XCTest {
/// Teardown method called after the invocation of every test method in the
/// class.
open class func tearDown() {}

private var teardownBlocks: [() -> Void] = []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice to have better encapsulation of this now!

private var teardownBlocksDequeued: Bool = false
private let teardownBlocksQueue: DispatchQueue = DispatchQueue(label: "org.swift.XCTest.XCTestCase.teardownBlocks")


/// Registers a block of teardown code to be run after the current test
/// method ends.
open func addTeardownBlock(_ block: @escaping () -> Void) {
teardownBlocksQueue.sync {
precondition(!self.teardownBlocksDequeued, "API violation -- attempting to add a teardown block after teardown blocks have been dequeued")
self.teardownBlocks.append(block)
}
teardownBlocksState.append(block)
}

/// Registers a block of teardown code to be run after the current test
/// method ends.
@available(macOS 12.0, *)
open func addTeardownBlock(_ block: @Sendable @escaping () async throws -> Void) {
teardownBlocksState.appendAsync(block)
}

private func performSetUpSequence() {
do {
try setUpWithError()
} catch {
func handleErrorDuringSetUp(_ error: Error) {
if error.xct_shouldRecordAsTestFailure {
recordFailure(for: error)
self.recordFailure(for: error)
}

if error.xct_shouldSkipTestInvocation {
Expand All @@ -225,33 +224,61 @@ open class XCTestCase: XCTest {
}
}

setUp()
}
do {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is our behavior here matching Xcode XCTest, in terms of what additional steps of the test we still run and what we report if an async setUp override:

  • throws XCTSkip?
  • throws some other error?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe it should be handling these correctly, due to the following two guards above in handleErrorDuringSetUp(_:):

if error.xct_shouldRecordAsTestFailure { and
if error.xct_shouldSkipTestInvocation {

if #available(macOS 12.0, *) {
try awaitUsingExpectation {
try await self.setUp()
}
}
} catch {
handleErrorDuringSetUp(error)
}

do {
try self.setUpWithError()
} catch {
handleErrorDuringSetUp(error)
}

self.setUp()
}


private func performTearDownSequence() {
func handleErrorDuringTearDown(_ error: Error) {
if error.xct_shouldRecordAsTestFailure {
recordFailure(for: error)
}
}

func runTeardownBlocks() {
for block in self.teardownBlocksState.finalize().reversed() {
do {
try block()
} catch {
handleErrorDuringTearDown(error)
}
}
}

runTeardownBlocks()

tearDown()

do {
try tearDownWithError()
} catch {
if error.xct_shouldRecordAsTestFailure {
recordFailure(for: error)
}
handleErrorDuringTearDown(error)
}
}

private func runTeardownBlocks() {
let blocks = teardownBlocksQueue.sync { () -> [() -> Void] in
self.teardownBlocksDequeued = true
let blocks = self.teardownBlocks
self.teardownBlocks = []
return blocks
}

for block in blocks.reversed() {
block()
do {
if #available(macOS 12.0, *) {
try awaitUsingExpectation {
try await self.tearDown()
}
}
} catch {
handleErrorDuringTearDown(error)
}
}

Expand Down Expand Up @@ -292,3 +319,59 @@ private func test<T: XCTestCase>(_ testFunc: @escaping (T) -> () throws -> Void)
try testFunc(testCase)()
}
}

@available(macOS 12.0, *)
public func asyncTest<T: XCTestCase>(
_ testClosureGenerator: @escaping (T) -> () async throws -> Void
) -> (T) -> () throws -> Void {
return { (testType: T) in
let testClosure = testClosureGenerator(testType)
return {
try awaitUsingExpectation(testClosure)
}
}
}

@available(macOS 12.0, *)
func awaitUsingExpectation(
_ closure: @escaping () async throws -> Void
) throws -> Void {
let expectation = XCTestExpectation(description: "async test completion")
let thrownErrorWrapper = ThrownErrorWrapper()

Task {
defer { expectation.fulfill() }

do {
try await closure()
} catch {
thrownErrorWrapper.error = error
}
}

_ = XCTWaiter.wait(for: [expectation], timeout: asyncTestTimeout)

if let error = thrownErrorWrapper.error {
throw error
}
}

private final class ThrownErrorWrapper: @unchecked Sendable {

private var _error: Error?

var error: Error? {
get {
XCTWaiter.subsystemQueue.sync { _error }
}
set {
XCTWaiter.subsystemQueue.sync { _error = newValue }
}
}
}


// This time interval is set to a very large value due to their being no real native timeout functionality within corelibs-xctest.
// With the introduction of async/await support, the framework now relies on XCTestExpectations internally to coordinate the addition async portions of setup and tear down.
// This time interval is the timeout corelibs-xctest uses with XCTestExpectations.
private let asyncTestTimeout: TimeInterval = 60 * 60 * 24 * 30
Loading