Skip to content

Commit dcce2c7

Browse files
authored
Lazily evaluate test arguments only after determining their test will run, and support throwing expressions (#366)
This changes test declaration, discovery, planning, and running such that arguments to parameterized test functions are evaluated lazily, only after determining their test will run. It also adds support for throwing expressions in test arguments. ### Motivation: When defining parameterized test functions, it can be useful to call throwing or asynchronous APIs — for example, to fetch the arguments from an external source. Today, the `@Test` macro supports `async` expressions but not `throws`. More problematic still: the arguments of _all_ tests get evaluated, even for tests which don't actually run (which may happen for various reasons). Evaluating the arguments of a test which won't run wastes time and resources, so we should try to avoid this, and support `throws` expressions too for better flexibility. ### Modifications: - Modify the `@Test` macro and supporting library interfaces to surround test arguments in closures, so their evaluation can be lazy and deferred. - Modify how test case arguments are stored on each `Test` instance accordingly. - Modify the planning logic in `Runner.Plan` to defer evaluation of test cases until we determine whether each test will run. - Update tests. ### Result: Now, arguments to a parameterized test will only be evaluated if that test is actually going to run, and the expressions may include `try` and potentially throw an `Error`. ### 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. Resolves #166 Resolves rdar://121531170
1 parent 2516cdb commit dcce2c7

File tree

6 files changed

+246
-69
lines changed

6 files changed

+246
-69
lines changed

Sources/Testing/Running/Runner.Plan.swift

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,11 +193,14 @@ extension Runner.Plan {
193193
testGraph = configuration.testFilter.apply(to: testGraph)
194194

195195
// For each test value, determine the appropriate action for it.
196-
await testGraph.forEach { keyPath, test in
196+
//
197+
// FIXME: Parallelize this work. Calling `prepare(...)` on all traits and
198+
// evaluating all test arguments should be safely parallelizable.
199+
testGraph = await testGraph.mapValues { keyPath, test in
197200
// Skip any nil test, which implies this node is just a placeholder and
198201
// not actual test content.
199-
guard let test else {
200-
return
202+
guard var test else {
203+
return nil
201204
}
202205

203206
var action = runAction
@@ -232,7 +235,24 @@ extension Runner.Plan {
232235
action = .recordIssue(issue)
233236
}
234237

238+
// If the test is still planned to run (i.e. nothing thus far has caused
239+
// it to be skipped), evaluate its test cases now.
240+
//
241+
// The argument expressions of each test are captured in closures so they
242+
// can be evaluated lazily only once it is determined that the test will
243+
// run, to avoid unnecessary work. But now is the appropriate time to
244+
// evaluate them.
245+
do {
246+
try await test.evaluateTestCases()
247+
} catch {
248+
let sourceContext = SourceContext(backtrace: Backtrace(forFirstThrowOf: error))
249+
let issue = Issue(kind: .errorCaught(error), comments: [], sourceContext: sourceContext)
250+
action = .recordIssue(issue)
251+
}
252+
235253
actionGraph.updateValue(action, at: keyPath)
254+
255+
return test
236256
}
237257

238258
// Now that we have allowed all the traits to update their corresponding

Sources/Testing/Test+Macro.swift

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ extension Test {
168168
testFunction: @escaping @Sendable () async throws -> Void
169169
) -> Self {
170170
let containingTypeInfo = containingType.map(TypeInfo.init(describing:))
171-
let caseGenerator = Case.Generator(testFunction: testFunction)
171+
let caseGenerator = { @Sendable in Case.Generator(testFunction: testFunction) }
172172
return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceLocation: sourceLocation, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: [])
173173
}
174174
}
@@ -239,14 +239,14 @@ extension Test {
239239
xcTestCompatibleSelector: __XCTestCompatibleSelector?,
240240
displayName: String? = nil,
241241
traits: [any TestTrait],
242-
arguments collection: C,
242+
arguments collection: @escaping @Sendable () async throws -> C,
243243
sourceLocation: SourceLocation,
244244
parameters paramTuples: [__Parameter],
245245
testFunction: @escaping @Sendable (C.Element) async throws -> Void
246246
) -> Self where C: Collection & Sendable, C.Element: Sendable {
247247
let containingTypeInfo = containingType.map(TypeInfo.init(describing:))
248248
let parameters = paramTuples.parameters
249-
let caseGenerator = Case.Generator(arguments: collection, parameters: parameters, testFunction: testFunction)
249+
let caseGenerator = { @Sendable in Case.Generator(arguments: try await collection(), parameters: parameters, testFunction: testFunction) }
250250
return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceLocation: sourceLocation, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters)
251251
}
252252
}
@@ -370,14 +370,14 @@ extension Test {
370370
xcTestCompatibleSelector: __XCTestCompatibleSelector?,
371371
displayName: String? = nil,
372372
traits: [any TestTrait],
373-
arguments collection1: C1, _ collection2: C2,
373+
arguments collection1: @escaping @Sendable () async throws -> C1, _ collection2: @escaping @Sendable () async throws -> C2,
374374
sourceLocation: SourceLocation,
375375
parameters paramTuples: [__Parameter],
376376
testFunction: @escaping @Sendable (C1.Element, C2.Element) async throws -> Void
377377
) -> Self where C1: Collection & Sendable, C1.Element: Sendable, C2: Collection & Sendable, C2.Element: Sendable {
378378
let containingTypeInfo = containingType.map(TypeInfo.init(describing:))
379379
let parameters = paramTuples.parameters
380-
let caseGenerator = Case.Generator(arguments: collection1, collection2, parameters: parameters, testFunction: testFunction)
380+
let caseGenerator = { @Sendable in try await Case.Generator(arguments: collection1(), collection2(), parameters: parameters, testFunction: testFunction) }
381381
return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceLocation: sourceLocation, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters)
382382
}
383383

