From 348641197918894a65cd6ae278c4d7ec55ea99de Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Thu, 26 Jun 2025 13:02:39 -0400 Subject: [PATCH] Add (hidden) synchronous overloads of `#expect(throws:)`. (#1178) This PR adds overloads of the `#expect(throws:)` and `#require(throws:)` macros that take synchronous closures. This is necessary due to a change in the compiler in Swift 6.2 that improves type checking on closures passed to macros. The change is a good thing, but it means that the compiler infers the type of closures passed to these macros as `async` even when they are synchronous and developers will now get warnings under some circumstances. This PR does not constitute an API change. The new overloads are identical to their `async` peers and there is no change in the underlying macro expansion logic at compile time or runtime. The PR serves solely to suppress new spurious warnings that were not emitted in Swift 6.1 or earlier. Resolves #1177. Resolves rdar://149299786. ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated. --- .../Expectations/Expectation+Macro.swift | 373 ++++++++++++++++++ 1 file changed, 373 insertions(+) diff --git a/Sources/Testing/Expectations/Expectation+Macro.swift b/Sources/Testing/Expectations/Expectation+Macro.swift index 8a6749a31..2f64aff3a 100644 --- a/Sources/Testing/Expectations/Expectation+Macro.swift +++ b/Sources/Testing/Expectations/Expectation+Macro.swift @@ -131,6 +131,74 @@ public macro require( // MARK: - Matching errors by type +#if compiler(>=6.2) +/// Check that an expression always throws an error of a given type. +/// +/// - Parameters: +/// - errorType: The type of error that is expected to be thrown. If +/// `expression` could throw _any_ error, or the specific type of thrown +/// error is unimportant, pass `(any Error).self`. +/// - comment: A comment describing the expectation. +/// - sourceLocation: The source location to which recorded expectations and +/// issues should be attributed. +/// - expression: The expression to be evaluated. +/// +/// - Returns: If the expectation passes, the instance of `errorType` that was +/// thrown by `expression`. If the expectation fails, the result is `nil`. +/// +/// Use this overload of `#expect()` when the expression `expression` _should_ +/// throw an error of a given type: +/// +/// ```swift +/// #expect(throws: EngineFailureError.self) { +/// FoodTruck.shared.engine.batteryLevel = 0 +/// try FoodTruck.shared.engine.start() +/// } +/// ``` +/// +/// If `expression` does not throw an error, or if it throws an error that is +/// not an instance of `errorType`, an ``Issue`` is recorded for the test that +/// is running in the current task. Any value returned by `expression` is +/// discarded. +/// +/// - Note: If you use this macro with a Swift compiler version lower than 6.1, +/// it doesn't return a value. +/// +/// If the thrown error need only equal another instance of [`Error`](https://developer.apple.com/documentation/swift/error), +/// use ``expect(throws:_:sourceLocation:performing:)-7du1h`` instead. +/// +/// ## Expressions that should never throw +/// +/// If the expression `expression` should _never_ throw any error, you can pass +/// [`Never.self`](https://developer.apple.com/documentation/swift/never): +/// +/// ```swift +/// #expect(throws: Never.self) { +/// FoodTruck.shared.engine.batteryLevel = 100 +/// try FoodTruck.shared.engine.start() +/// } +/// ``` +/// +/// If `expression` throws an error, an ``Issue`` is recorded for the test that +/// is running in the current task. Any value returned by `expression` is +/// discarded. +/// +/// Test functions can be annotated with `throws` and can throw errors which are +/// then recorded as issues when the test runs. If the intent is for a test to +/// fail when an error is thrown by `expression`, rather than to explicitly +/// check that an error is _not_ thrown by it, do not use this macro. Instead, +/// simply call the code in question and allow it to throw an error naturally. +@discardableResult +@freestanding(expression) +@_documentation(visibility: private) +public macro expect( + throws errorType: E.Type, + _ comment: @autoclosure () -> Comment? = nil, + sourceLocation: SourceLocation = #_sourceLocation, + performing expression: () throws -> R +) -> E? = #externalMacro(module: "TestingMacros", type: "ExpectMacro") where E: Error +#endif + /// Check that an expression always throws an error of a given type. /// /// - Parameters: @@ -195,6 +263,58 @@ public macro require( performing expression: () async throws -> R ) -> E? = #externalMacro(module: "TestingMacros", type: "ExpectMacro") where E: Error +#if compiler(>=6.2) +/// Check that an expression always throws an error of a given type, and throw +/// an error if it does not. +/// +/// - Parameters: +/// - errorType: The type of error that is expected to be thrown. If +/// `expression` could throw _any_ error, or the specific type of thrown +/// error is unimportant, pass `(any Error).self`. +/// - comment: A comment describing the expectation. +/// - sourceLocation: The source location to which recorded expectations and +/// issues should be attributed. +/// - expression: The expression to be evaluated. +/// +/// - Returns: The instance of `errorType` that was thrown by `expression`. +/// +/// - Throws: An instance of ``ExpectationFailedError`` if `expression` does not +/// throw a matching error. The error thrown by `expression` is not rethrown. +/// +/// Use this overload of `#require()` when the expression `expression` _should_ +/// throw an error of a given type: +/// +/// ```swift +/// try #require(throws: EngineFailureError.self) { +/// FoodTruck.shared.engine.batteryLevel = 0 +/// try FoodTruck.shared.engine.start() +/// } +/// ``` +/// +/// If `expression` does not throw an error, or if it throws an error that is +/// not an instance of `errorType`, an ``Issue`` is recorded for the test that +/// is running in the current task and an instance of ``ExpectationFailedError`` +/// is thrown. Any value returned by `expression` is discarded. +/// +/// - Note: If you use this macro with a Swift compiler version lower than 6.1, +/// it doesn't return a value. +/// +/// If the thrown error need only equal another instance of [`Error`](https://developer.apple.com/documentation/swift/error), +/// use ``require(throws:_:sourceLocation:performing:)-4djuw`` instead. +/// +/// If `expression` should _never_ throw, simply invoke the code without using +/// this macro. The test will then fail if an error is thrown. +@discardableResult +@freestanding(expression) +@_documentation(visibility: private) +public macro require( + throws errorType: E.Type, + _ comment: @autoclosure () -> Comment? = nil, + sourceLocation: SourceLocation = #_sourceLocation, + performing expression: () throws -> R +) -> E = #externalMacro(module: "TestingMacros", type: "RequireThrowsMacro") where E: Error +#endif + /// Check that an expression always throws an error of a given type, and throw /// an error if it does not. /// @@ -243,6 +363,28 @@ public macro require( performing expression: () async throws -> R ) -> E = #externalMacro(module: "TestingMacros", type: "RequireThrowsMacro") where E: Error +#if compiler(>=6.2) +/// Check that an expression never throws an error, and throw an error if it +/// does. +/// +/// - Parameters: +/// - comment: A comment describing the expectation. +/// - sourceLocation: The source location to which recorded expectations and +/// issues should be attributed. +/// - expression: The expression to be evaluated. +/// +/// - Throws: An instance of ``ExpectationFailedError`` if `expression` throws +/// any error. The error thrown by `expression` is not rethrown. +@freestanding(expression) +@_documentation(visibility: private) +public macro require( + throws _: Never.Type, + _ comment: @autoclosure () -> Comment? = nil, + sourceLocation: SourceLocation = #_sourceLocation, + performing expression: () throws -> R +) = #externalMacro(module: "TestingMacros", type: "RequireThrowsNeverMacro") +#endif + /// Check that an expression never throws an error, and throw an error if it /// does. /// @@ -265,6 +407,50 @@ public macro require( // MARK: - Matching instances of equatable errors +#if compiler(>=6.2) +/// Check that an expression always throws a specific error. +/// +/// - Parameters: +/// - error: The error that is expected to be thrown. +/// - comment: A comment describing the expectation. +/// - sourceLocation: The source location to which recorded expectations and +/// issues should be attributed. +/// - expression: The expression to be evaluated. +/// +/// - Returns: If the expectation passes, the instance of `E` that was thrown by +/// `expression` and is equal to `error`. If the expectation fails, the result +/// is `nil`. +/// +/// Use this overload of `#expect()` when the expression `expression` _should_ +/// throw a specific error: +/// +/// ```swift +/// #expect(throws: EngineFailureError.batteryDied) { +/// FoodTruck.shared.engine.batteryLevel = 0 +/// try FoodTruck.shared.engine.start() +/// } +/// ``` +/// +/// If `expression` does not throw an error, or if it throws an error that is +/// not equal to `error`, an ``Issue`` is recorded for the test that is running +/// in the current task. Any value returned by `expression` is discarded. +/// +/// - Note: If you use this macro with a Swift compiler version lower than 6.1, +/// it doesn't return a value. +/// +/// If the thrown error need only be an instance of a particular type, use +/// ``expect(throws:_:sourceLocation:performing:)-1hfms`` instead. +@discardableResult +@freestanding(expression) +@_documentation(visibility: private) +public macro expect( + throws error: E, + _ comment: @autoclosure () -> Comment? = nil, + sourceLocation: SourceLocation = #_sourceLocation, + performing expression: () throws -> R +) -> E? = #externalMacro(module: "TestingMacros", type: "ExpectMacro") where E: Error & Equatable +#endif + /// Check that an expression always throws a specific error. /// /// - Parameters: @@ -305,6 +491,54 @@ public macro require( performing expression: () async throws -> R ) -> E? = #externalMacro(module: "TestingMacros", type: "ExpectMacro") where E: Error & Equatable +#if compiler(>=6.2) +/// Check that an expression always throws a specific error, and throw an error +/// if it does not. +/// +/// - Parameters: +/// - error: The error that is expected to be thrown. +/// - comment: A comment describing the expectation. +/// - sourceLocation: The source location to which recorded expectations and +/// issues should be attributed. +/// - expression: The expression to be evaluated. + +/// - Returns: The instance of `E` that was thrown by `expression` and is equal +/// to `error`. +/// +/// - Throws: An instance of ``ExpectationFailedError`` if `expression` does not +/// throw a matching error. The error thrown by `expression` is not rethrown. +/// +/// Use this overload of `#require()` when the expression `expression` _should_ +/// throw a specific error: +/// +/// ```swift +/// try #require(throws: EngineFailureError.batteryDied) { +/// FoodTruck.shared.engine.batteryLevel = 0 +/// try FoodTruck.shared.engine.start() +/// } +/// ``` +/// +/// If `expression` does not throw an error, or if it throws an error that is +/// not equal to `error`, an ``Issue`` is recorded for the test that is running +/// in the current task and an instance of ``ExpectationFailedError`` is thrown. +/// Any value returned by `expression` is discarded. +/// +/// - Note: If you use this macro with a Swift compiler version lower than 6.1, +/// it doesn't return a value. +/// +/// If the thrown error need only be an instance of a particular type, use +/// ``require(throws:_:sourceLocation:performing:)-7n34r`` instead. +@discardableResult +@freestanding(expression) +@_documentation(visibility: private) +public macro require( + throws error: E, + _ comment: @autoclosure () -> Comment? = nil, + sourceLocation: SourceLocation = #_sourceLocation, + performing expression: () throws -> R +) -> E = #externalMacro(module: "TestingMacros", type: "RequireMacro") where E: Error & Equatable +#endif + /// Check that an expression always throws a specific error, and throw an error /// if it does not. /// @@ -351,6 +585,72 @@ public macro require( // MARK: - Arbitrary error matching +#if compiler(>=6.2) +/// Check that an expression always throws an error matching some condition. +/// +/// - Parameters: +/// - comment: A comment describing the expectation. +/// - sourceLocation: The source location to which recorded expectations and +/// issues should be attributed. +/// - expression: The expression to be evaluated. +/// - errorMatcher: A closure to invoke when `expression` throws an error that +/// indicates if it matched or not. +/// +/// - Returns: If the expectation passes, the error that was thrown by +/// `expression`. If the expectation fails, the result is `nil`. +/// +/// Use this overload of `#expect()` when the expression `expression` _should_ +/// throw an error, but the logic to determine if the error matches is complex: +/// +/// ```swift +/// #expect { +/// FoodTruck.shared.engine.batteryLevel = 0 +/// try FoodTruck.shared.engine.start() +/// } throws: { error in +/// return error == EngineFailureError.batteryDied +/// || error == EngineFailureError.stillCharging +/// } +/// ``` +/// +/// If `expression` does not throw an error, if it throws an error that is +/// not matched by `errorMatcher`, or if `errorMatcher` throws an error +/// (including the error passed to it), an ``Issue`` is recorded for the test +/// that is running in the current task. Any value returned by `expression` is +/// discarded. +/// +/// If the thrown error need only be an instance of a particular type, use +/// ``expect(throws:_:sourceLocation:performing:)-1hfms`` instead. If the thrown +/// error need only equal another instance of [`Error`](https://developer.apple.com/documentation/swift/error), +/// use ``expect(throws:_:sourceLocation:performing:)-7du1h`` instead. +/// +/// @Metadata { +/// @Available(Swift, introduced: 6.0) +/// @Available(Xcode, introduced: 16.0) +/// } +/// +/// @DeprecationSummary { +/// Examine the result of ``expect(throws:_:sourceLocation:performing:)-7du1h`` +/// or ``expect(throws:_:sourceLocation:performing:)-1hfms`` instead: +/// +/// ```swift +/// let error = #expect(throws: FoodTruckError.self) { +/// ... +/// } +/// #expect(error?.napkinCount == 0) +/// ``` +/// } +@available(swift, deprecated: 100000.0, message: "Examine the result of '#expect(throws:)' instead.") +@discardableResult +@freestanding(expression) +@_documentation(visibility: private) +public macro expect( + _ comment: @autoclosure () -> Comment? = nil, + sourceLocation: SourceLocation = #_sourceLocation, + performing expression: () throws -> R, + throws errorMatcher: (any Error) throws -> Bool +) -> (any Error)? = #externalMacro(module: "TestingMacros", type: "ExpectMacro") +#endif + /// Check that an expression always throws an error matching some condition. /// /// - Parameters: @@ -413,6 +713,79 @@ public macro require( throws errorMatcher: (any Error) async throws -> Bool ) -> (any Error)? = #externalMacro(module: "TestingMacros", type: "ExpectMacro") +#if compiler(>=6.2) +/// Check that an expression always throws an error matching some condition, and +/// throw an error if it does not. +/// +/// - Parameters: +/// - comment: A comment describing the expectation. +/// - sourceLocation: The source location to which recorded expectations and +/// issues should be attributed. +/// - expression: The expression to be evaluated. +/// - errorMatcher: A closure to invoke when `expression` throws an error that +/// indicates if it matched or not. +/// +/// - Returns: The error that was thrown by `expression`. +/// +/// - Throws: An instance of ``ExpectationFailedError`` if `expression` does not +/// throw a matching error. The error thrown by `expression` is not rethrown. +/// +/// Use this overload of `#require()` when the expression `expression` _should_ +/// throw an error, but the logic to determine if the error matches is complex: +/// +/// ```swift +/// #expect { +/// FoodTruck.shared.engine.batteryLevel = 0 +/// try FoodTruck.shared.engine.start() +/// } throws: { error in +/// return error == EngineFailureError.batteryDied +/// || error == EngineFailureError.stillCharging +/// } +/// ``` +/// +/// If `expression` does not throw an error, if it throws an error that is +/// not matched by `errorMatcher`, or if `errorMatcher` throws an error +/// (including the error passed to it), an ``Issue`` is recorded for the test +/// that is running in the current task and an instance of +/// ``ExpectationFailedError`` is thrown. Any value returned by `expression` is +/// discarded. +/// +/// If the thrown error need only be an instance of a particular type, use +/// ``require(throws:_:sourceLocation:performing:)-7n34r`` instead. If the thrown error need +/// only equal another instance of [`Error`](https://developer.apple.com/documentation/swift/error), +/// use ``require(throws:_:sourceLocation:performing:)-4djuw`` instead. +/// +/// If `expression` should _never_ throw, simply invoke the code without using +/// this macro. The test will then fail if an error is thrown. +/// +/// @Metadata { +/// @Available(Swift, introduced: 6.0) +/// @Available(Xcode, introduced: 16.0) +/// } +/// +/// @DeprecationSummary { +/// Examine the result of ``expect(throws:_:sourceLocation:performing:)-7du1h`` +/// or ``expect(throws:_:sourceLocation:performing:)-1hfms`` instead: +/// +/// ```swift +/// let error = try #require(throws: FoodTruckError.self) { +/// ... +/// } +/// #expect(error.napkinCount == 0) +/// ``` +/// } +@available(swift, deprecated: 100000.0, message: "Examine the result of '#require(throws:)' instead.") +@discardableResult +@freestanding(expression) +@_documentation(visibility: private) +public macro require( + _ comment: @autoclosure () -> Comment? = nil, + sourceLocation: SourceLocation = #_sourceLocation, + performing expression: () throws -> R, + throws errorMatcher: (any Error) throws -> Bool +) -> any Error = #externalMacro(module: "TestingMacros", type: "RequireMacro") +#endif + /// Check that an expression always throws an error matching some condition, and /// throw an error if it does not. ///