Skip to content

Commit 41d94d2

Browse files
committed
[DNM] Enable exit test value capturing
This PR enables the value capture subfeature of exit tests and adds documentation for it. > [!WARNING] > Do not merge this PR unless/until [ST-NNNN](swiftlang/swift-evolution#2886) has been approved.
1 parent e63d542 commit 41d94d2

File tree

6 files changed

+56
-129
lines changed

6 files changed

+56
-129
lines changed

Package.swift

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,6 @@ let package = Package(
9595
return result
9696
}(),
9797

98-
traits: [
99-
.trait(
100-
name: "ExperimentalExitTestValueCapture",
101-
description: "Enable experimental support for capturing values in exit tests"
102-
),
103-
],
104-
10598
dependencies: [
10699
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0-latest"),
107100
],
@@ -340,14 +333,6 @@ extension Array where Element == PackageDescription.SwiftSetting {
340333
.define("SWT_NO_LIBDISPATCH", .whenEmbedded()),
341334
]
342335

343-
// Unconditionally enable 'ExperimentalExitTestValueCapture' when building
344-
// for development.
345-
if buildingForDevelopment {
346-
result += [
347-
.define("ExperimentalExitTestValueCapture")
348-
]
349-
}
350-
351336
return result
352337
}
353338

Sources/Testing/Testing.docc/exit-testing.md

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -67,21 +67,7 @@ The parent process doesn't call the body of the exit test. Instead, the child
6767
process treats the body of the exit test as its `main()` function and calls it
6868
directly.
6969

70-
- Note: Because the body acts as the `main()` function of a new process, it
71-
can't capture any state originating in the parent process or from its lexical
72-
context. For example, the following exit test will fail to compile because it
73-
captures a variable declared outside the exit test itself:
74-
75-
```swift
76-
@Test func `Customer won't eat food unless it's nutritious`() async {
77-
let isNutritious = false
78-
await #expect(processExitsWith: .failure) {
79-
var food = ...
80-
food.isNutritious = isNutritious // ❌ ERROR: trying to capture state here
81-
Customer.current.eat(food)
82-
}
83-
}
84-
```
70+
<!-- TODO: discuss @MainActor isolation or lack thereof -->
8571

8672
If the body returns before the child process exits, the process exits as if
8773
`main()` returned normally. If the body throws an error, Swift handles it as if
@@ -106,6 +92,56 @@ status of the child process against the expected exit condition you passed. If
10692
they match, the exit test passes; otherwise, it fails and the testing library
10793
records an issue.
10894

