File: ExpectationCapture.md

package info (click to toggle)
swiftlang 6.2.3-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 2,856,264 kB
  • sloc: cpp: 9,995,718; ansic: 2,234,019; asm: 1,092,167; python: 313,940; objc: 82,726; f90: 80,126; lisp: 38,373; pascal: 25,580; sh: 20,378; ml: 5,058; perl: 4,751; makefile: 4,725; awk: 3,535; javascript: 3,018; xml: 918; fortran: 664; cs: 573; ruby: 396
file content (405 lines) | stat: -rw-r--r-- 14,093 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
# Rethinking expectation capture in Swift Testing

<!--
This source file is part of the Swift.org open source project

Copyright (c) 2025 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception

See https://swift.org/LICENSE.txt for license information
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
-->

<!--
This document was originally published on the Swift forums at
https://forums.swift.org/t/mini-vision-rethinking-expectation-capture-in-swift-testing/77075
-->

Since we announced Swift Testing and shipped it to developers with the Swift 6
toolchain, we've been looking at how we could improve the library. Some changes
we hope to introduce, like exit tests and attachments, are major new features.
Others are smaller quality-of-life changes (bug fixes, performance improvements,
and so on.)

### ℹ️ Be advised
This post assumes some knowledge and understanding of Swift macros and how they
work. In particular, you should know that when the Swift compiler encounters an
_expression_ of the form `#m(a, b, c)`, it passes that expression's
_abstract syntax tree_ (AST) to a compiler plugin that then replaces it with an
equivalent AST known as its _expansion_.

## Our expectations for expectations

One area we knew we'd want to revisit was the `#expect()` and `#require()`
macros and how they work. `#expect()` and `#require()` are more than just
functions: they're _expression macros_. We wrote them as expression macros so
that we could give them some special powers.

When you use one macro or the other, it examines the AST of its condition
argument and looks for one of several known kinds of expression (e.g. binary
operators, member function calls, or `is`/`as?` casts). If found, the macro
rewrites the expression in a form that Swift Testing can examine at runtime.
Then, if the expectation fails, Swift Testing can produce better diagnostics
than it could if it just treated the condition expression as a boolean value.

For example, if you write this expectation:

```swift
#expect(f() < g())
```

… Swift Testing is able to tell you the results of `f()` and `g()` in addition
to the overall expression `f() < g()`.

### Effectively ineffective macros

This works fairly well for simple expressions like that one, but it doesn't have
the flexibility needed to tell a test author what goes wrong when a more complex
expression is used. For example, this expression:

```swift
#expect(x && y && !z)
```

… is subject to a transformation called "operator folding" that takes place
prior to macro expansion, and the AST represents it as a binary operator whose
left-hand operand is _another_ binary operator. Swift Testing doesn't know how
to recursively expand the nested binary operator, so it only produces values for
`x && y` and `!z`.

We also run into trouble when effects (`try` and `await`) are in play. Swift
Testing's implementation can't readily handle arbitrary combinations of
subexpressions that may or may not need either keyword. As a result, it doesn't
try to expand expressions that contain effects. If `f()` or `g()` is a throwing
function:

```swift
#expect(try f() < g())
```

… Swift Testing will not attempt to do any further processing, and only the
outermost expression (`try f() < g()`) will be captured.

## Looking forward

Now that Swift 6.1 has branched for its upcoming release, we can start to look
at the _next_ Swift version and how we can improve Swift Testing to help make it
the _Awesomest Swift Release Ever_. And we'd like to start by revisiting how
we've implemented these macros.

