From e02fc58ccd86b15c09fad8015cdaeed94fcb1d07 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 27 Jan 2025 09:53:42 -0500 Subject: [PATCH 1/3] Update Documentation/README.md to cover more files. Update Documentation/README.md to cover the Proposals and ABI directories. --- Documentation/README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Documentation/README.md b/Documentation/README.md index ea8ff2f69..53c881367 100644 --- a/Documentation/README.md +++ b/Documentation/README.md @@ -23,12 +23,15 @@ as supplemental content located in the [`Sources/Testing/Testing.docc/`](https://github.com/swiftlang/swift-testing/tree/main/Sources/Testing/Testing.docc) directory. -## Vision document +## Vision document and API proposals The [Vision document](https://github.com/swiftlang/swift-evolution/blob/main/visions/swift-testing.md) for Swift Testing offers a comprehensive discussion of the project's design principles and goals. +The [`Proposals`](Proposals/) directory contains API proposals that have been +accepted and merged into Swift Testing. + ## Development and contribution - The top-level [`README`](https://github.com/swiftlang/swift-testing/blob/main/README.md) @@ -49,6 +52,20 @@ principles and goals. - Instructions are provided for running tests against a [WASI/WebAssembly target](https://github.com/swiftlang/swift-testing/blob/main/Documentation/WASI.md). +## Testing library ABI + +The [`ABI`](ABI/) directory contains documents related to Swift Testing's ABI: +that is, parts of its interface that are intended to be stable over time and can +be used without needing to write any code in Swift: + +- [`ABI/JSON.md`](ABI/JSON.md) contains Swift Testing's JSON specification that + can be used by tools to interact with Swift Testing either directly or via the + `swift test` command-line tool. +- [`ABI/TestContent.md`](ABI/TestContent.md) documents the section emitted by + the Swift compiler into test products that contains test definitions and other + metadata used by Swift Testing (and extensible by third-party testing + libraries.) + ## Project maintenance - The [Releases](https://github.com/swiftlang/swift-testing/blob/main/Documentation/Releases.md) From bd1f23440cfef59788d99f1c86c9a2c5a4113a48 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 27 Jan 2025 10:05:03 -0500 Subject: [PATCH 2/3] Add expectation capture refactor forum post --- Documentation/ExpectationCapture.md | 405 ++++++++++++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 Documentation/ExpectationCapture.md diff --git a/Documentation/ExpectationCapture.md b/Documentation/ExpectationCapture.md new file mode 100644 index 000000000..7bbce174f --- /dev/null +++ b/Documentation/ExpectationCapture.md @@ -0,0 +1,405 @@ +# Rethinking expectation capture in Swift Testing + + + + + +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. + +
+#expect(f() < g()) + +#### 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() +``` +
+ +
+#expect(x && y && !z) + +#### 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() +``` +
+ +
+#expect(try f() < g()) + +#### 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() +``` +
+ +> [!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. From e0c98b421d7d2d5ac3d7da3e1ef5cdd6adbe3676 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 27 Jan 2025 10:53:11 -0500 Subject: [PATCH 3/3] Delete Releases.md --- Documentation/README.md | 6 -- Documentation/Releases.md | 131 -------------------------------------- 2 files changed, 137 deletions(-) delete mode 100644 Documentation/Releases.md diff --git a/Documentation/README.md b/Documentation/README.md index 53c881367..e41bc9568 100644 --- a/Documentation/README.md +++ b/Documentation/README.md @@ -65,9 +65,3 @@ be used without needing to write any code in Swift: the Swift compiler into test products that contains test definitions and other metadata used by Swift Testing (and extensible by third-party testing libraries.) - -## Project maintenance - -- The [Releases](https://github.com/swiftlang/swift-testing/blob/main/Documentation/Releases.md) - document describes the process of creating and publishing a new release of - Swift Testing — a task which may be performed by project administrators. diff --git a/Documentation/Releases.md b/Documentation/Releases.md deleted file mode 100644 index 5f1a20cf4..000000000 --- a/Documentation/Releases.md +++ /dev/null @@ -1,131 +0,0 @@ -# How to create a release of Swift Testing - - - -This document describes how to create a new release of Swift Testing using Git -tags. - -> [!IMPORTANT] -> You must have administrator privileges to create a new release in this -> repository. - -## Version numbering - -Swift Testing uses [semantic versioning](https://semver.org) numbers for its -open source releases. We use Git _tags_ to publish new releases; we don't use -the GitHub [releases](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases) -feature. - -At this time, all Swift Testing releases are experimental, so the major version -is always `0`. We aren't using the patch component, so it's usually (if not -always) `0`. The minor component should be incremented by one for each release. - -For example, if the current release is version `0.1.0` and you are publishing -the next release, it should be `0.2.0`. - -> [!NOTE] -> Where you see `x.y.z` in this document, substitute the semantic version you -> are deploying. - -## Creating a branch for the release - -Before a release can be published, a branch must be created so that the -repository can be configured correctly. Ensure any local changes have been saved -and cleared from the repository (e.g. with `git stash` or `git reset --hard`), -then run the following commands from within the repository's root directory: - -```sh -git checkout main # or other branch as appropriate -git pull -git checkout -b release/x.y.z -``` - -## Preparing the repository's contents - -The package manifest files (Package.swift _and_ Package@swift-6.0.swift) must -be updated so that the release can be used as a package dependency: - -1. Delete any unsafe flags from `var packageSettings` as well as elsewhere in - the package manifest files. - -The repository's local state is now updated. To commit it to your branch, run -the typical commit command: - -```sh -git commit -a -m "Deploy x.y.z" -``` - -## Smoke-testing the branch - -Before deploying the tag publicly, test it by creating a simple package locally. -For example, you can initialize a new package in an empty directory with: - -```sh -swift package init --enable-experimental-swift-testing -``` - -Then modify the package's `Package.swift` file to point at your local clone of -the Swift Testing repository. Ensure that the package's test target builds and -runs successfully with: - -```sh -swift test -``` - -> [!NOTE] -> Be sure to test changes on both macOS and Linux using the most recent -> main-branch Swift toolchain. - -If changes to Swift Testing are necessary for the build to succeed, open -appropriate pull requests on GitHub, then rebase your tag branch after they're -merged. - -## Committing changes and pushing the release - -Run the following commands to push the release and make it publicly visible: - -```sh -git tag x.y.z -git push -u origin x.y.z -``` - -The release is now live and publicly visible [here](https://github.com/swiftlang/swift-testing/tags). -Developers using Swift Package Manager and listing Swift Testing as a dependency -will automatically update to it. - -## Oh no, I made a mistake… - -Don't panic. We all make mistakes. - -### … but I haven't pushed the release yet. - -If you've already created the release's tag locally, but haven't pushed it yet, -delete it with `git tag -d x.y.z`, resolve the issue, and recreate the tag by -following the steps above. - -### … but I can fix it. - -If the release is usable, but contains a bug that _cannot_ wait until the next -planned release to be fixed, a patch release can be deployed. First, fix the -issue locally. Then, follow the steps above to create a new release. Where you -would normally increment the _minor_ version component, increment the _patch_ -version component instead. For example, if the most recent release was `0.1.2`, -the fix should be released as `0.1.3`. - -### … and the release is completely unusable! - -If the release is broken and will not be usable by developers, delete the -release's tag from GitHub using `git push --delete origin x.y.z` so that -developers do not inadvertently download it. - -> [!IMPORTANT] -> Deleting a release or tag is often considered bad form, so only do so if the -> release is truly unusable.