├── .editorconfig
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ └── feature_request.md
└── workflows
│ └── ci.yml
├── .gitignore
├── .ruby-version
├── .swiftformat
├── CLI
├── .swiftpm
│ └── xcode
│ │ └── package.xcworkspace
│ │ └── contents.xcworkspacedata
├── Package.resolved
└── Package.swift
├── Contributing.md
├── Gemfile
├── Gemfile.lock
├── LICENSE
├── Package.swift
├── README.md
├── Scripts
├── build.swift
└── prepare-coverage-reports.sh
├── Sources
└── TestingExpectation
│ ├── Expectation.swift
│ └── Expectations.swift
├── TestingExpectation.podspec
├── Tests
└── TestingExpectationTests
│ ├── ExpectationTests.swift
│ └── ExpectationsTests.swift
├── codecov.yml
└── lint.sh
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*]
2 | insert_final_newline = true
3 | indent_size = 4
4 |
5 | # swift
6 | [*.swift]
7 | indent_style = tab
8 |
9 | # sh
10 | [*.sh]
11 | indent_style = tab
12 |
13 | # documentation, utils
14 | [*.{md,mdx,diff}]
15 | trim_trailing_whitespace = false
16 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: dfed
2 | buy_me_a_coffee: dfed
3 | custom: https://cash.app/$dan
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Goals**
11 | What do you want this feature to accomplish? What are the effects of your change?
12 |
13 | **Non-Goals**
14 | What aren’t you trying to accomplish? What are the boundaries of the proposed work?
15 |
16 | **Investigations**
17 | What other solutions (if any) did you investigate? Why didn’t you choose them?
18 |
19 | **Design**
20 | What are you proposing? What are the details of your chosen design? Include an API overview, technical details, and (potentially) some example headers, along with anything else you think will be useful. This is where you sell the design to yourself and project maintainers.
21 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | jobs:
10 | spm-16:
11 | name: Build Xcode 16
12 | runs-on: macos-15
13 | strategy:
14 | matrix:
15 | platforms: [
16 | 'iOS_18,watchOS_11',
17 | 'macOS_15,tvOS_18',
18 | 'visionOS_2'
19 | ]
20 | fail-fast: false
21 | steps:
22 | - name: Checkout Repo
23 | uses: actions/checkout@v4
24 | - uses: ruby/setup-ruby@v1
25 | with:
26 | ruby-version: '3.3.5'
27 | bundler-cache: true
28 | - name: Select Xcode Version
29 | run: sudo xcode-select --switch /Applications/Xcode_16.app/Contents/Developer
30 | - name: Download visionOS
31 | if: matrix.platforms == 'visionOS_2'
32 | run: |
33 | sudo xcodebuild -runFirstLaunch
34 | sudo xcrun simctl list
35 | sudo xcodebuild -downloadPlatform visionOS
36 | sudo xcodebuild -runFirstLaunch
37 | - name: Build and Test Framework
38 | run: Scripts/build.swift ${{ matrix.platforms }}
39 | - name: Prepare Coverage Reports
40 | run: ./Scripts/prepare-coverage-reports.sh
41 | - name: Upload Coverage Reports
42 | if: success()
43 | uses: codecov/codecov-action@v4
44 | spm-16-swift:
45 | name: Swift Build Xcode 16
46 | runs-on: macos-15
47 | steps:
48 | - name: Checkout Repo
49 | uses: actions/checkout@v4
50 | - uses: ruby/setup-ruby@v1
51 | with:
52 | ruby-version: '3.3.5'
53 | bundler-cache: true
54 | - name: Select Xcode Version
55 | run: sudo xcode-select --switch /Applications/Xcode_16.app/Contents/Developer
56 | - name: Build and Test Framework
57 | run: xcrun swift test -c release -Xswiftc -enable-testing
58 | pod-lint:
59 | name: Pod Lint
60 | runs-on: macos-15
61 | steps:
62 | - name: Checkout Repo
63 | uses: actions/checkout@v4
64 | - uses: ruby/setup-ruby@v1
65 | with:
66 | ruby-version: '3.3.5'
67 | bundler-cache: true
68 | - name: Select Xcode Version
69 | run: sudo xcode-select --switch /Applications/Xcode_16.app/Contents/Developer
70 | - name: Download visionOS
71 | run: |
72 | sudo xcodebuild -runFirstLaunch
73 | sudo xcrun simctl list
74 | sudo xcodebuild -downloadPlatform visionOS
75 | sudo xcodebuild -runFirstLaunch
76 | - name: Lint Podspec
77 | run: bundle exec pod lib lint --verbose --fail-fast --swift-version=6.0
78 | linux:
79 | name: "Build and Test on Linux"
80 | runs-on: ubuntu-24.04
81 | container: swift:6.0
82 | steps:
83 | - name: Checkout Repo
84 | uses: actions/checkout@v4
85 | - name: Build and Test Framework
86 | run: swift test -c release --enable-code-coverage -Xswiftc -enable-testing
87 | - name: Prepare Coverage Reports
88 | run: |
89 | llvm-cov export -format="lcov" .build/x86_64-unknown-linux-gnu/release/swift-testing-expectationPackageTests.xctest -instr-profile .build/x86_64-unknown-linux-gnu/release/codecov/default.profdata > coverage.lcov
90 | - name: Upload Coverage Reports
91 | if: success()
92 | uses: codecov/codecov-action@v4
93 | readme-validation:
94 | name: Check Markdown links
95 | runs-on: ubuntu-latest
96 | steps:
97 | - name: Checkout Repo
98 | uses: actions/checkout@v4
99 | - name: Link Checker
100 | uses: AlexanderDokuchaev/md-dead-link-check@v1.0.1
101 | lint-swift:
102 | name: Lint Swift
103 | runs-on: ubuntu-latest
104 | container: swift:6.0
105 | steps:
106 | - name: Checkout Repo
107 | uses: actions/checkout@v4
108 | - name: Lint Swift
109 | run: swift run --package-path CLI swiftformat . --lint
110 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | xcuserdata/
3 | .swiftpm
4 | .build/
5 |
--------------------------------------------------------------------------------
/.ruby-version:
--------------------------------------------------------------------------------
1 | 3.3.5
--------------------------------------------------------------------------------
/.swiftformat:
--------------------------------------------------------------------------------
1 | # format options
2 | --indent tab
3 | --modifierorder nonisolated,open,public,internal,fileprivate,private,private(set),final,override,required,convenience
4 | --ranges no-space
5 | --extensionacl on-declarations
6 | --funcattributes prev-line
7 | --typeattributes prev-line
8 | --storedvarattrs same-line
9 | --hexgrouping none
10 | --decimalgrouping 3
11 |
12 | # rules
13 | --enable isEmpty
14 | --enable wrapEnumCases
15 | --enable wrapMultilineStatementBraces
16 | --disable consistentSwitchCaseSpacing
17 | --disable blankLineAfterSwitchCase
18 |
19 | # global
20 | --swiftversion 6.0
21 |
--------------------------------------------------------------------------------
/CLI/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/CLI/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "b2996d8b20521bfede6aa79472b75c70d7ac765721c6e529be7f5e3dc3d95b1d",
3 | "pins" : [
4 | {
5 | "identity" : "swiftformat",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/nicklockwood/SwiftFormat",
8 | "state" : {
9 | "revision" : "1d02d0f54a5123c3ef67084b318f4421427b7a51",
10 | "version" : "0.56.2"
11 | }
12 | }
13 | ],
14 | "version" : 3
15 | }
16 |
--------------------------------------------------------------------------------
/CLI/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
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: "CLI",
8 | platforms: [
9 | .macOS(.v14),
10 | ],
11 | products: [],
12 | dependencies: [
13 | .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.56.1"),
14 | ],
15 | targets: []
16 | )
17 |
--------------------------------------------------------------------------------
/Contributing.md:
--------------------------------------------------------------------------------
1 | ### One issue or bug per Pull Request
2 |
3 | Keep your Pull Requests small. Small PRs are easier to reason about which makes them significantly more likely to get merged.
4 |
5 | ### Issues before features
6 |
7 | If you want to add a feature, please file an [Issue](https://github.com/dfed/swift-testing-expectation/issues) first. An Issue gives us the opportunity to discuss the requirements and implications of a feature with you before you start writing code.
8 |
9 | ### Backwards compatibility
10 |
11 | Respect the minimum deployment target. If you are adding code that uses new APIs, make sure to prevent older clients from crashing or misbehaving. Our CI runs against our minimum deployment targets, so you will not get a green build unless your code is backwards compatible.
12 |
13 | ### Forwards compatibility
14 |
15 | Please do not write new code using deprecated APIs.
16 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | ruby '3.3.5'
2 |
3 | source 'https://rubygems.org'
4 |
5 | gem 'cocoapods', '~> 1.16.0'
6 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | CFPropertyList (3.0.7)
5 | base64
6 | nkf
7 | rexml
8 | activesupport (7.2.1.2)
9 | base64
10 | bigdecimal
11 | concurrent-ruby (~> 1.0, >= 1.3.1)
12 | connection_pool (>= 2.2.5)
13 | drb
14 | i18n (>= 1.6, < 2)
15 | logger (>= 1.4.2)
16 | minitest (>= 5.1)
17 | securerandom (>= 0.3)
18 | tzinfo (~> 2.0, >= 2.0.5)
19 | addressable (2.8.7)
20 | public_suffix (>= 2.0.2, < 7.0)
21 | algoliasearch (1.27.5)
22 | httpclient (~> 2.8, >= 2.8.3)
23 | json (>= 1.5.1)
24 | atomos (0.1.3)
25 | base64 (0.2.0)
26 | bigdecimal (3.1.8)
27 | claide (1.1.0)
28 | cocoapods (1.16.1)
29 | addressable (~> 2.8)
30 | claide (>= 1.0.2, < 2.0)
31 | cocoapods-core (= 1.16.1)
32 | cocoapods-deintegrate (>= 1.0.3, < 2.0)
33 | cocoapods-downloader (>= 2.1, < 3.0)
34 | cocoapods-plugins (>= 1.0.0, < 2.0)
35 | cocoapods-search (>= 1.0.0, < 2.0)
36 | cocoapods-trunk (>= 1.6.0, < 2.0)
37 | cocoapods-try (>= 1.1.0, < 2.0)
38 | colored2 (~> 3.1)
39 | escape (~> 0.0.4)
40 | fourflusher (>= 2.3.0, < 3.0)
41 | gh_inspector (~> 1.0)
42 | molinillo (~> 0.8.0)
43 | nap (~> 1.0)
44 | ruby-macho (>= 2.3.0, < 3.0)
45 | xcodeproj (>= 1.26.0, < 2.0)
46 | cocoapods-core (1.16.1)
47 | activesupport (>= 5.0, < 8)
48 | addressable (~> 2.8)
49 | algoliasearch (~> 1.0)
50 | concurrent-ruby (~> 1.1)
51 | fuzzy_match (~> 2.0.4)
52 | nap (~> 1.0)
53 | netrc (~> 0.11)
54 | public_suffix (~> 4.0)
55 | typhoeus (~> 1.0)
56 | cocoapods-deintegrate (1.0.5)
57 | cocoapods-downloader (2.1)
58 | cocoapods-plugins (1.0.0)
59 | nap
60 | cocoapods-search (1.0.1)
61 | cocoapods-trunk (1.6.0)
62 | nap (>= 0.8, < 2.0)
63 | netrc (~> 0.11)
64 | cocoapods-try (1.2.0)
65 | colored2 (3.1.2)
66 | concurrent-ruby (1.3.4)
67 | connection_pool (2.4.1)
68 | drb (2.2.1)
69 | escape (0.0.4)
70 | ethon (0.16.0)
71 | ffi (>= 1.15.0)
72 | ffi (1.17.0)
73 | ffi (1.17.0-aarch64-linux-gnu)
74 | ffi (1.17.0-aarch64-linux-musl)
75 | ffi (1.17.0-arm-linux-gnu)
76 | ffi (1.17.0-arm-linux-musl)
77 | ffi (1.17.0-arm64-darwin)
78 | ffi (1.17.0-x86-linux-gnu)
79 | ffi (1.17.0-x86-linux-musl)
80 | ffi (1.17.0-x86_64-darwin)
81 | ffi (1.17.0-x86_64-linux-gnu)
82 | ffi (1.17.0-x86_64-linux-musl)
83 | fourflusher (2.3.1)
84 | fuzzy_match (2.0.4)
85 | gh_inspector (1.1.3)
86 | httpclient (2.8.3)
87 | i18n (1.14.6)
88 | concurrent-ruby (~> 1.0)
89 | json (2.7.5)
90 | logger (1.6.1)
91 | minitest (5.25.1)
92 | molinillo (0.8.0)
93 | nanaimo (0.4.0)
94 | nap (1.1.0)
95 | netrc (0.11.0)
96 | nkf (0.2.0)
97 | public_suffix (4.0.7)
98 | rexml (3.3.9)
99 | ruby-macho (2.5.1)
100 | securerandom (0.3.1)
101 | typhoeus (1.4.1)
102 | ethon (>= 0.9.0)
103 | tzinfo (2.0.6)
104 | concurrent-ruby (~> 1.0)
105 | xcodeproj (1.26.0)
106 | CFPropertyList (>= 2.3.3, < 4.0)
107 | atomos (~> 0.1.3)
108 | claide (>= 1.0.2, < 2.0)
109 | colored2 (~> 3.1)
110 | nanaimo (~> 0.4.0)
111 | rexml (>= 3.3.6, < 4.0)
112 |
113 | PLATFORMS
114 | aarch64-linux-gnu
115 | aarch64-linux-musl
116 | arm-linux-gnu
117 | arm-linux-musl
118 | arm64-darwin
119 | ruby
120 | x86-linux-gnu
121 | x86-linux-musl
122 | x86_64-darwin
123 | x86_64-linux-gnu
124 | x86_64-linux-musl
125 |
126 | DEPENDENCIES
127 | cocoapods (~> 1.16.0)
128 |
129 | RUBY VERSION
130 | ruby 3.3.5p100
131 |
132 | BUNDLED WITH
133 | 2.5.16
134 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Dan Federman
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.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
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: "swift-testing-expectation",
8 | platforms: [
9 | .macOS(.v13),
10 | .iOS(.v16),
11 | .tvOS(.v16),
12 | .watchOS(.v9),
13 | .macCatalyst(.v16),
14 | .visionOS(.v1),
15 | ],
16 | products: [
17 | .library(
18 | name: "TestingExpectation",
19 | targets: ["TestingExpectation"]
20 | ),
21 | ],
22 | targets: [
23 | .target(
24 | name: "TestingExpectation",
25 | dependencies: [],
26 | swiftSettings: [
27 | .swiftLanguageMode(.v6),
28 | ]
29 | ),
30 | .testTarget(
31 | name: "TestingExpectationTests",
32 | dependencies: ["TestingExpectation"],
33 | swiftSettings: [
34 | .swiftLanguageMode(.v6),
35 | ]
36 | ),
37 | ]
38 | )
39 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # swift-testing-expectation
2 | [](https://github.com/dfed/swift-testing-expectation/actions?query=workflow%3ACI+branch%3Amain)
3 | [](https://codecov.io/gh/dfed/swift-testing-expectation)
4 | [](https://spdx.org/licenses/MIT.html)
5 | [](https://swiftpackageindex.com/dfed/swift-testing-expectation)
6 | [](https://swiftpackageindex.com/dfed/swift-testing-expectation)
7 |
8 | Create an asynchronous expectation in Swift Testing
9 |
10 | ## Testing with asynchronous expectations
11 |
12 | The [Swift Testing](https://developer.apple.com/documentation/testing/testing-asynchronous-code) framework vends a [confirmation](https://developer.apple.com/documentation/testing/confirmation(_:expectedcount:isolation:sourcelocation:_:)) method which enables testing asynchronous code. However unlike [XCTest](https://developer.apple.com/documentation/xctest/asynchronous_tests_and_expectations)’s [XCTestExpectation](https://developer.apple.com/documentation/xctest/xctestexpectation), this `confirmation` must be confirmed before the confirmation’s `body` completes. Swift Testing has no out-of-the-box way to ensure that an expectation is fulfilled at some indeterminate point in the future.
13 |
14 | The `Expectation` vended from this library fills that gap:
15 |
16 | ```swift
17 | @Test func methodEventuallyTriggersClosure() async {
18 | let expectation = Expectation()
19 |
20 | systemUnderTest.closure = { expectation.fulfill() }
21 | systemUnderTest.method()
22 |
23 | await expectation.fulfillment(within: .seconds(5))
24 | }
25 | ```
26 |
27 | ### Waiting for multiple expectations
28 |
29 | The `Expectations` type vended from this library makes it easy to wait for multiple expectations:
30 |
31 | ```swift
32 | @Test func methodEventuallyTriggersClosures() async {
33 | let expectation1 = Expectation()
34 | let expectation2 = Expectation()
35 | let expectation3 = Expectation()
36 |
37 | systemUnderTest.closure1 = { expectation1.fulfill() }
38 | systemUnderTest.closure2 = { expectation2.fulfill() }
39 | systemUnderTest.closure3 = { expectation3.fulfill() }
40 | systemUnderTest.method()
41 |
42 | await Expectations(expectation1, expectation2, expectation3).fulfillment(within: .seconds(5))
43 | }
44 | ```
45 |
46 | ## Installation
47 |
48 | ### Swift Package Manager
49 |
50 | To install swift-testing-expectation in your project with [Swift Package Manager](https://github.com/apple/swift-package-manager), the following lines can be added to your `Package.swift` file:
51 |
52 | ```swift
53 | dependencies: [
54 | .package(url: "https://github.com/dfed/swift-testing-expectation", from: "0.1.0"),
55 | ]
56 | ```
57 |
58 | ### CocoaPods
59 |
60 | To install swift-testing-expectation in your project with [CocoaPods](https://blog.cocoapods.org/CocoaPods-Specs-Repo), add the following to your `Podfile`:
61 |
62 | ```
63 | pod 'TestingExpectation', '~> 0.1.0'
64 | ```
65 |
66 | ## Contributing
67 |
68 | I’m glad you’re interested in swift-testing-expectation, and I’d love to see where you take it. Please read the [contributing guidelines](Contributing.md) prior to submitting a Pull Request.
69 |
--------------------------------------------------------------------------------
/Scripts/build.swift:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env swift
2 |
3 | import Foundation
4 |
5 | // Usage: build.swift platforms
6 |
7 | func execute(commandPath: String, arguments: [String]) throws {
8 | let task = Process()
9 | task.executableURL = .init(filePath: commandPath)
10 | task.arguments = arguments
11 | print("Launching command: \(commandPath) \(arguments.joined(separator: " "))")
12 | try task.run()
13 | task.waitUntilExit()
14 | guard task.terminationStatus == 0 else {
15 | throw TaskError.code(task.terminationStatus)
16 | }
17 | }
18 |
19 | enum TaskError: Error {
20 | case code(Int32)
21 | }
22 |
23 | enum Platform: String, CaseIterable, CustomStringConvertible {
24 | case iOS_18
25 | case tvOS_18
26 | case macOS_15
27 | case macCatalyst_15
28 | case watchOS_11
29 | case visionOS_2
30 |
31 | var destination: String {
32 | switch self {
33 | case .iOS_18:
34 | "platform=iOS Simulator,OS=18.0,name=iPad (10th generation)"
35 |
36 | case .tvOS_18:
37 | "platform=tvOS Simulator,OS=18.0,name=Apple TV"
38 |
39 | case .macOS_15,
40 | .macCatalyst_15:
41 | "platform=OS X"
42 |
43 | case .watchOS_11:
44 | "OS=11.0,name=Apple Watch Series 10 (46mm)"
45 |
46 | case .visionOS_2:
47 | "OS=2.0,name=Apple Vision Pro"
48 | }
49 | }
50 |
51 | var sdk: String {
52 | switch self {
53 | case .iOS_18:
54 | "iphonesimulator"
55 |
56 | case .tvOS_18:
57 | "appletvsimulator"
58 |
59 | case .macOS_15,
60 | .macCatalyst_15:
61 | "macosx15.0"
62 |
63 | case .watchOS_11:
64 | "watchsimulator"
65 |
66 | case .visionOS_2:
67 | "xrsimulator"
68 | }
69 | }
70 |
71 | var derivedDataPath: String {
72 | ".build/derivedData/" + description
73 | }
74 |
75 | var description: String {
76 | rawValue
77 | }
78 | }
79 |
80 | guard CommandLine.arguments.count > 1 else {
81 | print("Usage: build.swift platforms")
82 | throw TaskError.code(1)
83 | }
84 |
85 | let rawPlatforms = CommandLine.arguments[1].components(separatedBy: ",")
86 |
87 | for rawPlatform in rawPlatforms {
88 | guard let platform = Platform(rawValue: rawPlatform) else {
89 | print("Received unknown platform type \(rawPlatform)")
90 | print("Possible platform types are: \(Platform.allCases)")
91 | throw TaskError.code(1)
92 | }
93 |
94 | var xcodeBuildArguments = [
95 | "-scheme", "swift-testing-expectation",
96 | "-sdk", platform.sdk,
97 | "-derivedDataPath", platform.derivedDataPath,
98 | "-PBXBuildsContinueAfterErrors=0",
99 | "OTHER_SWIFT_FLAGS=-warnings-as-errors",
100 | ]
101 |
102 | if !platform.destination.isEmpty {
103 | xcodeBuildArguments.append("-destination")
104 | xcodeBuildArguments.append(platform.destination)
105 | }
106 | xcodeBuildArguments.append("-enableCodeCoverage")
107 | xcodeBuildArguments.append("YES")
108 | xcodeBuildArguments.append("build")
109 | xcodeBuildArguments.append("test")
110 | xcodeBuildArguments.append("-test-iterations")
111 | xcodeBuildArguments.append("100")
112 | xcodeBuildArguments.append("-run-tests-until-failure")
113 |
114 | try execute(commandPath: "/usr/bin/xcodebuild", arguments: xcodeBuildArguments)
115 | }
116 |
--------------------------------------------------------------------------------
/Scripts/prepare-coverage-reports.sh:
--------------------------------------------------------------------------------
1 | #!/bin/zsh -l
2 | set -e
3 |
4 | function exportlcov() {
5 | build_type=$1
6 | executable_name=$2
7 |
8 | executable=$(find "${directory}" -type f -name $executable_name)
9 | profile=$(find "${directory}" -type f -name 'Coverage.profdata')
10 | output_file_name="$executable_name.lcov"
11 |
12 | can_proceed=true
13 | if [[ $build_type == watchOS* ]]; then
14 | echo "\tAborting creation of $output_file_name – watchOS not supported."
15 | elif [[ -z $profile ]]; then
16 | echo "\tAborting creation of $output_file_name – no profile found."
17 | elif [[ -z $executable ]]; then
18 | echo "\tAborting creation of $output_file_name – no executable found."
19 | else
20 | output_dir=".build/artifacts/$build_type"
21 | mkdir -p $output_dir
22 |
23 | output_file="$output_dir/$output_file_name"
24 | echo "\tExporting $output_file"
25 | xcrun llvm-cov export -format="lcov" $executable -instr-profile $profile > $output_file
26 | fi
27 | }
28 |
29 | for directory in $(git rev-parse --show-toplevel)/.build/derivedData/*/; do
30 | build_type=$(basename $directory)
31 | echo "Finding coverage information for $build_type"
32 |
33 | exportlcov $build_type 'TestingExpectationTests'
34 | done
35 |
--------------------------------------------------------------------------------
/Sources/TestingExpectation/Expectation.swift:
--------------------------------------------------------------------------------
1 | // MIT License
2 | //
3 | // Copyright (c) 2024 Dan Federman
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.
22 |
23 | import Testing
24 |
25 | public actor Expectation {
26 | // MARK: Initialization
27 |
28 | /// An expected outcome in an asynchronous test.
29 | /// - Parameters:
30 | /// - expectedCount: The number of times `fulfill()` must be called before the expectation is completely fulfilled.
31 | /// - requireAwaitingFulfillment: Controls whether `deinit` requires that a created expectation has its fulfillment awaited.
32 | public init(
33 | expectedCount: UInt = 1,
34 | requireAwaitingFulfillment: Bool = true,
35 | filePath: String = #filePath,
36 | fileID: String = #fileID,
37 | line: Int = #line,
38 | column: Int = #column
39 | ) {
40 | self.init(
41 | expectedCount: expectedCount,
42 | expect: { fulfilledWithExpectedCount, comment, sourceLocation in
43 | #expect(fulfilledWithExpectedCount, comment, sourceLocation: sourceLocation)
44 | },
45 | precondition: requireAwaitingFulfillment ? Swift.precondition : nil,
46 | filePath: filePath,
47 | fileID: fileID,
48 | line: line,
49 | column: column
50 | )
51 | }
52 |
53 | init(
54 | expectedCount: UInt,
55 | expect: @escaping @Sendable (Bool, Comment?, SourceLocation) -> Void,
56 | precondition: (@Sendable (@autoclosure () -> Bool, @autoclosure () -> String, StaticString, UInt) -> Void)? = nil,
57 | filePath: String = #filePath,
58 | fileID: String = #fileID,
59 | line: Int = #line,
60 | column: Int = #column
61 | ) {
62 | self.expectedCount = expectedCount
63 | self.expect = expect
64 | self.precondition = precondition
65 | createdSourceLocation = .init(
66 | fileID: fileID,
67 | filePath: filePath,
68 | line: line,
69 | column: column
70 | )
71 | }
72 |
73 | deinit {
74 | let fulfillmentAwaited = fulfillmentAwaited
75 | if let precondition {
76 | precondition(fulfillmentAwaited, "Expectation created at \(createdSourceLocation) was never awaited", #file, #line)
77 | }
78 | }
79 |
80 | // MARK: Public
81 |
82 | public func fulfillment(
83 | within duration: Duration,
84 | filePath: String = #filePath,
85 | fileID: String = #fileID,
86 | line: Int = #line,
87 | column: Int = #column
88 | ) async {
89 | fulfillmentAwaited = true
90 | guard !isComplete else { return }
91 | let wait = Task {
92 | try await Task.sleep(for: duration)
93 | expect(isComplete, "Expectation not fulfilled within \(duration)", .init(
94 | fileID: fileID,
95 | filePath: filePath,
96 | line: line,
97 | column: column
98 | ))
99 | }
100 | waits.append(wait)
101 | try? await wait.value
102 | }
103 |
104 | @discardableResult
105 | nonisolated
106 | public func fulfill(
107 | filePath: String = #filePath,
108 | fileID: String = #fileID,
109 | line: Int = #line,
110 | column: Int = #column
111 | ) -> Task {
112 | Task {
113 | await self._fulfill(
114 | filePath: filePath,
115 | fileID: fileID,
116 | line: line,
117 | column: column
118 | )
119 | }
120 | }
121 |
122 | // MARK: Private
123 |
124 | private var waits = [Task]()
125 | private var fulfillCount: UInt = 0
126 | private var isComplete: Bool {
127 | expectedCount <= fulfillCount
128 | }
129 |
130 | private var fulfillmentAwaited = false
131 |
132 | private let expectedCount: UInt
133 | private let expect: @Sendable (Bool, Comment?, SourceLocation) -> Void
134 | private let precondition: (@Sendable (@autoclosure () -> Bool, @autoclosure () -> String, StaticString, UInt) -> Void)?
135 | private let createdSourceLocation: SourceLocation
136 |
137 | private func _fulfill(
138 | filePath: String,
139 | fileID: String,
140 | line: Int,
141 | column: Int
142 | ) {
143 | fulfillCount += 1
144 | guard isComplete else { return }
145 | expect(
146 | expectedCount == fulfillCount,
147 | "Expected \(expectedCount) calls to `fulfill()`. Received \(fulfillCount).",
148 | .init(
149 | fileID: fileID,
150 | filePath: filePath,
151 | line: line,
152 | column: column
153 | )
154 | )
155 | for wait in waits {
156 | wait.cancel()
157 | }
158 | waits = []
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/Sources/TestingExpectation/Expectations.swift:
--------------------------------------------------------------------------------
1 | // MIT License
2 | //
3 | // Copyright (c) 2025 Dan Federman
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.
22 |
23 | public actor Expectations {
24 | // MARK: Initialization
25 |
26 | public init(_ expectations: [Expectation]) {
27 | self.expectations = expectations
28 | }
29 |
30 | public init(_ expectations: Expectation...) {
31 | self.init(expectations)
32 | }
33 |
34 | // MARK: Public
35 |
36 | public func fulfillment(
37 | within duration: Duration,
38 | filePath: String = #filePath,
39 | fileID: String = #fileID,
40 | line: Int = #line,
41 | column: Int = #column
42 | ) async {
43 | await withTaskGroup(of: Void.self) { taskGroup in
44 | for expectation in expectations {
45 | taskGroup.addTask {
46 | await expectation.fulfillment(
47 | within: duration,
48 | filePath: filePath,
49 | fileID: fileID,
50 | line: line,
51 | column: column
52 | )
53 | }
54 | }
55 | await taskGroup.waitForAll()
56 | }
57 | }
58 |
59 | // MARK: Private
60 |
61 | private let expectations: [Expectation]
62 | }
63 |
--------------------------------------------------------------------------------
/TestingExpectation.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 | s.name = 'TestingExpectation'
3 | s.version = '0.1.4'
4 | s.license = 'MIT'
5 | s.summary = 'Create an asynchronous expectation in Swift Testing'
6 | s.homepage = 'https://github.com/dfed/swift-testing-expectation'
7 | s.authors = 'Dan Federman'
8 | s.source = { :git => 'https://github.com/dfed/swift-testing-expectation.git', :tag => s.version }
9 | s.swift_version = '6.0'
10 | s.source_files = 'Sources/**/*.{swift}'
11 | s.frameworks = 'Testing'
12 | s.ios.deployment_target = '16.0'
13 | s.tvos.deployment_target = '16.0'
14 | s.watchos.deployment_target = '9.0'
15 | s.macos.deployment_target = '13.0'
16 | s.visionos.deployment_target = '1'
17 | end
18 |
--------------------------------------------------------------------------------
/Tests/TestingExpectationTests/ExpectationTests.swift:
--------------------------------------------------------------------------------
1 | // MIT License
2 | //
3 | // Copyright (c) 2024 Dan Federman
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.
22 |
23 | import Testing
24 |
25 | @testable import TestingExpectation
26 |
27 | struct ExpectationTests {
28 | @Test
29 | func fulfill_triggersExpectation() async {
30 | await confirmation { confirmation in
31 | let systemUnderTest = Expectation(
32 | expectedCount: 1,
33 | expect: { expectation, _, _ in
34 | #expect(expectation)
35 | confirmation()
36 | }
37 | )
38 | await systemUnderTest.fulfill().value
39 | }
40 | }
41 |
42 | @Test
43 | func fulfill_triggersExpectationOnceWhenCalledTwiceAndExpectedCountIsTwo() async {
44 | await confirmation { confirmation in
45 | let systemUnderTest = Expectation(
46 | expectedCount: 2,
47 | expect: { expectation, _, _ in
48 | #expect(expectation)
49 | confirmation()
50 | }
51 | )
52 | await systemUnderTest.fulfill().value
53 | await systemUnderTest.fulfill().value
54 | }
55 | }
56 |
57 | @Test
58 | func fulfill_triggersExpectationWhenExpectedCountIsZero() async {
59 | await confirmation { confirmation in
60 | let systemUnderTest = Expectation(
61 | expectedCount: 0,
62 | expect: { expectation, _, _ in
63 | #expect(!expectation)
64 | confirmation()
65 | }
66 | )
67 | await systemUnderTest.fulfill().value
68 | }
69 | }
70 |
71 | @Test
72 | func fulfillment_doesNotWaitIfAlreadyFulfilled() async {
73 | let systemUnderTest = Expectation(expectedCount: 0)
74 | await systemUnderTest.fulfillment(within: .seconds(10))
75 | }
76 |
77 | @MainActor // Global actor ensures Task ordering.
78 | @Test
79 | func fulfillment_waitsForFulfillment() async {
80 | let systemUnderTest = Expectation(expectedCount: 1, requireAwaitingFulfillment: false)
81 | var hasFulfilled = false
82 | let wait = Task {
83 | await systemUnderTest.fulfillment(within: .seconds(10))
84 | #expect(hasFulfilled)
85 | }
86 | Task {
87 | systemUnderTest.fulfill()
88 | hasFulfilled = true
89 | }
90 | await wait.value
91 | }
92 |
93 | @Test
94 | func fulfillment_triggersFalseExpectationWhenItTimesOut() async {
95 | await confirmation { confirmation in
96 | let systemUnderTest = Expectation(
97 | expectedCount: 1,
98 | expect: { expectation, _, _ in
99 | #expect(!expectation)
100 | confirmation()
101 | }
102 | )
103 | await systemUnderTest.fulfillment(within: .zero)
104 | }
105 | }
106 |
107 | @Test
108 | func deinit_triggersTrueExpectationWhenNotAwaited() async {
109 | let expect: @Sendable (Bool, Comment?, SourceLocation) -> Void = { _, _, _ in }
110 | expect(true, nil, #_sourceLocation) // Force code coverage to cover the empty closure.
111 | await confirmation { confirmation in
112 | let unmanagedSystemUnderTest = Unmanaged.passRetained(Expectation(
113 | expectedCount: 0,
114 | expect: expect,
115 | precondition: { condition, message, _, _ in
116 | _ = message() // Force code coverage to cover the creation of the message.
117 | #expect(condition())
118 | confirmation()
119 | }
120 | ))
121 | await unmanagedSystemUnderTest.takeUnretainedValue().fulfillment(within: .zero)
122 | unmanagedSystemUnderTest.release() // Force the system under test to deinit.
123 | }
124 | }
125 |
126 | @Test
127 | func deinit_triggersFalseExpectationWhenNotAwaited() async {
128 | let expect: @Sendable (Bool, Comment?, SourceLocation) -> Void = { _, _, _ in }
129 | expect(true, nil, #_sourceLocation) // Force code coverage to cover the empty closure.
130 | await confirmation { confirmation in
131 | _ = Expectation(
132 | expectedCount: 1,
133 | expect: expect,
134 | precondition: { condition, message, _, _ in
135 | _ = message() // Force code coverage to cover the creation of the message.
136 | #expect(!condition())
137 | confirmation()
138 | }
139 | )
140 | }
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/Tests/TestingExpectationTests/ExpectationsTests.swift:
--------------------------------------------------------------------------------
1 | // MIT License
2 | //
3 | // Copyright (c) 2025 Dan Federman
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.
22 |
23 | import Testing
24 |
25 | @testable import TestingExpectation
26 |
27 | struct ExpectationsTests {
28 | @Test
29 | func fulfillment_doesNotWaitIfAlreadyFulfilled() async {
30 | let expectation = Expectation(expectedCount: 0)
31 | await Expectations(expectation).fulfillment(within: .seconds(10))
32 | }
33 |
34 | @MainActor // Global actor ensures Task ordering.
35 | @Test
36 | func fulfillment_waitsForFulfillmentOfSingleExpectation() async {
37 | let expectation = Expectation(expectedCount: 1)
38 | var hasFulfilled = false
39 | let wait = Task {
40 | await Expectations(expectation).fulfillment(within: .seconds(10))
41 | #expect(hasFulfilled)
42 | }
43 | Task {
44 | expectation.fulfill()
45 | hasFulfilled = true
46 | }
47 | await wait.value
48 | }
49 |
50 | @MainActor // Global actor ensures Task ordering.
51 | @Test
52 | func fulfillment_waitsForFulfillmentOfMultipleExpectations() async {
53 | let expectation1 = Expectation(expectedCount: 1)
54 | let expectation2 = Expectation(expectedCount: 1)
55 | let expectation3 = Expectation(expectedCount: 1)
56 | var hasFulfilled = false
57 | let wait = Task {
58 | await Expectations(expectation1, expectation2, expectation3).fulfillment(within: .seconds(10))
59 | #expect(hasFulfilled)
60 | }
61 | Task {
62 | expectation1.fulfill()
63 | expectation2.fulfill()
64 | expectation3.fulfill()
65 | hasFulfilled = true
66 | }
67 | await wait.value
68 | }
69 |
70 | @Test
71 | func fulfillment_triggersFalseExpectationWhenSingleExpectationTimesOut() async {
72 | await confirmation { confirmation in
73 | let expectation = Expectation(
74 | expectedCount: 1,
75 | expect: { expectation, _, _ in
76 | #expect(!expectation)
77 | confirmation()
78 | }
79 | )
80 | let systemUnderTest = Expectations(expectation)
81 | await systemUnderTest.fulfillment(within: .zero)
82 | }
83 | }
84 |
85 | @Test
86 | func fulfillment_triggersFalseExpectationWhenSingleExpectationOfManyTimesOut() async {
87 | await confirmation(expectedCount: 3) { confirmation in
88 | let expectation1 = Expectation(
89 | expectedCount: 1,
90 | expect: { expectation, _, _ in
91 | #expect(!expectation)
92 | confirmation()
93 | }
94 | )
95 | let expectation2 = Expectation(
96 | expectedCount: 1,
97 | expect: { expectation, _, _ in
98 | #expect(expectation)
99 | confirmation()
100 | }
101 | )
102 | await expectation2.fulfill().value
103 | let expectation3 = Expectation(
104 | expectedCount: 1,
105 | expect: { expectation, _, _ in
106 | #expect(expectation)
107 | confirmation()
108 | }
109 | )
110 | await expectation3.fulfill().value
111 |
112 | let systemUnderTest = Expectations(expectation1, expectation2, expectation3)
113 | await systemUnderTest.fulfillment(within: .zero)
114 | }
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | codecov:
2 | require_ci_to_pass: yes
3 |
4 | comment:
5 | layout: "reach,diff,flags,tree"
6 | behavior: default
7 | require_changes: no
8 |
9 | coverage:
10 | status:
11 | project:
12 | default:
13 | target: 100%
14 | patch: off
15 |
--------------------------------------------------------------------------------
/lint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/zsh
2 |
3 | set -e
4 |
5 | pushd $(git rev-parse --show-toplevel)
6 |
7 | swift run --only-use-versions-from-resolved-file --package-path CLI --scratch-path .build -c release swiftformat .
8 |
9 | popd
10 |
--------------------------------------------------------------------------------