Skip to content

Commit 5089e36

Browse files
authored
Extend test/task cancellation support to test case evaluation. (#1297)
This PR extends the special-casing of `SkipInfo` and `CancellationError` added in #1284 to also cover test case evaluation during test planning. If either error is thrown from `evaluateTestCases()`, we treat it as test cancellation the same way we do for trait evaluation. Example: ```swift func websites() async throws -> [Website] { guard let www = Web() else { try Test.cancel("The Web doesn't exist, is this 1992?") } return try await www.downloadEverything() } @test(arguments: try await websites()) func browseWeb(at website: Website) { ... } ``` ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated.
1 parent 440eced commit 5089e36

File tree

5 files changed

+117
-49
lines changed

5 files changed

+117
-49
lines changed

Sources/Testing/Issues/Issue+Recording.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,7 @@ extension Issue {
186186
// This error is thrown by expectation checking functions to indicate a
187187
// condition evaluated to `false`. Those functions record their own issue,
188188
// so we don't need to record another one redundantly.
189-
} catch is SkipInfo,
190-
is CancellationError where Task.isCancelled {
189+
} catch let error where SkipInfo(error) != nil {
191190
// This error represents control flow rather than an issue, so we suppress
192191
// it here.
193192
} catch {
@@ -232,8 +231,7 @@ extension Issue {
232231
// This error is thrown by expectation checking functions to indicate a
233232
// condition evaluated to `false`. Those functions record their own issue,
234233
// so we don't need to record another one redundantly.
235-
} catch is SkipInfo,
236-
is CancellationError where Task.isCancelled {
234+
} catch let error where SkipInfo(error) != nil {
237235
// This error represents control flow rather than an issue, so we suppress
238236
// it here.
239237
} catch {

Sources/Testing/Running/Runner.Plan.swift

Lines changed: 49 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -201,47 +201,75 @@ extension Runner.Plan {
201201
/// - Parameters:
202202
/// - test: The test whose action will be determined.
203203
///
204-
/// - Returns: A tuple containing the action to take for `test` as well as any
205-
/// error that was thrown during trait evaluation. If more than one error
206-
/// was thrown, the first-caught error is returned.
207-
private static func _determineAction(for test: Test) async -> (Action, (any Error)?) {
204+
/// - Returns:The action to take for `test`.
205+
private static func _determineAction(for test: inout Test) async -> Action {
206+
let result: Action
207+
208208
// We use a task group here with a single child task so that, if the trait
209209
// code calls Test.cancel() we don't end up cancelling the entire test run.
210210
// We could also model this as an unstructured task except that they aren't
211211
// available in the "task-to-thread" concurrency model.
212212
//
213213
// FIXME: Parallelize this work. Calling `prepare(...)` on all traits and
214214
// evaluating all test arguments should be safely parallelizable.
215-
await withTaskGroup(returning: (Action, (any Error)?).self) { taskGroup in
215+
(test, result) = await withTaskGroup(returning: (Test, Action).self) { [test] taskGroup in
216216
taskGroup.addTask {
217+
var test = test
217218
var action = _runAction
218-
var firstCaughtError: (any Error)?
219219

220220
await Test.withCurrent(test) {
221-
for trait in test.traits {
221+
do {
222+
var firstCaughtError: (any Error)?
223+
224+
for trait in test.traits {
225+
do {
226+
try await trait.prepare(for: test)
227+
} catch {
228+
if let skipInfo = SkipInfo(error) {
229+
action = .skip(skipInfo)
230+
break
231+
} else {
232+
// Only preserve the first caught error
233+
firstCaughtError = firstCaughtError ?? error
234+
}
235+
}
236+
}
237+
238+
// If no trait specified that the test should be skipped, but one
239+
// did throw an error, then the action is to record an issue for
240+
// that error.
241+
if case .run = action, let error = firstCaughtError {
242+
action = .recordIssue(Issue(for: error))
243+
}
244+
}
245+
246+
// If the test is still planned to run (i.e. nothing thus far has
247+
// caused it to be skipped), evaluate its test cases now.
248+
//
249+
// The argument expressions of each test are captured in closures so
250+
// they can be evaluated lazily only once it is determined that the
251+
// test will run, to avoid unnecessary work. But now is the
252+
// appropriate time to evaluate them.
253+
if case .run = action {
222254
do {
223-
try await trait.prepare(for: test)
224-
} catch let error as SkipInfo {
225-
action = .skip(error)
226-
break
227-
} catch is CancellationError where Task.isCancelled {
228-
// Synthesize skip info for this cancellation error.
229-
let sourceContext = SourceContext(backtrace: .current(), sourceLocation: nil)
230-
let skipInfo = SkipInfo(comment: nil, sourceContext: sourceContext)
231-
action = .skip(skipInfo)
232-
break
255+
try await test.evaluateTestCases()
233256
} catch {
234-
// Only preserve the first caught error
235-
firstCaughtError = firstCaughtError ?? error
257+
if let skipInfo = SkipInfo(error) {
258+
action = .skip(skipInfo)
259+
} else {
260+
action = .recordIssue(Issue(for: error))
261+
}
236262
}
237263
}
238264
}
239265

240-
return (action, firstCaughtError)
266+
return (test, action)
241267
}
242268

243269
return await taskGroup.first { _ in true }!
244270
}
271+
272+
return result
245273
}
246274

247275
/// Construct a graph of runner plan steps for the specified tests.
@@ -309,36 +337,12 @@ extension Runner.Plan {
309337
return nil
310338
}
311339

312-
var action = runAction
313-
var firstCaughtError: (any Error)?
314-
315340
// Walk all the traits and tell each to prepare to run the test.
316341
// If any throw a `SkipInfo` error at this stage, stop walking further.
317342
// But if any throw another kind of error, keep track of the first error
318343
// but continue walking, because if any subsequent traits throw a
319344
// `SkipInfo`, the error should not be recorded.
320-
(action, firstCaughtError) = await _determineAction(for: test)
321-
322-
// If no trait specified that the test should be skipped, but one did
323-
// throw an error, then the action is to record an issue for that error.
324-
if case .run = action, let error = firstCaughtError {
325-
action = .recordIssue(Issue(for: error))
326-
}
327-
328-
// If the test is still planned to run (i.e. nothing thus far has caused
329-
// it to be skipped), evaluate its test cases now.
330-
//
331-
// The argument expressions of each test are captured in closures so they
332-
// can be evaluated lazily only once it is determined that the test will
333-
// run, to avoid unnecessary work. But now is the appropriate time to
334-
// evaluate them.
335-
if case .run = action {
336-
do {
337-
try await test.evaluateTestCases()
338-
} catch {
339-
action = .recordIssue(Issue(for: error))
340-
}
341-
}
345+
var action = await _determineAction(for: &test)
342346

343347
// If the test is parameterized but has no cases, mark it as skipped.
344348
if case .run = action, let testCases = test.testCases, testCases.first(where: { _ in true }) == nil {

Sources/Testing/Running/SkipInfo.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,30 @@ extension SkipInfo: Equatable, Hashable {}
5454

5555
extension SkipInfo: Codable {}
5656

57+
// MARK: -
58+
59+
extension SkipInfo {
60+
/// Initialize an instance of this type from an arbitrary error.
61+
///
62+
/// - Parameters:
63+
/// - error: The error to convert to an instance of this type.
64+
///
65+
/// If `error` does not represent a skip or cancellation event, this
66+
/// initializer returns `nil`.
67+
init?(_ error: any Error) {
68+
if let skipInfo = error as? Self {
69+
self = skipInfo
70+
} else if error is CancellationError, Task.isCancelled {
71+
// Synthesize skip info for this cancellation error.
72+
let backtrace = Backtrace(forFirstThrowOf: error) ?? .current()
73+
let sourceContext = SourceContext(backtrace: backtrace, sourceLocation: nil)
74+
self.init(comment: nil, sourceContext: sourceContext)
75+
} else {
76+
return nil
77+
}
78+
}
79+
}
80+
5781
// MARK: - Deprecated
5882

5983
extension SkipInfo {

Tests/TestingTests/TestCancellationTests.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,22 @@
132132
}
133133
}
134134

135+
@Test func `Cancelling a test while evaluating test cases skips the test`() async {
136+
await testCancellation(testSkipped: 1) { configuration in
137+
await Test(arguments: { try await cancelledTestCases(cancelsTask: false) }) { _ in
138+
Issue.record("Recorded an issue!")
139+
}.run(configuration: configuration)
140+
}
141+
}
142+
143+
@Test func `Cancelling the current task while evaluating test cases skips the test`() async {
144+
await testCancellation(testSkipped: 1) { configuration in
145+
await Test(arguments: { try await cancelledTestCases(cancelsTask: true) }) { _ in
146+
Issue.record("Recorded an issue!")
147+
}.run(configuration: configuration)
148+
}
149+
}
150+
135151
#if !SWT_NO_EXIT_TESTS
136152
@Test func `Cancelling the current test from within an exit test`() async {
137153
await testCancellation(testCancelled: 1, testCaseCancelled: 1) { configuration in
@@ -219,6 +235,15 @@ struct CancelledTrait: TestTrait {
219235
}
220236
}
221237

238+
func cancelledTestCases(cancelsTask: Bool) async throws -> EmptyCollection<Int> {
239+
if cancelsTask {
240+
withUnsafeCurrentTask { $0?.cancel() }
241+
try Task.checkCancellation()
242+
}
243+
try Test.cancel("Cancelled from trait")
244+
}
245+
246+
222247
#if !SWT_NO_SNAPSHOT_TYPES
223248
struct `Shows as skipped in Xcode 16` {
224249
@Test func `Cancelled test`() throws {

Tests/TestingTests/TestSupport/TestingAdditions.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,23 @@ extension Test {
199199
self.init(name: name, displayName: name, traits: traits, sourceLocation: sourceLocation, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters)
200200
}
201201

202+
init<C>(
203+
_ traits: any TestTrait...,
204+
arguments collection: @escaping @Sendable () async throws -> C,
205+
parameters: [Parameter] = [
206+
Parameter(index: 0, firstName: "x", type: C.Element.self),
207+
],
208+
sourceLocation: SourceLocation = #_sourceLocation,
209+
column: Int = #column,
210+
name: String = #function,
211+
testFunction: @escaping @Sendable (C.Element) async throws -> Void
212+
) where C: Collection & Sendable, C.Element: Sendable {
213+
let caseGenerator = { @Sendable in
214+
Case.Generator(arguments: try await collection(), parameters: parameters, testFunction: testFunction)
215+
}
216+
self.init(name: name, displayName: name, traits: traits, sourceLocation: sourceLocation, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters)
217+
}
218+
202219
/// Initialize an instance of this type with a function or closure to call,
203220
/// parameterized over two collections of values.
204221
///

0 commit comments

Comments
 (0)