95+
### Capture state from the parent process
96+
97+
To pass information from the parent process to the child process, you specify
98+
the Swift values you want to pass in a [capture list](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/closures/#Capturing-Values)
99+
on the exit test's body:
100+
101+
```swift
102+
@Test(arguments: Food.allJunkFood)
103+
func `Customer won't eat food unless it's nutritious`(_ food: Food) async {
104+
await #expect(processExitsWith: .failure) { [food] in
105+
Customer.current.eat(food)
106+
}
107+
}
108+
```
109+
110+
If a captured value is an argument to the current function or is `self`, its
111+
type is inferred at compile time. Otherwise, explicitly specify the type of the
112+
value using the `as` operator:
113+
114+
```swift
115+
@Test func `Customer won't eat food unless it's nutritious`() async {
116+
var food = ...
117+
food.isNutritious = false
118+
await #expect(processExitsWith: .failure) { [self, food = food as Food] in
119+
self.prepare(food)
120+
Customer.current.eat(food)
121+
}
122+
}
123+
```
124+
125+
Every value you capture in an exit test must conform to [`Sendable`](https://developer.apple.com/documentation/swift/sendable)
126+
and [`Codable`](https://developer.apple.com/documentation/swift/codable). Each
127+
value is encoded by the parent process using [`encode(to:)`](https://developer.apple.com/documentation/swift/encodable/encode(to:))
128+
and is decoded by the child process [`init(from:)`](https://developer.apple.com/documentation/swift/decodable/init(from:))
129+
before being passed to the exit test body.
130+
131+
If a captured value's type does not conform to both `Sendable` and `Codable`, or
132+
if the value is not explicitly specified in the exit test body's capture list,
133+
the compiler emits an error:
134+
135+
```swift
136+
@Test func `Customer won't eat food unless it's nutritious`() async {
137+
var food = ...
138+
food.isNutritious = false
139+
await #expect(processExitsWith: .failure) {
140+
Customer.current.eat(food) // ❌ ERROR: implicitly capturing 'food'
141+
}
142+
}
143+
```
144+
109145
### Gather output from the child process
110146

111147
The ``expect(processExitsWith:observing:_:sourceLocation:performing:)`` and

Sources/TestingMacros/ConditionMacro.swift

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -630,15 +630,6 @@ extension ExitTestConditionMacro {
630630
) -> Bool {
631631
var diagnostics = [DiagnosticMessage]()
632632

633-
if let closureExpr = bodyArgumentExpr.as(ClosureExprSyntax.self),
634-
let captureClause = closureExpr.signature?.capture,
635-
!captureClause.items.isEmpty {
636-
// Disallow capture lists if the experimental feature is not enabled.
637-
if !ExitTestExpectMacro.isValueCapturingEnabled {
638-
diagnostics.append(.captureClauseUnsupported(captureClause, in: closureExpr, inExitTest: macro))
639-
}
640-
}
641-
642633
// Disallow exit tests in generic types and functions as they cannot be
643634
// correctly expanded due to the use of a nested type with static members.
644635
for lexicalContext in context.lexicalContext {
@@ -664,22 +655,6 @@ extension ExitTestConditionMacro {
664655
}
665656
}
666657

667-
extension ExitTestExpectMacro {
668-
/// Whether or not experimental value capturing via explicit capture lists is
669-
/// enabled.
670-
///
671-
/// This member is declared on ``ExitTestExpectMacro`` but also applies to
672-
/// ``ExitTestRequireMacro``.
673-
@TaskLocal
674-
static var isValueCapturingEnabled: Bool = {
675-
#if ExperimentalExitTestValueCapture
676-
return true
677-
#else
678-
return false
679-
#endif
680-
}()
681-
}
682-
683658
/// A type describing the expansion of the `#expect(processExitsWith:)` macro.
684659
///
685660
/// This type checks for nested invocations of `#expect()` and `#require()` and

Sources/TestingMacros/Support/DiagnosticMessage.swift

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -845,50 +845,6 @@ extension DiagnosticMessage {
845845
)
846846
}
847847

848-
/// Create a diagnostic message stating that a capture clause cannot be used
849-
/// in an exit test.
850-
///
851-
/// - Parameters:
852-
/// - captureClause: The invalid capture clause.
853-
/// - closure: The closure containing `captureClause`.
854-
/// - exitTestMacro: The containing exit test macro invocation.
855-
///
856-
/// - Returns: A diagnostic message.
857-
static func captureClauseUnsupported(_ captureClause: ClosureCaptureClauseSyntax, in closure: ClosureExprSyntax, inExitTest exitTestMacro: some FreestandingMacroExpansionSyntax) -> Self {
858-
let changes: [FixIt.Change]
859-
if let signature = closure.signature,
860-
Array(signature.with(\.capture, nil).tokens(viewMode: .sourceAccurate)).count == 1 {
861-
// The only remaining token in the signature is `in`, so remove the whole
862-
// signature tree instead of just the capture clause.
863-
changes = [
864-
.replaceTrailingTrivia(token: closure.leftBrace, newTrivia: ""),
865-
.replace(
866-
oldNode: Syntax(signature),
867-
newNode: Syntax("" as ExprSyntax)
868-
)
869-
]
870-
} else {
871-
changes = [
872-
.replace(
873-
oldNode: Syntax(captureClause),
874-
newNode: Syntax("" as ExprSyntax)
875-
)
876-
]
877-
}
878-
879-
return Self(
880-
syntax: Syntax(captureClause),
881-
message: "Cannot specify a capture clause in closure passed to \(_macroName(exitTestMacro))",
882-
severity: .error,
883-
fixIts: [
884-
FixIt(
885-
message: MacroExpansionFixItMessage("Remove '\(captureClause.trimmed)'"),
886-
changes: changes
887-
),
888-
]
889-
)
890-
}
891-
892848
/// Create a diagnostic message stating that an expression macro is not
893849
/// supported in a generic context.
894850
///

Tests/TestingMacrosTests/ConditionMacroTests.swift

Lines changed: 5 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,6 @@ struct ConditionMacroTests {
452452
}
453453
}
454454

455-
#if ExperimentalExitTestValueCapture
456455
@Test("#expect(processExitsWith:) produces a diagnostic for a bad capture",
457456
arguments: [
458457
"#expectExitTest(processExitsWith: x) { [weak a] in }":
@@ -470,34 +469,12 @@ struct ConditionMacroTests {
470469
]
471470
)
472471
func exitTestCaptureDiagnostics(input: String, expectedMessage: String) throws {
473-
try ExitTestExpectMacro.$isValueCapturingEnabled.withValue(true) {
474-
let (_, diagnostics) = try parse(input)
475-
476-
#expect(diagnostics.count > 0)
477-
for diagnostic in diagnostics {
478-
#expect(diagnostic.diagMessage.severity == .error)
479-
#expect(diagnostic.message == expectedMessage)
480-
}
481-
}
482-
}
483-
#endif
472+
let (_, diagnostics) = try parse(input)
484473

485-
@Test(
486-
"Capture list on an exit test produces a diagnostic",
487-
arguments: [
488-
"#expectExitTest(processExitsWith: x) { [a] in }":
489-
"Cannot specify a capture clause in closure passed to '#expectExitTest(processExitsWith:_:)'"
490-
]
491-
)
492-
func exitTestCaptureListProducesDiagnostic(input: String, expectedMessage: String) throws {
493-
try ExitTestExpectMacro.$isValueCapturingEnabled.withValue(false) {
494-
let (_, diagnostics) = try parse(input)
495-
496-
#expect(diagnostics.count > 0)
497-
for diagnostic in diagnostics {
498-
#expect(diagnostic.diagMessage.severity == .error)
499-
#expect(diagnostic.message == expectedMessage)
500-
}
474+
#expect(diagnostics.count > 0)
475+
for diagnostic in diagnostics {
476+
#expect(diagnostic.diagMessage.severity == .error)
477+
#expect(diagnostic.message == expectedMessage)
501478
}
502479
}
503480

Tests/TestingTests/ExitTestTests.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,6 @@ private import _TestingInternals
381381
}
382382
}
383383

384-
#if ExperimentalExitTestValueCapture
385384
@Test("Capture list")
386385
func captureList() async {
387386
let i = 123
@@ -559,7 +558,6 @@ private import _TestingInternals
559558
}
560559
}
561560
#endif
562-
#endif
563561
}
564562

565563
// MARK: - Fixtures

0 commit comments

Comments
 (0)