Skip to content

Commit 802d8f8

Browse files
authored
Enable exit test value capturing (#1165)
1 parent 9abe480 commit 802d8f8

File tree

6 files changed

+59
-129
lines changed

6 files changed

+59
-129
lines changed

Package.swift

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,6 @@ let package = Package(
107107
return result
108108
}(),
109109

110-
traits: [
111-
.trait(
112-
name: "ExperimentalExitTestValueCapture",
113-
description: "Enable experimental support for capturing values in exit tests"
114-
),
115-
],
116-
117110
dependencies: [
118111
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0-latest"),
119112
],
@@ -383,14 +376,6 @@ extension Array where Element == PackageDescription.SwiftSetting {
383376
.define("SWT_NO_LIBDISPATCH", .whenEmbedded()),
384377
]
385378

386-
// Unconditionally enable 'ExperimentalExitTestValueCapture' when building
387-
// for development.
388-
if buildingForDevelopment {
389-
result += [
390-
.define("ExperimentalExitTestValueCapture")
391-
]
392-
}
393-
394379
return result
395380
}
396381

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

Lines changed: 54 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,59 @@ 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+
- Note: If you use this macro with a Swift compiler version lower than 6.3, it
111+
doesn't support capturing state.
112+
113+
If a captured value is an argument to the current function or is `self`, its
114+
type is inferred at compile time. Otherwise, explicitly specify the type of the
115+
value using the `as` operator:
116+
117+
```swift
118+
@Test func `Customer won't eat food unless it's nutritious`() async {
119+
var food = ...
120+
food.isNutritious = false
121+
await #expect(processExitsWith: .failure) { [self, food = food as Food] in
122+
self.prepare(food)
123+
Customer.current.eat(food)
124+
}
125+
}
126+
```
127+
128+
Every value you capture in an exit test must conform to [`Sendable`](https://developer.apple.com/documentation/swift/sendable)
129+
and [`Codable`](https://developer.apple.com/documentation/swift/codable). Each
130+
value is encoded by the parent process using [`encode(to:)`](https://developer.apple.com/documentation/swift/encodable/encode(to:))
131+
and is decoded by the child process [`init(from:)`](https://developer.apple.com/documentation/swift/decodable/init(from:))
132+
before being passed to the exit test body.
133+
134+
If a captured value's type does not conform to both `Sendable` and `Codable`, or
135+
if the value is not explicitly specified in the exit test body's capture list,
136+
the compiler emits an error:
137+
138+
```swift
139+
@Test func `Customer won't eat food unless it's nutritious`() async {
140+
var food = ...
141+
food.isNutritious = false
142+
await #expect(processExitsWith: .failure) {
143+
Customer.current.eat(food) // ❌ ERROR: implicitly capturing 'food'
144+
}
145+
}
146+
```
147+
109148
### Gather output from the child process
110149

111150
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
@@ -855,50 +855,6 @@ extension DiagnosticMessage {
855855
)
856856
}
857857

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

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
@@ -383,7 +383,6 @@ private import _TestingInternals
383383
}
384384
}
385385

386-
#if ExperimentalExitTestValueCapture
387386
@Test("Capture list")
388387
func captureList() async {
389388
let i = 123
@@ -561,7 +560,6 @@ private import _TestingInternals
561560
}
562561
}
563562
#endif
564-
#endif
565563
}
566564

567565
// MARK: - Fixtures

0 commit comments

Comments
 (0)