@@ -394,14 +394,14 @@ extension Test {
394394
xcTestCompatibleSelector: __XCTestCompatibleSelector?,
395395
displayName: String? = nil,
396396
traits: [any TestTrait],
397-
arguments collection: C,
397+
arguments collection: @escaping @Sendable () async throws -> C,
398398
sourceLocation: SourceLocation,
399399
parameters paramTuples: [__Parameter],
400400
testFunction: @escaping @Sendable ((E1, E2)) async throws -> Void
401401
) -> Self where C: Collection & Sendable, C.Element == (E1, E2), E1: Sendable, E2: Sendable {
402402
let containingTypeInfo = containingType.map(TypeInfo.init(describing:))
403403
let parameters = paramTuples.parameters
404-
let caseGenerator = Case.Generator(arguments: collection, parameters: parameters, testFunction: testFunction)
404+
let caseGenerator = { @Sendable in Case.Generator(arguments: try await collection(), parameters: parameters, testFunction: testFunction) }
405405
return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceLocation: sourceLocation, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters)
406406
}
407407

@@ -421,14 +421,14 @@ extension Test {
421421
xcTestCompatibleSelector: __XCTestCompatibleSelector?,
422422
displayName: String? = nil,
423423
traits: [any TestTrait],
424-
arguments dictionary: Dictionary<Key, Value>,
424+
arguments dictionary: @escaping @Sendable () async throws -> Dictionary<Key, Value>,
425425
sourceLocation: SourceLocation,
426426
parameters paramTuples: [__Parameter],
427427
testFunction: @escaping @Sendable ((Key, Value)) async throws -> Void
428428
) -> Self where Key: Sendable, Value: Sendable {
429429
let containingTypeInfo = containingType.map(TypeInfo.init(describing:))
430430
let parameters = paramTuples.parameters
431-
let caseGenerator = Case.Generator(arguments: dictionary, parameters: parameters, testFunction: testFunction)
431+
let caseGenerator = { @Sendable in Case.Generator(arguments: try await dictionary(), parameters: parameters, testFunction: testFunction) }
432432
return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceLocation: sourceLocation, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters)
433433
}
434434

@@ -442,15 +442,17 @@ extension Test {
442442
xcTestCompatibleSelector: __XCTestCompatibleSelector?,
443443
displayName: String? = nil,
444444
traits: [any TestTrait],
445-
arguments zippedCollections: Zip2Sequence<C1, C2>,
445+
arguments zippedCollections: @escaping @Sendable () async throws -> Zip2Sequence<C1, C2>,
446446
sourceLocation: SourceLocation,
447447
parameters paramTuples: [__Parameter],
448448
testFunction: @escaping @Sendable (C1.Element, C2.Element) async throws -> Void
449449
) -> Self where C1: Collection & Sendable, C1.Element: Sendable, C2: Collection & Sendable, C2.Element: Sendable {
450450
let containingTypeInfo = containingType.map(TypeInfo.init(describing:))
451451
let parameters = paramTuples.parameters
452-
let caseGenerator = Case.Generator(arguments: zippedCollections, parameters: parameters) {
453-
try await testFunction($0, $1)
452+
let caseGenerator = { @Sendable in
453+
Case.Generator(arguments: try await zippedCollections(), parameters: parameters) {
454+
try await testFunction($0, $1)
455+
}
454456
}
455457
return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceLocation: sourceLocation, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters)
456458
}

Sources/Testing/Test.swift

