├── .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 | [![CI Status](https://img.shields.io/github/actions/workflow/status/dfed/swift-testing-expectation/ci.yml?branch=main)](https://github.com/dfed/swift-testing-expectation/actions?query=workflow%3ACI+branch%3Amain) 3 | [![codecov](https://codecov.io/gh/dfed/swift-testing-expectation/branch/main/graph/badge.svg?token=nZBHcZZ63F)](https://codecov.io/gh/dfed/swift-testing-expectation) 4 | [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://spdx.org/licenses/MIT.html) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fdfed%2Fswift-testing-expectation%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/dfed/swift-testing-expectation) 6 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fdfed%2Fswift-testing-expectation%2Fbadge%3Ftype%3Dplatforms)](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 | --------------------------------------------------------------------------------