Repository: Matejkob/swift-spyable (swift)

Issue:
Support throwing error
If the function can throw:
- add variable `{variablePrefix}ThrowableError: Error?`,
- throw this `error` if is not nil.

Example:
```swift
func foo() throws {
   if fooThrowableError { throw fooThrowableError }
   { ... }
}
```

Pull request:

Patch:
diff --git a/Package.resolved b/Package.resolved
index c4f9794..ab64405 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -5,8 +5,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/apple/swift-syntax.git",
       "state" : {
-        "revision" : "165fc6d22394c1168ff76ab5d951245971ef07e5",
-        "version" : "509.0.0-swift-DEVELOPMENT-SNAPSHOT-2023-06-05-a"
+        "revision" : "f1e9245226002bb134884345d4809b9543da3666",
+        "version" : "509.0.0-swift-DEVELOPMENT-SNAPSHOT-2023-06-17-a"
       }
     }
   ],
diff --git a/Sources/SpyableMacro/Factories/FunctionImplementationFactory.swift b/Sources/SpyableMacro/Factories/FunctionImplementationFactory.swift
index 0850338..4d42cff 100644
--- a/Sources/SpyableMacro/Factories/FunctionImplementationFactory.swift
+++ b/Sources/SpyableMacro/Factories/FunctionImplementationFactory.swift
@@ -9,6 +9,7 @@ import SwiftSyntaxBuilder
 /// - `CallsCountFactory`: to increment the `CallsCount` each time the function is invoked.
 /// - `ReceivedArgumentsFactory`: to update the `ReceivedArguments` with the arguments of the latest invocation.
 /// - `ReceivedInvocationsFactory`: to append the latest invocation to the `ReceivedInvocations` list.
+/// - `ThrowableErrorFactory`: to provide throw `ThrowableError` expression.
 /// - `ClosureFactory`: to generate a closure expression that mirrors the function signature.
 /// - `ReturnValueFactory`: to provide the return statement from the `ReturnValue`.
 ///
@@ -42,10 +43,13 @@ import SwiftSyntaxBuilder
 /// ```
 /// the factory generates:
 /// ```swift
-/// func fetchText() -> String {
+/// func fetchText() async throws -> String {
 ///     fetchTextCallsCount += 1
-///     if let closure = fetchTextClosure {
-///         return try await closure()
+///     if let fetchTextThrowableError {
+///         throw fetchTextThrowableError
+///     }
+///     if fetchTextClosure != nil {
+///         return try await fetchTextClosure!()
 ///     } else {
 ///         return fetchTextReturnValue
 ///     }
@@ -55,6 +59,7 @@ struct FunctionImplementationFactory {
     private let callsCountFactory = CallsCountFactory()
     private let receivedArgumentsFactory = ReceivedArgumentsFactory()
     private let receivedInvocationsFactory = ReceivedInvocationsFactory()
+    private let throwableErrorFactory = ThrowableErrorFactory()
     private let closureFactory = ClosureFactory()
     private let returnValueFactory = ReturnValueFactory()
 
@@ -86,6 +91,10 @@ struct FunctionImplementationFactory {
                     )
                 }
 