Lines changed: 114 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -68,24 +68,91 @@ public struct Test: Sendable {
6868
@_spi(ForToolsIntegrationOnly)
6969
public var xcTestCompatibleSelector: __XCTestCompatibleSelector?
7070

71-
/// Storage for the ``testCases`` property.
71+
/// An enumeration describing the evaluation state of a test's cases.
7272
///
73-
/// This use of `UncheckedSendable` and of `AnySequence` is necessary because
74-
/// it is not currently possible to express `Sequence<Test.Case> & Sendable`
75-
/// as an existential (`any`) ([96960993](rdar://96960993)). It is also not
76-
/// possible to have a value of an underlying generic sequence type without
77-
/// specifying its generic parameters.
78-
private var _testCases: UncheckedSendable<AnySequence<Test.Case>>?
73+
/// This use of `UncheckedSendable` and of `AnySequence` in this type's cases
74+
/// is necessary because it is not currently possible to express
75+
/// `Sequence<Test.Case> & Sendable` as an existential (`any`)
76+
/// ([96960993](rdar://96960993)). It is also not possible to have a value of
77+
/// an underlying generic sequence type without specifying its generic
78+
/// parameters.
79+
fileprivate enum TestCasesState: Sendable {
80+
/// The test's cases have not yet been evaluated.
81+
///
82+
/// - Parameters:
83+
/// - function: The function to call to evaluate the test's cases. The
84+
/// result is a sequence of test cases.
85+
case unevaluated(_ function: @Sendable () async throws -> AnySequence<Test.Case>)
86+
87+
/// The test's cases have been evaluated, and either returned a sequence of
88+
/// cases or failed by throwing an error.
89+
///
90+
/// - Parameters:
91+
/// - result: The result of having evaluated the test's cases.
92+
case evaluated(_ result: Result<UncheckedSendable<AnySequence<Test.Case>>, any Error>)
93+
}
94+
95+
/// The evaluation state of this test's cases, if any.
96+
///
97+
/// If this test represents a suite type, the value of this property is `nil`.
98+
fileprivate var testCasesState: TestCasesState?
7999

80100
/// The set of test cases associated with this test, if any.
81101
///
102+
/// - Precondition: This property may only be accessed on test instances
103+
/// representing suites, or on test functions whose ``testCaseState``
104+
/// indicates a successfully-evaluated state.
105+
///
82106
/// For parameterized tests, each test case is associated with a single
83107
/// combination of parameterized inputs. For non-parameterized tests, a single
84108
/// test case is synthesized. For test suite types (as opposed to test
85109
/// functions), the value of this property is `nil`.
86-
@_spi(ForToolsIntegrationOnly)
87-
public var testCases: (some Sequence<Test.Case> & Sendable)? {
88-
_testCases?.rawValue
110+
var testCases: (some Sequence<Test.Case> & Sendable)? {
111+
guard let testCasesState else {
112+
return nil as AnySequence<Test.Case>?
113+
}
114+
guard case let .evaluated(result) = testCasesState else {
115+
// Callers are expected to first attempt to evaluate a test's cases by
116+
// calling `evaluateTestCases()`.
117+
preconditionFailure("Attempting to access test cases before they have been evaluated.")
118+
}
119+
guard case let .success(testCases) = result else {
120+
// Callers are never expected to access this property after evaluating a
121+
// test's cases, if that evaluation threw an error, because if the test
122+
// cannot be run. In this scenario, a `Runner.Plan` is expected to record
123+
// issue for the test, rather than attempt to run it, and thus never
124+
// access this property.
125+
preconditionFailure("Attempting to access test cases after evaluating them failed.")
126+
}
127+
return testCases.rawValue
128+
}
129+
130+
/// Evaluate this test's cases if they have not been evaluated yet.
131+
///
132+
/// The arguments of a test are captured into a closure so they can be lazily
133+
/// evaluated only if the test will run to avoid unnecessary work. This
134+
/// function may be called once that determination has been made, to perform
135+
/// this evaluation once. The resulting arguments are stored on this instance
136+
/// so that subsequent calls to ``testCases`` do not cause the arguments to be
137+
/// re-evaluated.
138+
///
139+
/// - Throws: Any error caught while first evaluating the test arguments.
140+
mutating func evaluateTestCases() async throws {
141+
guard let testCasesState else { return }
142+
143+
do {
144+
switch testCasesState {
145+
case let .unevaluated(function):
146+
let sequence = try await function()
147+
self.testCasesState = .evaluated(.success(UncheckedSendable(rawValue: sequence)))
148+
case .evaluated:
149+
// No-op: already evaluated
150+
break
151+
}
152+
} catch {
153+
self.testCasesState = .evaluated(.failure(error))
154+
throw error
155+
}
89156
}
90157

91158
/// Whether or not this test is parameterized.
@@ -109,7 +176,7 @@ public struct Test: Sendable {
109176
///
110177
/// A test suite can be declared using the ``Suite(_:_:)`` macro.
111178
public var isSuite: Bool {
112-
containingTypeInfo != nil && testCases == nil
179+
containingTypeInfo != nil && testCasesState == nil
113180
}
114181

115182
/// Whether or not this instance was synthesized at runtime.
@@ -137,6 +204,27 @@ public struct Test: Sendable {
137204
self.isSynthesized = isSynthesized
138205
}
139206

207+
/// Initialize an instance of this type representing a test function.
208+
init<S>(
209+
name: String,
210+
displayName: String? = nil,
211+
traits: [any Trait],
212+
sourceLocation: SourceLocation,
213+
containingTypeInfo: TypeInfo? = nil,
214+
xcTestCompatibleSelector: __XCTestCompatibleSelector? = nil,
215+
testCases: @escaping @Sendable () async throws -> Test.Case.Generator<S>,
216+
parameters: [Parameter]
217+
) {
218+
self.name = name
219+
self.displayName = displayName
220+
self.traits = traits
221+
self.sourceLocation = sourceLocation
222+
self.containingTypeInfo = containingTypeInfo
223+
self.xcTestCompatibleSelector = xcTestCompatibleSelector
224+
self.testCasesState = .unevaluated { .init(try await testCases()) }
225+
self.parameters = parameters
226+
}
227+
140228
/// Initialize an instance of this type representing a test function.
141229
init<S>(
142230
name: String,
@@ -154,7 +242,7 @@ public struct Test: Sendable {
154242
self.sourceLocation = sourceLocation
155243
self.containingTypeInfo = containingTypeInfo
156244
self.xcTestCompatibleSelector = xcTestCompatibleSelector
157-
self._testCases = .init(rawValue: .init(testCases))
245+
self.testCasesState = .evaluated(.success(UncheckedSendable(rawValue: .init(testCases))))
158246
self.parameters = parameters
159247
}
160248
}
@@ -209,10 +297,10 @@ extension Test {
209297

210298
/// The set of test cases associated with this test, if any.
211299
///
212-
/// ## See Also
213-
///
214-
/// - ``Test/testCases``
215-
@_spi(ForToolsIntegrationOnly)
300+
/// If the ``Test`` this instance was snapshotted from represented a
301+
/// parameterized test function but its test cases had not yet been
302+
/// evaluated when the snapshot was taken, or the evaluation attempt failed,
303+
/// the value of this property will be an empty array.
216304
public var testCases: [Test.Case.Snapshot]?
217305

218306
/// The test function parameters, if any.
@@ -252,14 +340,23 @@ extension Test {
252340
name = test.name
253341
displayName = test.displayName
254342
sourceLocation = test.sourceLocation
255-
testCases = test.testCases?.map(Test.Case.Snapshot.init)
256343
parameters = test.parameters
257344
comments = test.comments
258345
tags = test.tags
259346
associatedBugs = test.associatedBugs
260347
if #available(_clockAPI, *) {
261348
_timeLimit = test.timeLimit.map(TimeValue.init)
262349
}
350+
351+
testCases = switch test.testCasesState {
352+
case .unevaluated,
353+
.evaluated(.failure):
354+
[]
355+
case let .evaluated(.success(testCases)):
356+
testCases.rawValue.map(Test.Case.Snapshot.init(snapshotting:))
357+
case nil:
358+
nil
359+
}
263360
}
264361

265362
/// Whether or not this test is parameterized.

Sources/TestingMacros/Support/AttributeDiscovery.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,21 @@ struct AttributeInfo {
180180
ArrayElementSyntax(expression: traitExpr)
181181
}
182182
}))
183+
184+
// Any arguments of the test declaration macro which specify test arguments
185+
// need to be wrapped a closure so they may be evaluated lazily by the
186+
// testing library at runtime. If any such arguments are present, they will
187+
// begin with a labeled argument named `arguments:` and include all
188+
// subsequent unlabeled arguments.
189+
var otherArguments = self.otherArguments
190+
if let argumentsIndex = otherArguments.firstIndex(where: { $0.label?.tokenKind == .identifier("arguments") }) {
191+
for index in argumentsIndex ..< otherArguments.endIndex {
192+
var argument = otherArguments[index]
193+
argument.expression = .init(ClosureExprSyntax { argument.expression })
194+
otherArguments[index] = argument
195+
}
196+
}
197+
183198
arguments += otherArguments
184199
arguments.append(Argument(label: "sourceLocation", expression: sourceLocation))
185200

0 commit comments

Comments
 (0)