Skip to content

Commit b74b5ee

Browse files
committed
init
0 parents  commit b74b5ee

8 files changed

Lines changed: 372 additions & 0 deletions

File tree

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
/*.xcodeproj
5+
xcuserdata/
6+
DerivedData/
7+
.swiftpm/config/registries.json
8+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
9+
.netrc
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>IDEDidComputeMac32BitWarning</key>
6+
<true/>
7+
</dict>
8+
</plist>
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<Scheme
3+
LastUpgradeVersion = "1400"
4+
version = "1.3">
5+
<BuildAction
6+
parallelizeBuildables = "YES"
7+
buildImplicitDependencies = "YES">
8+
<BuildActionEntries>
9+
<BuildActionEntry
10+
buildForTesting = "YES"
11+
buildForRunning = "YES"
12+
buildForProfiling = "YES"
13+
buildForArchiving = "YES"
14+
buildForAnalyzing = "YES">
15+
<BuildableReference
16+
BuildableIdentifier = "primary"
17+
BlueprintIdentifier = "AsyncTesting"
18+
BuildableName = "AsyncTesting"
19+
BlueprintName = "AsyncTesting"
20+
ReferencedContainer = "container:">
21+
</BuildableReference>
22+
</BuildActionEntry>
23+
<BuildActionEntry
24+
buildForTesting = "YES"
25+
buildForRunning = "YES"
26+
buildForProfiling = "NO"
27+
buildForArchiving = "NO"
28+
buildForAnalyzing = "YES">
29+
<BuildableReference
30+
BuildableIdentifier = "primary"
31+
BlueprintIdentifier = "AsyncTestingTests"
32+
BuildableName = "AsyncTestingTests"
33+
BlueprintName = "AsyncTestingTests"
34+
ReferencedContainer = "container:">
35+
</BuildableReference>
36+
</BuildActionEntry>
37+
</BuildActionEntries>
38+
</BuildAction>
39+
<TestAction
40+
buildConfiguration = "Debug"
41+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
42+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
43+
shouldUseLaunchSchemeArgsEnv = "YES"
44+
codeCoverageEnabled = "YES">
45+
<Testables>
46+
<TestableReference
47+
skipped = "NO"
48+
parallelizable = "YES"
49+
testExecutionOrdering = "random">
50+
<BuildableReference
51+
BuildableIdentifier = "primary"
52+
BlueprintIdentifier = "AsyncTestingTests"
53+
BuildableName = "AsyncTestingTests"
54+
BlueprintName = "AsyncTestingTests"
55+
ReferencedContainer = "container:">
56+
</BuildableReference>
57+
</TestableReference>
58+
</Testables>
59+
</TestAction>
60+
<LaunchAction
61+
buildConfiguration = "Debug"
62+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
63+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
64+
launchStyle = "0"
65+
useCustomWorkingDirectory = "NO"
66+
ignoresPersistentStateOnLaunch = "NO"
67+
debugDocumentVersioning = "YES"
68+
debugServiceExtension = "internal"
69+
allowLocationSimulation = "YES">
70+
</LaunchAction>
71+
<ProfileAction
72+
buildConfiguration = "Release"
73+
shouldUseLaunchSchemeArgsEnv = "YES"
74+
savedToolIdentifier = ""
75+
useCustomWorkingDirectory = "NO"
76+
debugDocumentVersioning = "YES">
77+
<MacroExpansion>
78+
<BuildableReference
79+
BuildableIdentifier = "primary"
80+
BlueprintIdentifier = "AsyncTesting"
81+
BuildableName = "AsyncTesting"
82+
BlueprintName = "AsyncTesting"
83+
ReferencedContainer = "container:">
84+
</BuildableReference>
85+
</MacroExpansion>
86+
</ProfileAction>
87+
<AnalyzeAction
88+
buildConfiguration = "Debug">
89+
</AnalyzeAction>
90+
<ArchiveAction
91+
buildConfiguration = "Release"
92+
revealArchiveInOrganizer = "YES">
93+
</ArchiveAction>
94+
</Scheme>

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2022 Brennan Stehling
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Package.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// swift-tools-version: 5.7
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "AsyncTesting",
8+
platforms: [.macOS(.v12), .iOS(.v13), .tvOS(.v13), .watchOS(.v6)],
9+
products: [
10+
.library(
11+
name: "AsyncTesting",
12+
targets: ["AsyncTesting"]),
13+
],
14+
dependencies: [
15+
],
16+
targets: [
17+
.target(
18+
name: "AsyncTesting",
19+
dependencies: [],
20+
linkerSettings: [.linkedFramework("XCTest")]
21+
),
22+
.testTarget(
23+
name: "AsyncTestingTests",
24+
dependencies: ["AsyncTesting"]),
25+
]
26+
)

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# AsyncTesting
2+
3+
Xcode includes XCTest which only has [waitForExpectations(timeout:handler:)] to support async tests. It does not support waiting on a given list of expectations which is necessary for more complex async activity. The original [wait(for:timeout:enforceOrder:)] function does take a list of expectations but is not compatible with modern Swift Concurrency.
4+
5+
The `AsyncExpectation` type in this package supports more feautures for more complex use cases. See the unit tests for reference code.
6+
7+
[waitForExpectations(timeout:handler:)]: https://developer.apple.com/documentation/xctest/xctestcase/1500748-waitforexpectations
8+
[wait(for:timeout:enforceOrder:)]: https://developer.apple.com/documentation/xctest/xctestcase/2806857-wait
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import Foundation
2+
import XCTest
3+
4+
extension Task where Success == Never, Failure == Never {
5+
static func sleep(seconds: Double) async throws {
6+
let nanoseconds = UInt64(seconds * Double(NSEC_PER_SEC))
7+
try await Task.sleep(nanoseconds: nanoseconds)
8+
}
9+
}
10+
11+
public actor AsyncExpectation {
12+
enum State {
13+
case pending
14+
case fulfilled
15+
case timedOut
16+
}
17+
public typealias AsyncExpectationContinuation = CheckedContinuation<Void, Error>
18+
public let expectationDescription: String
19+
public var isInverted: Bool
20+
public var expectedFulfillmentCount: Int
21+
22+
private var fulfillmentCount: Int = 0
23+
private var continuation: AsyncExpectationContinuation?
24+
private var state: State = .pending
25+
26+
private var satisified: Bool {
27+
fulfillmentCount == expectedFulfillmentCount
28+
}
29+
30+
public init(description: String,
31+
isInverted: Bool = false,
32+
expectedFulfillmentCount: Int = 1) {
33+
expectationDescription = description
34+
self.isInverted = isInverted
35+
self.expectedFulfillmentCount = expectedFulfillmentCount
36+
}
37+
38+
public static func expectation(description: String,
39+
isInverted: Bool = false,
40+
expectedFulfillmentCount: Int = 1) -> AsyncExpectation {
41+
AsyncExpectation(description: description,
42+
isInverted: isInverted,
43+
expectedFulfillmentCount: expectedFulfillmentCount)
44+
}
45+
46+
public func fulfill(file: StaticString = #filePath, line: UInt = #line) {
47+
guard state != .fulfilled else { return }
48+
49+
50+
guard !isInverted else {
51+
XCTFail("Inverted expectation fulfilled: \(expectationDescription)", file: file, line: line)
52+
finish()
53+
return
54+
}
55+
56+
fulfillmentCount += 1
57+
if fulfillmentCount == expectedFulfillmentCount {
58+
state = .fulfilled
59+
}
60+
}
61+
62+
@MainActor
63+
public static func waitForExpectations(_ expectations: [AsyncExpectation],
64+
timeout: Double = 1.0,
65+
file: StaticString = #filePath,
66+
line: UInt = #line) async throws {
67+
guard !expectations.isEmpty else { return }
68+
69+
// check if all expectations are already satisfied and skip sleeping
70+
var count = 0
71+
for exp in expectations {
72+
if await exp.satisified {
73+
count += 1
74+
}
75+
}
76+
if count == expectations.count {
77+
return
78+
}
79+
80+
let timeout = Task {
81+
try await Task.sleep(seconds: timeout)
82+
for exp in expectations {
83+
await exp.timeOut(file: file, line: line)
84+
}
85+
}
86+
87+
await withThrowingTaskGroup(of: Void.self) { group in
88+
for exp in expectations {
89+
group.addTask {
90+
try await exp.wait()
91+
}
92+
}
93+
}
94+
95+
timeout.cancel()
96+
}
97+
98+
private func wait() async throws {
99+
try await withTaskCancellationHandler(handler: {
100+
Task {
101+
await cancel()
102+
}
103+
}, operation: {
104+
try await withCheckedThrowingContinuation { (continuation: AsyncExpectationContinuation) in
105+
self.continuation = continuation
106+
}
107+
})
108+
}
109+
110+
private func timeOut(file: StaticString = #filePath,
111+
line: UInt = #line) async {
112+
if !satisified && !isInverted {
113+
state = .timedOut
114+
XCTFail("Expectation timed out: \(expectationDescription)", file: file, line: line)
115+
}
116+
finish()
117+
}
118+
119+
private func cancel() {
120+
continuation?.resume(throwing: CancellationError())
121+
continuation = nil
122+
}
123+
124+
private func finish() {
125+
continuation?.resume(returning: ())
126+
continuation = nil
127+
}
128+
129+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import XCTest
2+
@testable import AsyncTesting
3+
4+
final class AsyncExpectationTests: XCTestCase {
5+
6+
func testDoneExpectation() async throws {
7+
let done = AsyncExpectation.expectation(description: "done")
8+
Task {
9+
try await Task.sleep(seconds: 0.1)
10+
await done.fulfill()
11+
}
12+
try await AsyncExpectation.waitForExpectations([done])
13+
}
14+
15+
func testDoneMultipleTimesExpectation() async throws {
16+
let done = AsyncExpectation.expectation(description: "done", expectedFulfillmentCount: 3)
17+
Task {
18+
try await Task.sleep(seconds: 0.1)
19+
await done.fulfill()
20+
}
21+
Task {
22+
try await Task.sleep(seconds: 0.1)
23+
await done.fulfill()
24+
}
25+
Task {
26+
try await Task.sleep(seconds: 0.1)
27+
await done.fulfill()
28+
}
29+
try await AsyncExpectation.waitForExpectations([done])
30+
}
31+
32+
func testNotDoneInvertedExpectation() async throws {
33+
let notDone = AsyncExpectation.expectation(description: "not done", isInverted: true)
34+
try await AsyncExpectation.waitForExpectations([notDone], timeout: 0.1)
35+
}
36+
37+
func testDoneAndNotDoneInvertedExpectation() async throws {
38+
let done = AsyncExpectation.expectation(description: "done")
39+
let notDone = AsyncExpectation.expectation(description: "not done", isInverted: true)
40+
Task {
41+
try await Task.sleep(seconds: 0.1)
42+
await done.fulfill()
43+
}
44+
try await AsyncExpectation.waitForExpectations([notDone], timeout: 0.1)
45+
try await AsyncExpectation.waitForExpectations([done])
46+
}
47+
48+
func testMultipleFulfilledExpectation() async throws {
49+
let one = AsyncExpectation.expectation(description: "one")
50+
let two = AsyncExpectation.expectation(description: "two")
51+
let three = AsyncExpectation.expectation(description: "three")
52+
Task {
53+
try await Task.sleep(seconds: 0.1)
54+
await one.fulfill()
55+
}
56+
Task {
57+
try await Task.sleep(seconds: 0.1)
58+
await two.fulfill()
59+
}
60+
Task {
61+
try await Task.sleep(seconds: 0.1)
62+
await three.fulfill()
63+
}
64+
try await AsyncExpectation.waitForExpectations([one, two, three])
65+
}
66+
67+
func testMultipleAlreadyFulfilledExpectation() async throws {
68+
let one = AsyncExpectation.expectation(description: "one")
69+
let two = AsyncExpectation.expectation(description: "two")
70+
let three = AsyncExpectation.expectation(description: "three")
71+
await one.fulfill()
72+
await two.fulfill()
73+
await three.fulfill()
74+
75+
try await AsyncExpectation.waitForExpectations([one, two, three])
76+
}
77+
}

0 commit comments

Comments
 (0)