We've been working on [a branch](https://github.com/swiftlang/swift-testing/tree/jgrynspan/162-redesign-value-capture)
of Swift Testing (with a corresponding [draft PR](https://github.com/swiftlang/swift-testing/pull/840))
that completely redesigns the implementation of the `#expect()` and `#require()`
macros. Instead of trying to sniff out an "interesting" expression to expand,
the code on this branch walks the AST of the condition expression and rewrites
_all_ interesting subexpressions.

This expectation:

```swift
#expect(x && y && !z)
```

Can now be fully expanded and will provide a full breakdown of the condition at
runtime if it fails:

```
◇ Test example() started.
✘ Test example() recorded an issue at Example.swift:1:2: Expectation failed: x && y && !z → false
↳ x && y && !z → false
↳   x && y → true
↳     x → true
↳     y → true
↳   !z → false
↳     z → true
✘ Test example() failed after 0.002 seconds with 1 issue.
```

## How you can help

Before we merge this PR and enable these changes in prerelease Swift toolchains,
we’d love it if you could try it out! These changes are a major change for Swift
Testing and the more feedback we can get, the better. To try out the changes:

1. _Temporarily_ add an explicit package dependency on Swift Testing and point
   Swift Package Manager or Xcode to the branch. In your Package.swift file, add
   this dependency:

   ```swift
   dependencies: [
     /* ... */
     .package(
       url: "https://github.com/swiftlang/swift-testing.git",
       branch: "jgrynspan/162-redesign-value-capture"
     ),
   ],
   ```

   And update your test target:

   ```swift
   .testTarget(
     name: "MyTests",
     dependencies: [
       /* ... */
       .productItem(name: "Testing", package: "swift-testing"),
     ]
   )
   ```

   If you’re using an Xcode project, you can add a package dependency via the
   **Package Dependencies** tab in your project’s configuration. Add the
   `Testing` target as a dependency of your test target and click
   **Trust & Enable** to use the locally-built `TestingMacros` target.

1. Once you’ve added the package dependency, clean your package
   (`swift package clean`) or project (**Product** → **Clean Build Folder…**),
   then build and run your tests.

   Swift Testing will be built from source along with swift-syntax, which may
   significantly increase your build times, so we don’t recommend doing this in
   production—this is an at-desk experiment. Swift Testing will be built with
   optimizations off by default, so runtime performance may be impacted, but
   that’s okay: we’re mostly concerned about correctness rather than raw
   performance measurements for the moment.

1. Let us know how your experience goes, especially if you run into problems.
   You can reach me by sending me [a forum DM](https://forums.swift.org/u/grynspan/summary)
   or by commenting on [this PR](https://github.com/swiftlang/swift-testing/pull/840).

## Here be caveats

There are some expressions that Swift Testing could previously successfully
expand that will cause problems with this new implementation:

- Expectations with effects where the effect keyword is to the left of the macro
  name:

  ```swift
  try #expect(h())
  ```

  Macros cannot currently "see" effect keywords in this position. The old
  implementation would often compile because the expansion didn't introduce a
  nested closure scope (which necessarily must repeat these keywords in other
  positions.) The new implementation does not know it needs to insert the `try`
  keyword anywhere in this case, resulting in some confusing diagnostics:

  > ⚠️ No calls to throwing functions occur within 'try' expression
  >
  > 🛑 Call can throw, but it is not marked with 'try' and the error is not
  > handled

  [Stuart Montgomery](https://github.com/stmontgomery) and I chatted with
  [Doug Gregor](https://github.com/DougGregor) and [Holly Borla](https://github.com/hborla)
  recently about this issue; they're looking at the problem and seeing what sort
  of compiler-side support might be possible to help solve it.

  [Stuart Montgomery](https://github.com/stmontgomery) has opened [a PR](https://github.com/swiftlang/swift-syntax/pull/2724)
  against swift-syntax that we hope will help resolve this issue.

  **To avoid this issue**, always place `try` and `await` _within_ the argument
  list of `#expect()`:

  ```swift
  #expect(try h())
  ```

  For `#require()`, the implementation knows that `try` must be present to the
  left of the macro.

- Expectations with particularly complex conditions can, after expansion,
  overwhelm the type checker and fail to compile:

  > 🛑 The compiler is unable to type-check this expression in reasonable time;
  > try breaking up the expression into distinct sub-expressions

  Because macros have little-to-no type information, there aren't a lot of
  opportunities for us to provide any in the macro expansion. I've reached out
  to [Holly Borla](https://github.com/hborla) and her colleagues to see if
  there's room for us to improve our implementation in ways that help the type
  checker.

  **If your expectation fails with this error,** break up the expression as
  recommended and only include part of the expression in the macro's argument
  list:

  ```swift
  let x = ...
  let y = ...
  #expect(x == y)
  ```

- Type names are (syntactically speaking) indistinguishable from variable names.
That means there may be some expressions that we _could_ expand further, but
because we can't tell if a syntax node refers to a variable, a type, or a
module, we don't try:

  ```swift
  #expect(a.b == c) // a may not be expressible in isolation
  ```

  Where we think we can expand such a syntax node, the macro expansion appends
  `.self` to the node in case it refers to a type. There may be cases where the
  macro expansion logic does not work as intended: please send us bug reports if
  you find them!

- The `==`, `!=`, `===`, and `!==` operators are special-cased so that we can
  use [`difference(from:)`](https://developer.apple.com/documentation/swift/bidirectionalcollection/difference(from:))
  to compare operands where possible. The macro implementation assumes that
  these operators eagerly evaluate their arguments (unlike operators like `&&`
  that short-circuit their right-hand arguments using `@autoclosure`.) This
  assumption is true of all implementations of these operators in the Swift
  Standard Library, but we can't make any real guarantees about third-party code.

  We believe that this should not be a common issue in real-world code, but
  please reach out if Swift Testing is expanding these operators incorrectly in
  your code.

### Disabling expression expansion

If you have a condition expression that you don't want expanded at all (for
instance, because the macro is incorrectly expanding it, or because its
implementation might be affected by side effects from Swift Testing), you can
cast the expression with `as Bool` or `as T?`:

```swift
let x = ...
let y = ...
#expect((x == y) as Bool)

let z: String?
let w = try #require(z as String?)
```

## Example expansions

Here (hidden behind disclosure triangles so as not to frighten children and
pets) are some before-and-after examples of how Swift Testing expands the
`#expect()` macro. I've cleaned up the whitespace to make it easier to read.

<details>
<summary><code>#expect(f() < g())</code></summary>

#### Before
```swift
Testing.__checkBinaryOperation(
  f(),
  { $0 < $1() },
  g(),
  expression: .__fromBinaryOperation(
    .__fromSyntaxNode("f()"),
    "<",
    .__fromSyntaxNode("g()")
  ),
  comments: [],
  isRequired: false,
  sourceLocation: Testing.SourceLocation.__here()
).__expected()
```

#### After
```swift
Testing.__checkCondition(
  { (__ec: inout Testing.__ExpectationContext) -> Swift.Bool in
    __ec(__ec(f(), 0x2) < __ec(g(), 0x400), 0x0)
  },
  sourceCode: [
    0x0: "f() < g()",
    0x2: "f()",
    0x400: "g()"
  ],
  comments: [],
  isRequired: false,
  sourceLocation: Testing.SourceLocation.__here()
).__expected()
```
</details>

<details>
<summary><code>#expect(x && y && !z)</code></summary>

#### Before
```swift
Testing.__checkBinaryOperation(
  x && y,
  { $0 && $1() },
  !z,
  expression: .__fromBinaryOperation(
    .__fromSyntaxNode("x && y"),
    "&&",
    .__fromSyntaxNode("!z")
  ),
  comments: [],
  isRequired: false,
  sourceLocation: Testing.SourceLocation.__here()
).__expected()
```

#### After
```swift
Testing.__checkCondition(
  { (__ec: inout Testing.__ExpectationContext) -> Swift.Bool in
    __ec(__ec(__ec(x, 0x6) && __ec(y, 0x42), 0x2) && __ec(!__ec(z, 0x1400), 0x400), 0x0)
  },
  sourceCode: [
    0x0: "x && y && !z",
    0x2: "x && y",
    0x6: "x",
    0x42: "y",
    0x400: "!z",
    0x1400: "z"
  ],
  comments: [],
  isRequired: false,
  sourceLocation: Testing.SourceLocation.__here()
).__expected()
```
</details>

<details>
<summary><code>#expect(try f() < g())</code></summary>

#### Before
```swift
Testing.__checkValue(
  try f() < g(),
  expression: .__fromSyntaxNode("try f() < g()"),
  comments: [],
  isRequired: false,
  sourceLocation: Testing.SourceLocation.__here()
).__expected()
```

#### After
```swift
try Testing.__checkCondition(
  { (__ec: inout Testing.__ExpectationContext) -> Swift.Bool in
    try __ec(__ec(f(), 0xc) < __ec(g(), 0x1004), 0x4)
  },
  sourceCode: [
    0x4: "f() < g()",
    0xc: "f()",
    0x1004: "g()"
  ],
  comments: [],
  isRequired: false,
  sourceLocation: Testing.SourceLocation.__here()
).__expected()
```
</details>

> [!NOTE]
> **What's with the hexadecimal?**
>
> You'll note that all calls to `__ec()` include an integer literal argument,
> and the `sourceCode` argument is a dictionary whose keys are integer literals
> too. These values represent the unique identifiers of each captured syntax
> node from the original AST. They uniquely encode the syntax nodes' positions
> in the tree so that we can reconstruct the (sparse) tree at runtime when a
> test fails.
>
> [I](http://github.com/grynspan) find this subtopic interesting enough to want
> to devote a whole forum thread to it, personally, but it's a bit arcane—for
> more information, see the implementation [here](https://github.com/swiftlang/swift-testing/blob/jgrynspan/162-redesign-value-capture/Sources/Testing/SourceAttribution/ExpressionID.swift)
> or feel free to reach out to me via forum DM.