+                if protocolFunctionDeclaration.signature.effectSpecifiers?.throwsSpecifier != nil {
+                    throwableErrorFactory.throwErrorExpression(variablePrefix: variablePrefix)
+                }
+
                 if protocolFunctionDeclaration.signature.output == nil {
                     closureFactory.callExpression(
                         variablePrefix: variablePrefix,
diff --git a/Sources/SpyableMacro/Factories/SpyFactory.swift b/Sources/SpyableMacro/Factories/SpyFactory.swift
index 9be6c2f..7f3afe7 100644
--- a/Sources/SpyableMacro/Factories/SpyFactory.swift
+++ b/Sources/SpyableMacro/Factories/SpyFactory.swift
@@ -17,6 +17,8 @@ import SwiftSyntaxBuilder
 ///   These factories produce variables that keep track of how many times a method was called, whether it was called,
 ///   the arguments it was last called with, and all invocations with their arguments respectively.
 ///
+/// - `ThrowableErrorFactory`: It creates a variable for storing a throwing error for a stubbed method.
+///
 /// - `ReturnValueFactory`: It creates a variable for storing a return value for a stubbed method.
 ///
 /// - `ClosureFactory`: It creates a closure variable for every method in the protocol, allowing the spy to
@@ -40,7 +42,7 @@ import SwiftSyntaxBuilder
 /// ```swift
 /// protocol ServiceProtocol {
 ///     var data: Data { get }
-///     func fetch(text: String, count: Int) async -> Decimal
+///     func fetch(text: String, count: Int) async throws -> Decimal
 /// }
 /// ```
 /// the factory generates:
@@ -58,15 +60,19 @@ import SwiftSyntaxBuilder
 ///     }
 ///     var fetchTextCountReceivedArguments: (text: String, count: Int)?
 ///     var fetchTextCountReceivedInvocations: [(text: String, count: Int)] = []
+///     var fetchTextCountThrowableError: Error?
 ///     var fetchTextCountReturnValue: Decimal!
-///     var fetchTextCountClosure: ((String, Int) async -> Decimal)?
+///     var fetchTextCountClosure: ((String, Int) async throws -> Decimal)?
 ///
-///     func fetch(text: String, count: Int) async -> Decimal {
+///     func fetch(text: String, count: Int) async throws -> Decimal {
 ///         fetchTextCountCallsCount += 1
 ///         fetchTextCountReceivedArguments = (text, count)
 ///         fetchTextCountReceivedInvocations.append((text, count))
+///         if let fetchTextCountThrowableError {
+///             throw fetchTextCountThrowableError
+///         }
 ///         if fetchTextCountClosure != nil {
-///             return await fetchTextCountClosure!(text, count)
+///             return try await fetchTextCountClosure!(text, count)
 ///         } else {
 ///             return fetchTextCountReturnValue
 ///         }
@@ -80,6 +86,7 @@ struct SpyFactory {
     private let calledFactory = CalledFactory()
     private let receivedArgumentsFactory = ReceivedArgumentsFactory()
     private let receivedInvocationsFactory = ReceivedInvocationsFactory()
+    private let throwableErrorFactory = ThrowableErrorFactory()
     private let returnValueFactory = ReturnValueFactory()
     private let closureFactory = ClosureFactory()
     private let functionImplementationFactory = FunctionImplementationFactory()
@@ -125,6 +132,10 @@ struct SpyFactory {
                         )
                     }
 
+                    if functionDeclaration.signature.effectSpecifiers?.throwsSpecifier != nil {
+                        throwableErrorFactory.variableDeclaration(variablePrefix: variablePrefix)
+                    }
+
                     if let returnType = functionDeclaration.signature.output?.returnType {
                         returnValueFactory.variableDeclaration(
                             variablePrefix: variablePrefix,
diff --git a/Sources/SpyableMacro/Factories/ThrowableErrorFactory.swift b/Sources/SpyableMacro/Factories/ThrowableErrorFactory.swift
new file mode 100644
index 0000000..69bed32
--- /dev/null
+++ b/Sources/SpyableMacro/Factories/ThrowableErrorFactory.swift
@@ -0,0 +1,77 @@
+import SwiftSyntax
+import SwiftSyntaxBuilder
+
+/// The `ThrowableErrorFactory` is designed to generate a representation of a Swift
+/// variable declaration that simulates a throwable error. It is useful for testing how your code
+/// handles errors thrown by a function.
+///
+/// The factory generates an optional variable of type `Error`. The name of the variable
+/// is constructed by appending the word "ThrowableError" to the `variablePrefix` parameter.
+///
+/// The factory also generates an if-let statement that checks whether the variable is not `nil` and
+/// throws it as an error if it isn't. This allows you to simulate the throwing of an error in a function.
+///
+/// The following code:
+/// ```swift
+/// var fooThrowableError: Error?
+///
+/// if let fooThrowableError {
+///     throw fooThrowableError
+/// }
+/// ```
+/// would be generated for a function like this:
+/// ```swift
+/// func foo() throws
+/// ```
+/// and an argument `variablePrefix` equal to `foo`.
+///
+/// - Note: The `ThrowableErrorFactory` gives you control over the errors that a function throws in
+///         your tests. You can use it to simulate different scenarios and verify that your code handles
+///         errors correctly.
+struct ThrowableErrorFactory {
+    func variableDeclaration(variablePrefix: String) -> VariableDeclSyntax {
+        VariableDeclSyntax(
+            bindingKeyword: .keyword(.var),
+            bindingsBuilder: {
+                PatternBindingSyntax(
+                    pattern: IdentifierPatternSyntax(
+                        identifier: variableIdentifier(variablePrefix: variablePrefix)
+                    ),
+                    typeAnnotation: TypeAnnotationSyntax(
+                        type: OptionalTypeSyntax(
+                            wrappedType: SimpleTypeIdentifierSyntax(name: .identifier("Error"))
+                        )
+                    )
+                )
+            }
+        )
+    }
+
+    func throwErrorExpression(variablePrefix: String) -> IfExprSyntax {
+        IfExprSyntax(
+            conditions: ConditionElementListSyntax {
+                ConditionElementSyntax(
+                    condition: .optionalBinding(
+                        OptionalBindingConditionSyntax(
+                           bindingKeyword: .keyword(.let),
+                           pattern: IdentifierPatternSyntax(
+                               identifier: variableIdentifier(variablePrefix: variablePrefix)
+                           )
+                       )
+                    )
+                )
+            },
+            bodyBuilder: {
+                ThrowStmtSyntax(
+                    expression: IdentifierExprSyntax(
+                        identifier: variableIdentifier(variablePrefix: variablePrefix)
+                    )
+                )
+            }
+        )
+    }
+
+    private func variableIdentifier(variablePrefix: String) -> TokenSyntax {
+        TokenSyntax.identifier(variablePrefix + "ThrowableError")
+    }
+}


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 123e7bd..93fa6d2 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -17,7 +17,7 @@ jobs:
       - name: Install Swift
         uses: slashmo/install-swift@v0.4.0
         with:
-          version: swift-5.9-DEVELOPMENT-SNAPSHOT-2023-06-16-a
+          version: swift-5.9-DEVELOPMENT-SNAPSHOT-2023-06-17-a
       - name: Build
         run: |
           export TOOLCHAINS=swift
diff --git a/Tests/SpyableMacroTests/Factories/UT_FunctionImplementationFactory.swift b/Tests/SpyableMacroTests/Factories/UT_FunctionImplementationFactory.swift
index 8aef167..1eecd28 100644
--- a/Tests/SpyableMacroTests/Factories/UT_FunctionImplementationFactory.swift
+++ b/Tests/SpyableMacroTests/Factories/UT_FunctionImplementationFactory.swift
@@ -97,6 +97,9 @@ final class UT_FunctionImplementationFactory: XCTestCase {
                 fooCallsCount += 1
                 fooReceivedBar = (bar)
                 fooReceivedInvocations.append((bar))
+                if let fooThrowableError {
+                    throw fooThrowableError
+                }
                 if fooClosure != nil {
                     return try await fooClosure!(bar)
                 } else {
diff --git a/Tests/SpyableMacroTests/Factories/UT_SpyFactory.swift b/Tests/SpyableMacroTests/Factories/UT_SpyFactory.swift
index 69eaac0..eb1e8d3 100644
--- a/Tests/SpyableMacroTests/Factories/UT_SpyFactory.swift
+++ b/Tests/SpyableMacroTests/Factories/UT_SpyFactory.swift
@@ -184,12 +184,16 @@ final class UT_SpyFactory: XCTestCase {
                 }
                 var fooReceivedAdded: ((text: String) -> Void)?
                 var fooReceivedInvocations: [((text: String) -> Void)?] = []
+                var fooThrowableError: Error?
                 var fooReturnValue: (() -> Int)?
                 var fooClosure: ((((text: String) -> Void)?) throws -> (() -> Int)?)?
                 func foo(_ added: ((text: String) -> Void)?) throws -> (() -> Int)? {
                     fooCallsCount += 1
                     fooReceivedAdded = (added)
                     fooReceivedInvocations.append((added))
+                    if let fooThrowableError {
+                        throw fooThrowableError
+                    }
                     if fooClosure != nil {
                         return try fooClosure!(added)
                     } else {
diff --git a/Tests/SpyableMacroTests/Factories/UT_ThrowableErrorFactory.swift b/Tests/SpyableMacroTests/Factories/UT_ThrowableErrorFactory.swift
new file mode 100644
index 0000000..ebe766d
--- /dev/null
+++ b/Tests/SpyableMacroTests/Factories/UT_ThrowableErrorFactory.swift
@@ -0,0 +1,34 @@
+import XCTest
+@testable import SpyableMacro
+import SwiftSyntax
+
+final class UT_ThrowableErrorFactory: XCTestCase {
+    func testVariableDeclaration() {
+        let variablePrefix = "functionName"
+
+        let result = ThrowableErrorFactory().variableDeclaration(variablePrefix: variablePrefix)
+
+        assertBuildResult(
+            result,
+            """
+            var functionNameThrowableError: Error?
+            """
+        )
+    }
+
+    func testThrowErrorExpression() {
+        let variablePrefix = "function_name"
+
+        let result = ThrowableErrorFactory().throwErrorExpression(variablePrefix: variablePrefix)
+
+        assertBuildResult(
+            result,
+            """
+            if let function_nameThrowableError {
+                throw function_nameThrowableError
+            }
+            """
+        )
+    }
+
+}
diff --git a/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift b/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift
index 5a92964..8ca4315 100644
--- a/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift
+++ b/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift
@@ -90,10 +90,14 @@ final class UT_SpyableMacro: XCTestCase {
                 var fetchConfigCalled: Bool {
                     return fetchConfigCallsCount > 0
                 }
+                var fetchConfigThrowableError: Error?
                 var fetchConfigReturnValue: [String: String]!
                 var fetchConfigClosure: (() async throws -> [String: String])?
                     func fetchConfig() async throws -> [String: String] {
                     fetchConfigCallsCount += 1
+                    if let fetchConfigThrowableError {
+                        throw fetchConfigThrowableError
+                    }
                     if fetchConfigClosure != nil {
                         return try await fetchConfigClosure!()
                     } else {