|
| 1 | +# Capturing values in exit tests |
| 2 | + |
| 3 | +* Proposal: [ST-NNNN](NNNN-filename.md) |
| 4 | +* Authors: [Jonathan Grynspan](https://github.com/grynspan) |
| 5 | +* Review Manager: TBD |
| 6 | +* Status: **Awaiting review** |
| 7 | +* Bug: [swiftlang/swift-testing#1157](https://github.com/swiftlang/swift-testing/issues/1157) |
| 8 | +* Implementation: [swiftlang/swift-testing#1040](https://github.com/swiftlang/swift-testing/pull/1040) _et al._ |
| 9 | +* Review: ([pitch](https://forums.swift.org/t/pitch-capturing-values-in-exit-tests/80494)) |
| 10 | + |
| 11 | +## Introduction |
| 12 | + |
| 13 | +In Swift 6.2, we introduced the concept of an _exit test_: a section of code in |
| 14 | +a test function that would run in an independent process and allow test authors |
| 15 | +to test code that terminates the process. For example: |
| 16 | + |
| 17 | +```swift |
| 18 | +enum Fruit: Equatable { |
| 19 | + case apple, orange, olive, tomato |
| 20 | + var isSweet: Bool { get } |
| 21 | + |
| 22 | + consuming func feed(to bat: FruitBat) { |
| 23 | + precondition(self.isSweet, "Fruit bats don't like savory fruits!") |
| 24 | + ... |
| 25 | + } |
| 26 | +} |
| 27 | + |
| 28 | +@Test func `Fruit bats don't eat savory fruits`() async { |
| 29 | + await #expect(processExitsWith: .failure) { |
| 30 | + let fruit = Fruit.olive |
| 31 | + let bat = FruitBat(named: "Chauncey") |
| 32 | + fruit.feed(to: bat) // should trigger a precondition failure and process termination |
| 33 | + } |
| 34 | +} |
| 35 | +``` |
| 36 | + |
| 37 | +This proposal extends exit tests to support capturing state from the enclosing |
| 38 | +context (subject to several practical constraints.) |
| 39 | + |
| 40 | +## Motivation |
| 41 | + |
| 42 | +Exit tests in their current form are useful, but there is no reliable way to |
| 43 | +pass non-constant information from the parent process to the child process, |
| 44 | +which makes them difficult to use with parameterized tests. Consider: |
| 45 | + |
| 46 | +```swift |
| 47 | +@Test(arguments: [Fruit.olive, .tomato]) |
| 48 | +func `Fruit bats don't eat savory fruits`(_ fruit: Fruit) async { |
| 49 | + await #expect(processExitsWith: .failure) { |
| 50 | + let bat = FruitBat(named: "Chauncey") |
| 51 | + fruit.feed(to: bat) // 🛑 can't capture 'fruit' from enclosing scope |
| 52 | + } |
| 53 | +} |
| 54 | +``` |
| 55 | + |
| 56 | +In the above example, the test function's argument cannot be passed into the |
| 57 | +exit test. In a trivial example like this one, it wouldn't be difficult to write |
| 58 | +two tests that differ only in the case of `Fruit` they use in their exit test |
| 59 | +bodies, but this approach doesn't scale very far and is generally an |
| 60 | +anti-pattern when using Swift Testing. |
| 61 | + |
| 62 | +## Proposed solution |
| 63 | + |
| 64 | +We propose allowing the capture of values in an exit test when they are |
| 65 | +specified in a closure capture list on the exit test's body. |
| 66 | + |
| 67 | +## Detailed design |
| 68 | + |
| 69 | +The signatures of the exit test macros `expect(processExitsWith:)` and |
| 70 | +`require(processExitsWith:)` are unchanged. A test author may now add a closure |
| 71 | +capture list to the body of an exit test: |
| 72 | + |
| 73 | +```swift |
| 74 | +@Test(arguments: [Fruit.olive, .tomato]) |
| 75 | +func `Fruit bats don't eat savory fruits`(_ fruit: Fruit) async { |
| 76 | + await #expect(processExitsWith: .failure) { [fruit] in |
| 77 | + let bat = FruitBat(named: "Chauncey") |
| 78 | + fruit.feed(to: bat) |
| 79 | + } |
| 80 | +} |
| 81 | +``` |
| 82 | + |
| 83 | +This feature has some necessary basic constraints: |
| 84 | + |
| 85 | +### Captured values must be explicitly listed in a closure capture list |
| 86 | + |
| 87 | +Swift Testing needs to know what values need to be encoded, sent to the child |
| 88 | +process, and decoded. Swift macros including `#expect(processExitsWith:)` must |
| 89 | +rely solely on syntax—that is, the code typed by a test author. An implicit |
| 90 | +capture within an exit test body is indistinguishable from any other identifier |
| 91 | +or symbol name. |
| 92 | + |
| 93 | +Hence, only values listed in the closure's capture list will be captured. |
| 94 | +Implicitly captured values will produce a compile-time diagnostic as they do |
| 95 | +today. |
| 96 | + |
| 97 | +### Captured values must conform to Sendable and Codable |
| 98 | + |
| 99 | +Captured values will be sent across process boundaries and, in order to support |
| 100 | +that operation, must conform to `Codable`. As well, captured values need to make |
| 101 | +their way through the various internal mechanisms of Swift Testing and its host |
| 102 | +infrastructure, and so must conform to `Sendable`. Conformance to `Copyable` and |
| 103 | +`Escapable` is implied. |
| 104 | + |
| 105 | +If a value that does _not_ conform to the above protocols is specified in an |
| 106 | +exit test body's capture list, a diagnostic is emitted: |
| 107 | + |
| 108 | +```swift |
| 109 | +let bat: FruitBat = ... |
| 110 | +await #expect(processExitsWith: .failure) { [bat] in |
| 111 | + // 🛑 Type of captured value 'bat' must conform to 'Sendable' and 'Codable' |
| 112 | + ... |
| 113 | +} |
| 114 | +``` |
| 115 | + |
| 116 | +### Captured values' types must be visible to the exit test macro |
| 117 | + |
| 118 | +In order for us to successfully _decode_ captured values in the child process, |
| 119 | +we must know their Swift types. Type information is not readily available during |
| 120 | +macro expansion and we must, in general, rely on the parsed syntax tree for it. |
| 121 | + |
| 122 | +The type of `self` and the types of arguments to the calling function are, |
| 123 | +generally, known and can be inferred from context[^shadows]. The types of other |
| 124 | +values, including local variables and global state, are not visible in the |
| 125 | +syntax tree and must be specified explicitly in the capture list using an `as` |
| 126 | +expression: |
| 127 | + |
| 128 | +```swift |
| 129 | +await #expect(processExitsWith: .failure) { [fruit = fruit as Fruit] in |
| 130 | + ... |
| 131 | +} |
| 132 | +``` |
| 133 | + |
| 134 | +Finally, the types of captured literals (e.g. `[x = 123]`) are known at compile |
| 135 | +time and can always be inferred as `IntegerLiteralType` etc., although we don't |
| 136 | +anticipate this will be particularly useful in practice. |
| 137 | + |
| 138 | +If the type of a captured value cannot be resolved from context, the test author |
| 139 | +will see an error at compile time: |
| 140 | + |
| 141 | +```swift |
| 142 | +await #expect(processExitsWith: .failure) { [fruit] in |
| 143 | + // 🛑 Type of captured value 'fruit' is ambiguous |
| 144 | + // Fix-It: Add '= fruit as T' |
| 145 | + ... |
| 146 | +} |
| 147 | +``` |
| 148 | + |
| 149 | +See the **Future directions** section of this proposal for more information on |
| 150 | +how we hope to lift this constraint. If we are able to lift this constraint in |
| 151 | +the future, we expect it will not require (no pun intended) a second Swift |
| 152 | +Evolution proposal. |
| 153 | + |
| 154 | +[^shadows]: If a local variable is declared that shadows `self` or a function |
| 155 | + argument, we may incorrectly infer the type of that value when captured. When |
| 156 | + this occurs, Swift Testing emits a diagnostic of the form "🛑 Type of captured |
| 157 | + value 'foo' is ambiguous". |
| 158 | + |
| 159 | +## Source compatibility |
| 160 | + |
| 161 | +This change is additive and relies on syntax that would previously be rejected |
| 162 | +at compile time. |
| 163 | + |
| 164 | +## Integration with supporting tools |
| 165 | + |
| 166 | +Xcode, Swift Package Manager, and the Swift VS Code plugin _already_ support |
| 167 | +captured values in exit tests as they use Swift Testing's built-in exit test |
| 168 | +handling logic. |
| 169 | + |
| 170 | +Tools that implement their own exit test handling logic will need to account for |
| 171 | +captured values. The `ExitTest` type now has a new SPI property: |
| 172 | + |
| 173 | +```swift |
| 174 | +extension ExitTest { |
| 175 | + /// The set of values captured in the parent process before the exit test is |
| 176 | + /// called. |
| 177 | + /// |
| 178 | + /// This property is automatically set by the testing library when using the |
| 179 | + /// built-in exit test handler and entry point functions. Do not modify the |
| 180 | + /// value of this property unless you are implementing a custom exit test |
| 181 | + /// handler or entry point function. |
| 182 | + /// |
| 183 | + /// The order of values in this array must be the same between the parent and |
| 184 | + /// child processes. |
| 185 | + @_spi(ForToolsIntegrationOnly) |
| 186 | + public var capturedValues: [CapturedValue] { get set } |
| 187 | +} |
| 188 | +``` |
| 189 | + |
| 190 | +In the parent process (that is, for an instance of `ExitTest` passed to |
| 191 | +`Configuration.exitTestHandler`), this property represents the values captured |
| 192 | +at runtime by the exit test. In the child process (that is, for an instance of |
| 193 | +`ExitTest` returned from `ExitTest.find(identifiedBy:)`), the elements in this |
| 194 | +array do not have values associated with them until the hosting tool provides |
| 195 | +them. |
| 196 | + |
| 197 | +## Future directions |
| 198 | + |
| 199 | +- Supporting captured values without requiring type information |
| 200 | + |
| 201 | + We need the types of captured values in order to successfully decode them, but |
| 202 | + we are constrained by macros being syntax-only. In the future, the compiler |
| 203 | + may gain a language feature similar to `decltype()` in C++ or `typeof()` in |
| 204 | + C23, in which case we should be able to use it and avoid the need for explicit |
| 205 | + types in the capture list. ([rdar://153389205](rdar://153389205)) |
| 206 | + |
| 207 | +- Supporting capturing values that do not conform to `Codable` |
| 208 | + |
| 209 | + Alternatives to `Codable` exist or have been proposed, such as |
| 210 | + [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding) |
| 211 | + or [`JSONCodable`](https://forums.swift.org/t/the-future-of-serialization-deserialization-apis/78585). |
| 212 | + In the future, we may want to extend support for values that conform to these |
| 213 | + protocols instead of `Codable`. |
| 214 | + |
| 215 | +## Alternatives considered |
| 216 | + |
| 217 | +- Doing nothing. There is sufficient motivation to support capturing values in |
| 218 | + exit tests and it is within our technical capabilities. |
| 219 | + |
| 220 | +- Passing captured values as arguments to `#expect(processExitsWith:)` and its |
| 221 | + body closure. For example: |
| 222 | + |
| 223 | + ```swift |
| 224 | + await #expect( |
| 225 | + processExitsWith: .failure, |
| 226 | + arguments: [fruit, bat] |
| 227 | + ) { fruit, bat in |
| 228 | + ... |
| 229 | + } |
| 230 | + ``` |
| 231 | + |
| 232 | + This is technically feasible, but: |
| 233 | + |
| 234 | + - It requires that the caller state the capture list twice; |
| 235 | + - Type information still isn't available for captured values, so you'd still |
| 236 | + need to _actually_ write `{ (fruit: Fruit, bat: Bat) in ... }` (or otherwise |
| 237 | + specify the types somewhere in the macro invocation); and |
| 238 | + - The language already has a dedicated syntax for specifying lists of values |
| 239 | + that should be captured in a closure. |
| 240 | + |
| 241 | +- Supporting non-`Sendable` or non-`Codable` captured values. Since exit tests' |
| 242 | + bodies are, by definition, in separate isolation domains from the caller, and |
| 243 | + since they, by nature, run in separate processes, conformance to these |
| 244 | + protocols is fundamentally necessary. |
| 245 | + |
| 246 | +- Implicitly capturing `self`. This would require us to statically detect during |
| 247 | + macro expansion whether `self` conformed to the necessary protocols _and_ |
| 248 | + would preclude capturing any state from static or free test functions. |
| 249 | + |
| 250 | +- Forking the exit test process such that all captured values are implicitly |
| 251 | + copied by the kernel into the new process. Forking, in the UNIX fashion, is |
| 252 | + fundamentally incompatible with the Swift runtime and the Swift thread pool. |
| 253 | + On Darwin, you [cannot fork a process that links to Core Foundation without |
| 254 | + immediately calling `exec()`](https://duckduckgo.com/?q=__THE_PROCESS_HAS_FORKED_AND_YOU_CANNOT_USE_THIS_COREFOUNDATION_FUNCTIONALITY___YOU_MUST_EXEC__), |
| 255 | + and `fork()` isn't even present on Windows. |
| 256 | + |
| 257 | +## Acknowledgments |
| 258 | + |
| 259 | +Thanks to @rintaro for assistance investigating swift-syntax diagnostic support |
| 260 | +and to @xedin for humouring my questions about `decltype()`. |
| 261 | + |
| 262 | +Thanks to the Swift Testing team and the Testing Workgroup as always. And thanks |
| 263 | +to those individuals, who shall remain unnamed, who nerd-sniped me into building |
| 264 | +this feature. |
0 commit comments