|
| 1 | +# Test cancellation |
| 2 | + |
| 3 | +* Proposal: [ST-NNNN](NNNN-test-cancellation.md) |
| 4 | +* Authors: [Jonathan Grynspan](https://github.com/grynspan) |
| 5 | +* Review Manager: TBD |
| 6 | +* Status: **Awaiting review** |
| 7 | +* Bug: [swiftlang/swift-testing#120](https://github.com/swiftlang/swift-testing/issues/120) |
| 8 | +* Implementation: [swiftlang/swift-testing#1284](https://github.com/swiftlang/swift-testing/pull/1284) |
| 9 | +* Review: ([pitch](https://forums.swift.org/...)) |
| 10 | + |
| 11 | +## Introduction |
| 12 | + |
| 13 | +Swift Testing provides the ability to conditionally skip a test before it runs |
| 14 | +using the [`.enabled(if:)`](https://developer.apple.com/documentation/testing/trait/enabled(if:_:sourcelocation:)), |
| 15 | +[`.disabled(if:)`](https://developer.apple.com/documentation/testing/trait/disabled(if:_:sourcelocation:)), |
| 16 | +etc. family of traits: |
| 17 | + |
| 18 | +```swift |
| 19 | +@Test(.enabled(if: Tyrannosaurus.isTheLizardKing)) |
| 20 | +func `Tyrannosaurus is scary`() { |
| 21 | + let dino = Tyrannosaurus() |
| 22 | + #expect(dino.isScary) |
| 23 | + // ... |
| 24 | +} |
| 25 | +``` |
| 26 | + |
| 27 | +This proposal extends that feature to allow cancelling a test after it has |
| 28 | +started but before it has ended. |
| 29 | + |
| 30 | +## Motivation |
| 31 | + |
| 32 | +We have received feedback from a number of developers indicating that their |
| 33 | +tests have constraints that can only be checked after a test has started, and |
| 34 | +they would like the ability to end a test early and see that state change |
| 35 | +reflected in their development tools. |
| 36 | + |
| 37 | +To date, we have not provided an API for ending a test's execution early because |
| 38 | +we want to encourage developers to use the [`.enabled(if:)`](https://developer.apple.com/documentation/testing/trait/enabled(if:_:sourcelocation:)) |
| 39 | +_et al._ trait. This trait can be evaluated early and lets Swift Testing plan a |
| 40 | +test run more efficiently. However, we recognize that these traits aren't |
| 41 | +sufficient. Some test constraints are dependent on data that isn't available |
| 42 | +until the test starts, while others only apply to specific test cases in a |
| 43 | +parameterized test function. |
| 44 | + |
| 45 | +## Proposed solution |
| 46 | + |
| 47 | +A static `cancel()` function is added to the [`Test`](https://developer.apple.com/documentation/testing/test) |
| 48 | +and [`Test.Case`](https://developer.apple.com/documentation/testing/test/case) |
| 49 | +types. When a test author calls these functions from within the body of a test |
| 50 | +(or from within the implementation of a trait, e.g. from [`prepare(for:)`](https://developer.apple.com/documentation/testing/trait/prepare(for:))), |
| 51 | +Swift Testing cancels the currently-running test or test case, respectively. |
| 52 | + |
| 53 | +### Relationship between tasks and tests |
| 54 | + |
| 55 | +Each test runs in its own task during a test run, and each test case in a test |
| 56 | +also runs in its own task. Cancelling the current task from within the body of a |
| 57 | +test will, therefore, cancel the current test case, but not the current test: |
| 58 | + |
| 59 | +```swift |
| 60 | +@Test(arguments: Species.all(in: .dinosauria)) |
| 61 | +func `Are all dinosaurs extinct?`(_ species: Species) { |
| 62 | + if species.in(.aves) { |
| 63 | + // Birds aren't extinct (I hope) |
| 64 | + withUnsafeCurrentTask { $0?.cancel() } |
| 65 | + return |
| 66 | + } |
| 67 | + // ... |
| 68 | +} |
| 69 | +``` |
| 70 | + |
| 71 | +Using [`withUnsafeCurrentTask(body:)`](https://developer.apple.com/documentation/swift/withunsafecurrenttask(body:)-6gvhl) |
| 72 | +here is not ideal. It's not clear that the intent is to cancel the test case, |
| 73 | +and [`UnsafeCurrentTask`](https://developer.apple.com/documentation/swift/unsafecurrenttask) |
| 74 | +is, unsurprisingly, an unsafe interface. |
| 75 | + |
| 76 | +> [!NOTE] |
| 77 | +> The version of Swift Testing included with Swift 6.2 does not correctly handle |
| 78 | +> task cancellation under all conditions. See [swiftlang/swift-testing#1289](https://github.com/swiftlang/swift-testing/issues/1289). |
| 79 | +
|
| 80 | +## Detailed design |
| 81 | + |
| 82 | +New static members are added to [`Test`](https://developer.apple.com/documentation/testing/test) |
| 83 | +and [`Test.Case`](https://developer.apple.com/documentation/testing/test/case): |
| 84 | + |
| 85 | +```swift |
| 86 | +extension Test { |
| 87 | + /// Cancel the current test. |
| 88 | + /// |
| 89 | + /// - Parameters: |
| 90 | + /// - comment: A comment describing why you are cancelling the test. |
| 91 | + /// - sourceLocation: The source location to which the testing library will |
| 92 | + /// attribute the cancellation. |
| 93 | + /// |
| 94 | + /// - Throws: An error indicating that the current test case has been |
| 95 | + /// cancelled. |
| 96 | + /// |
| 97 | + /// The testing library runs each test in its own task. When you call this |
| 98 | + /// function, the testing library cancels the task associated with the current |
| 99 | + /// test: |
| 100 | + /// |
| 101 | + /// ```swift |
| 102 | + /// @Test func `Food truck is well-stocked`() throws { |
| 103 | + /// guard businessHours.contains(.now) else { |
| 104 | + /// try Test.cancel("We're off the clock.") |
| 105 | + /// } |
| 106 | + /// // ... |
| 107 | + /// } |
| 108 | + /// ``` |
| 109 | + /// |
| 110 | + /// If the current test is parameterized, all of its pending and running test |
| 111 | + /// cases are cancelled. If the current test is a suite, all of its pending |
| 112 | + /// and running tests are cancelled. If you have already cancelled the current |
| 113 | + /// test or if it has already finished running, this function throws an error |
| 114 | + /// but does not attempt to cancel the test a second time. |
| 115 | + /// |
| 116 | + /// - Important: If the current task is not associated with a test (for |
| 117 | + /// example, because it was created with [`Task.detached(name:priority:operation:)`](https://developer.apple.com/documentation/swift/task/detached(name:priority:operation:)-795w1)) |
| 118 | + /// this function records an issue and cancels the current task. |
| 119 | + /// |
| 120 | + /// To cancel the current test case but leave other test cases of the current |
| 121 | + /// test alone, call ``Test/Case/cancel(_:sourceLocation:)`` instead. |
| 122 | + public static func cancel(_ comment: Comment? = nil, sourceLocation: SourceLocation = #_sourceLocation) throws -> Never |
| 123 | +} |
| 124 | + |
| 125 | +extension Test.Case { |
| 126 | + /// Cancel the current test case. |
| 127 | + /// |
| 128 | + /// - Parameters: |
| 129 | + /// - comment: A comment describing why you are cancelling the test case. |
| 130 | + /// - sourceLocation: The source location to which the testing library will |
| 131 | + /// attribute the cancellation. |
| 132 | + /// |
| 133 | + /// - Throws: An error indicating that the current test case has been |
| 134 | + /// cancelled. |
| 135 | + /// |
| 136 | + /// The testing library runs each test case of a test in its own task. When |
| 137 | + /// you call this function, the testing library cancels the task associated |
| 138 | + /// with the current test case: |
| 139 | + /// |
| 140 | + /// ```swift |
| 141 | + /// @Test(arguments: [Food.burger, .fries, .iceCream]) |
| 142 | + /// func `Food truck is well-stocked`(_ food: Food) throws { |
| 143 | + /// if food == .iceCream && Season.current == .winter { |
| 144 | + /// try Test.Case.cancel("It's too cold for ice cream.") |
| 145 | + /// } |
| 146 | + /// // ... |
| 147 | + /// } |
| 148 | + /// ``` |
| 149 | + /// |
| 150 | + /// If the current test is parameterized, the test's other test cases continue |
| 151 | + /// running. If the current test case has already been cancelled, this |
| 152 | + /// function throws an error but does not attempt to cancel the test case a |
| 153 | + /// second time. |
| 154 | + /// |
| 155 | + /// - Important: If the current task is not associated with a test case (for |
| 156 | + /// example, because it was created with [`Task.detached(name:priority:operation:)`](https://developer.apple.com/documentation/swift/task/detached(name:priority:operation:)-795w1)) |
| 157 | + /// this function records an issue and cancels the current task. |
| 158 | + /// |
| 159 | + /// To cancel all test cases in the current test, call |
| 160 | + /// ``Test/cancel(_:sourceLocation:)`` instead. |
| 161 | + public static func cancel(_ comment: Comment? = nil, sourceLocation: SourceLocation = #_sourceLocation) throws -> Never |
| 162 | +} |
| 163 | +``` |
| 164 | + |
| 165 | +These functions behave similarly, and are distinguished by the level of the test |
| 166 | +to which they apply: |
| 167 | + |
| 168 | +- `Test.cancel()` cancels the current test. |
| 169 | + - If the current test is parameterized, it implicitly cancels all running and |
| 170 | + pending test cases of said test. |
| 171 | + - If the current test is a suite (only applicable during trait evaluation), it |
| 172 | + recursively cancels all test suites and test functions within said suite. |
| 173 | +- `Test.Case.cancel()` cancels the current test case. |
| 174 | + - If the current test is parameterized, other test cases are unaffected. |
| 175 | + - If the current test is _not_ parameterized, `Test.Case.cancel()` behaves the |
| 176 | + same as `Test.cancel()`. |
| 177 | + |
| 178 | +Cancelling a test or test case implicitly cancels its associated task (and any |
| 179 | +child tasks thereof) as if [`Task.cancel()`](https://developer.apple.com/documentation/swift/task/cancel()) |
| 180 | +were called on that task. |
| 181 | + |
| 182 | +### Throwing semantics |
| 183 | + |
| 184 | +Unlike [`Task.cancel()`](https://developer.apple.com/documentation/swift/task/cancel()), |
| 185 | +these functions always throw an error instead of returning. This simplifies |
| 186 | +control flow when a test is cancelled; instead of having to write: |
| 187 | + |
| 188 | +```swift |
| 189 | +if condition { |
| 190 | + theTask.cancel() |
| 191 | + return |
| 192 | +} |
| 193 | +``` |
| 194 | + |
| 195 | +A test author need only write: |
| 196 | + |
| 197 | +```swift |
| 198 | +if condition { |
| 199 | + try Test.cancel() |
| 200 | +} |
| 201 | +``` |
| 202 | + |
| 203 | +The errors these functions throw are of a type internal to Swift Testing that is |
| 204 | +semantically similar to [`CancellationError`](https://developer.apple.com/documentation/swift/cancellationerror) |
| 205 | +but carries additional information (namely the `comment` and `sourceLocation` |
| 206 | +arguments to `cancel(_:sourceLocation:)`) that Swift Testing can present to the |
| 207 | +user. When Swift Testing catches an error of this type[^cancellationErrorToo], |
| 208 | +it does not record an issue for the current test or test case. |
| 209 | + |
| 210 | +[^cancellationErrorToo]: Swift Testing also catches errors of type |
| 211 | + [`CancellationError`](https://developer.apple.com/documentation/swift/cancellationerror) |
| 212 | + if the current task has been cancelled. If the current task has not been |
| 213 | + cancelled, errors of this type are still recorded as issues. |
| 214 | + |
| 215 | +Suppressing these errors with `do`/`catch` or `try?` does not uncancel a test, |
| 216 | +test case, or task, but can be useful if you have additional local work you need |
| 217 | +to do before the test or test case ends. |
| 218 | + |
| 219 | +### Interaction with recorded issues |
| 220 | + |
| 221 | +If you cancel a test or test case that has previously recorded an issue, that |
| 222 | +issue is not overridden or nullified. In particular, if the test or test case |
| 223 | +has already recorded an issue of severity **error** when you call |
| 224 | +`cancel(_:sourceLocation:)`, the test or test case will still fail. |
| 225 | + |
| 226 | +### Example usage |
| 227 | + |
| 228 | +To cancel the current test case and let other test cases run: |
| 229 | + |
| 230 | +```swift |
| 231 | +@Test(arguments: Species.all(in: .dinosauria)) |
| 232 | +func `Are all dinosaurs extinct?`(_ species: Species) throws { |
| 233 | + if species.in(.aves) { |
| 234 | + try Test.Case.cancel("\(species) is birds!") |
| 235 | + } |
| 236 | + // ... |
| 237 | +} |
| 238 | +``` |
| 239 | + |
| 240 | +Or, to cancel all remaining test cases in the current test: |
| 241 | + |
| 242 | +```swift |
| 243 | +@Test(arguments: Species.all(in: .dinosauria)) |
| 244 | +func `Are all dinosaurs extinct?`(_ species: Species) throws { |
| 245 | + if species.is(.godzilla) { |
| 246 | + try Test.cancel("Forget about unit tests! Run for your life!") |
| 247 | + } |
| 248 | + // ... |
| 249 | +} |
| 250 | +``` |
| 251 | + |
| 252 | +## Source compatibility |
| 253 | + |
| 254 | +This change is additive only. |
| 255 | + |
| 256 | +## Integration with supporting tools |
| 257 | + |
| 258 | +The JSON event stream Swift Testing provides is updated to include two new event |
| 259 | +kinds: |
| 260 | + |
| 261 | +```diff |
| 262 | + <event-kind> ::= "runStarted" | "testStarted" | "testCaseStarted" | |
| 263 | + "issueRecorded" | "testCaseEnded" | "testEnded" | "testSkipped" | |
| 264 | +- "runEnded" | "valueAttached" |
| 265 | ++ "runEnded" | "valueAttached" | "testCancelled" | "testCaseEnded" |
| 266 | +``` |
| 267 | + |
| 268 | +And new fields are added to event records to represent the comment and source |
| 269 | +location passed to `cancel(_:sourceLocation:)`: |
| 270 | + |
| 271 | +```diff |
| 272 | + <event> ::= { |
| 273 | + "kind": <event-kind>, |
| 274 | + "instant": <instant>, ; when the event occurred |
| 275 | + ["issue": <issue>,] ; the recorded issue (if "kind" is "issueRecorded") |
| 276 | + ["attachment": <attachment>,] ; the attachment (if kind is "valueAttached") |
| 277 | + "messages": <array:message>, |
| 278 | + ["testID": <test-id>,] |
| 279 | ++ ["comments": <array:string>,] |
| 280 | ++ ["sourceLocation": <source-location>,] |
| 281 | + } |
| 282 | +``` |
| 283 | + |
| 284 | +These new fields are populated for the new event kinds as well as other event |
| 285 | +kinds that can populate them. |
| 286 | + |
| 287 | +These new event kinds and fields will be included in the next revision of the |
| 288 | +JSON schema (currently expected to be schema version `"6.3"`.) |
| 289 | + |
| 290 | +## Future directions |
| 291 | + |
| 292 | +- Adding a corresponding `Test.checkCancellation()` function and/or |
| 293 | + `Test.isCancelled` static property. These are beyond the scope of this |
| 294 | + proposal, primarily because [`Task.isCancelled`](https://developer.apple.com/documentation/swift/task/iscancelled-swift.type.property) |
| 295 | + and [`Task.checkCancellation()`](https://developer.apple.com/documentation/swift/task/checkcancellation()) |
| 296 | + already work in a test. |
| 297 | + |
| 298 | +## Alternatives considered |
| 299 | + |
| 300 | +- Doing nothing. While we do want test authors to use [`.enabled(if:)`](https://developer.apple.com/documentation/testing/trait/enabled(if:_:sourcelocation:)) |
| 301 | + _et al._ trait, we recognize it does not provide the full set of functionality |
| 302 | + that test authors need. |
| 303 | + |
| 304 | +- Ignoring task cancellation or treating [`CancellationError`](https://developer.apple.com/documentation/swift/cancellationerror) |
| 305 | + as a normal error even when the current task has been cancelled. It is not |
| 306 | + possible for Swift Testing to outright ignore task cancellation, and a |
| 307 | + [`CancellationError`](https://developer.apple.com/documentation/swift/cancellationerror) |
| 308 | + instance thrown from [`Task.checkCancellation()`](https://developer.apple.com/documentation/swift/task/checkcancellation()) |
| 309 | + is not really a test issue but rather a manifestation of control flow. |
| 310 | + |
| 311 | +- Using the [`XCTSkip`](https://developer.apple.com/documentation/xctest/xctskip-swift.struct) |
| 312 | + type from XCTest. Interoperation with XCTest is an area of exploration for us, |
| 313 | + but core functionality of Swift Testing needs to be usable without also |
| 314 | + importing XCTest. |
| 315 | + |
| 316 | +- Spelling the functions `static func cancel(_:sourceLocation:) -> some Error` |
| 317 | + and requiring it be called as `throw Test.cancel()`. This is closer to how |
| 318 | + the [`XCTSkip`](https://developer.apple.com/documentation/xctest/xctskip-swift.struct) |
| 319 | + type is used in XCTest. We have received indirect feedback about [`XCTSkip`](https://developer.apple.com/documentation/xctest/xctskip-swift.struct) |
| 320 | + indicating its usage is unclear, and sometimes need to help developers who |
| 321 | + have written: |
| 322 | + |
| 323 | + ```swift |
| 324 | + if x { |
| 325 | + XCTSkip() |
| 326 | + } |
| 327 | + ``` |
| 328 | + |
| 329 | + And don't understand why it has failed to stop the test. More broadly, it is |
| 330 | + not common practice in Swift for a function to return an error that the caller |
| 331 | + is then responsible for throwing. |
| 332 | + |
| 333 | +- Providing additional `cancel(if:)` and `cancel(unless:)` functions. In |
| 334 | + Objective-C, XCTest provides the [`XCTSkipIf()`](https://developer.apple.com/documentation/xctest/xctskipif) |
| 335 | + and [`XCTSkipUnless()`](https://developer.apple.com/documentation/xctest/xctskipunless) |
| 336 | + macros which capture their condition arguments as strings for display to the |
| 337 | + test author. This functionality is not available in Swift, but XCTest's Swift |
| 338 | + interface provides equivalent throwing functions as conveniences. We could |
| 339 | + provide these functions (without any sort of string-capturing ability) too, |
| 340 | + but they provide little additional clarity above an `if` or `guard` statement. |
| 341 | + |
| 342 | +- Implementing cancellation using Swift macros so we can capture an `if` or |
| 343 | + `unless` argument as a string. A macro for this feature is probably the wrong |
| 344 | + tradeoff between compile-time magic and technical debt. |
| 345 | + |
| 346 | +- Relying solely on [`Task.cancel()`](https://developer.apple.com/documentation/swift/task/cancel()). |
| 347 | + Ignoring the interplay between tests and test cases, this approach is |
| 348 | + difficult for test authors to use because the current [`Task`](https://developer.apple.com/documentation/swift/task) |
| 349 | + instance isn't visible _within_ that task. Instead, a test author would need |
| 350 | + to use [`withUnsafeCurrentTask(body:)`](https://developer.apple.com/documentation/swift/withunsafecurrenttask(body:)-6gvhl) |
| 351 | + to get a temporary reference to the task and cancel _that_ value. We would |
| 352 | + also not have the ability to include a comment and source location information |
| 353 | + in the test's console output or an IDE's test result interface. |
| 354 | + |
| 355 | + With that said, [`UnsafeCurrentTask.cancel()`](https://developer.apple.com/documentation/swift/unsafecurrenttask/cancel()) |
| 356 | + _does_ cancel the test or test case associated with the current task. |
| 357 | + |
| 358 | +## Acknowledgments |
| 359 | + |
| 360 | +Thanks team! |
| 361 | + |
| 362 | +Thanks Arthur! That's right, dinosaurs _do_ say "roar!" |
| 363 | + |
| 364 | +And thanks to [@allevato](https://github.com/allevato) for nerd-sniping me into |
| 365 | +writing this proposal. |
0 commit comments