From ac1b89b39df4caafba585c8ef614feab7dea8992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ba=CC=A8k?= <44930823+Matejkob@users.noreply.github.com> Date: Wed, 14 Jun 2023 16:18:44 +0200 Subject: [PATCH] Add documentation --- README.md | 140 ++++++++++++++++++ Sources/Spyable/Spyable.swift | 53 +++++++ .../Diagnostics/SpyableDiagnostic.swift | 7 + .../SpyableMacro/Extractors/Extractor.swift | 8 + .../Factories/CalledFactory.swift | 23 +++ .../Factories/CallsCountFactory.swift | 19 +++ .../Factories/ClosureFactory.swift | 31 +++- .../FunctionImplementationFactory.swift | 50 +++++++ .../Factories/ReceivedArgumentsFactory.swift | 34 +++++ .../ReceivedInvocationsFactory.swift | 39 +++++ .../Factories/ReturnValueFactory.swift | 36 +++++ .../SpyableMacro/Factories/SpyFactory.swift | 72 +++++++++ .../VariablesImplementationFactory.swift | 39 +++++ Sources/SpyableMacro/Macro/SpyableMacro.swift | 21 ++- 14 files changed, 568 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e88bd27..01f292e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,142 @@ # Spyable +A powerful tool for Swift that simplifies and automates the process of creating spies +for testing. Using the `@Spyable` annotation on a protocol, the macro generates +a spy class that implements the same interface as the protocol and keeps track of +interactions with its methods and properties. + +## Overview + +A "spy" is a specific type of test double that not only replaces a real component, but also +records all interactions for later inspection. It's particularly useful in behavior verification, +where the interaction between objects, rather than the state, is the subject of the test. + +The Spyable macro is designed to simplify and enhance the usage of spies in Swift testing. +Traditionally, developers would need to manually create spies for each protocol in their +codebase — a tedious and error-prone task. The Spyable macro revolutionizes this process +by automatically generating these spies. + +When a protocol is annotated with `@Spyable`, the macro generates a corresponding spy class that +implement this protocol. This spy class is capable of tracking all interactions with its methods +and properties. It records method invocations, their arguments, and returned values, providing +a comprehensive log of interactions that occurred during the test. This data can then be used +to make precise assertions about the behavior of the system under test. + +**TL;DR** + +The Spyable macro provides the following functionality: +- **Automatic Spy Generation**: No need to manually create spy classes for each protocol. + Just annotate the protocol with `@Spyable`, and let the macro do the rest. +- **Interaction Tracking**: The generated spy records method calls, arguments, and return + values, making it easy to verify behavior in your tests. +- **Swift Syntax**: The macro uses Swift syntax, providing a seamless and familiar experience + for Swift developers. + +## Quick start + +To get started, annotate your protocol with `@Spyable`: + +```swift +@Spyable +protocol ServiceProtocol { + var name: String { get } + func fetchConfig(arg: UInt8) async throws -> [String: String] +} +``` + +This will generate a spy class named `ServiceProtocolSpy` that implements `ServiceProtocol`. +The generated class includes properties and methods for tracking the number of method calls, +the arguments passed, and whether the method was called. + +```swift +class ServiceProtocolSpy: ServiceProtocol { + var name: String { + get { underlyingName } + set { underlyingName = newValue } + } + var underlyingName: (String )! + + var fetchConfigArgCallsCount = 0 + var fetchConfigArgCalled: Bool { + return fetchConfigArgCallsCount > 0 + } + var fetchConfigArgReceivedArg: UInt8? + var fetchConfigArgReceivedInvocations: [UInt8] = [] + var fetchConfigArgReturnValue: [String: String]! + var fetchConfigArgClosure: ((UInt8) async throws -> [String: String])? + + func fetchConfig(arg: UInt8) async throws -> [String: String] { + fetchConfigArgCallsCount += 1 + fetchConfigArgReceivedArg = (arg) + fetchConfigArgReceivedInvocations.append((arg)) + if fetchConfigArgClosure != nil { + return try await fetchConfigArgClosure!(arg) + } else { + return fetchConfigArgReturnValue + } + } +} +``` + +Then, in your tests, you can use the spy to verify that your code is interacting +with the `service` dependency of type `ServiceProtocol` correctly: + +```swift +func testFetchConfig() async throws { + let serviceSpy = ServiceProtocolSpy() + let sut = ViewModel(service: serviceSpy) + + serviceSpy.fetchConfigArgReturnValue = ["key": "value"] + + try await sut.fetchConfig() + + XCTAssertEqual(serviceSpy.fetchConfigArgCallsCount, 1) + XCTAssertEqual(serviceSpy.fetchConfigArgReceivedInvocations, [1]) + + try await sut.saveConfig() + + XCTAssertEqual(serviceSpy.fetchConfigArgCallsCount, 2) + XCTAssertEqual(serviceSpy.fetchConfigArgReceivedInvocations, [1, 1]) +} +``` + +## Examples + +This repo comes with an example of how to use Spyable. You can find it [here](./Examples). + +## Documentation + +Soon. + + + +## Installation + +> Warning: Xcode beta 15.x command line tools are required. + +**For Xcode project** + +If you are using Xcode beta 15.x command line tools, you can add + [swift-spyable](https://github.com/Matejkob/swift-spyable) macro to your project as a package. + +> `https://github.com/Matejkob/swift-spyable` + +**For Swift Package Manager** + +In `Package.swift` add: + +``` swift +dependencies: [ + .package(url: "https://github.com/Matejkob/swift-spyable", from: "0.1.0") +] +``` + +and then add the product to any target that needs access to the macro: + +```swift +.product(name: "Spyable", package: "swift-spyable"), +``` + +## License + +This library is released under the MIT license. See [LICENSE](LICENSE) for details. diff --git a/Sources/Spyable/Spyable.swift b/Sources/Spyable/Spyable.swift index 6f65303..2d8183b 100644 --- a/Sources/Spyable/Spyable.swift +++ b/Sources/Spyable/Spyable.swift @@ -1,3 +1,56 @@ +/// The `@Spyable` macro generates a test spy class for the protocol to which it is attached. +/// A spy is a type of test double that observes and records interactions for later verification in your tests. +/// +/// The `@Spyable` macro simplifies the task of writing test spies manually. It automatically generates a new +/// class (the spy) that implements the given protocol. It tracks and exposes information about how the protocol's +/// methods and properties were used, providing valuable insight for test assertions. +/// +/// Usage: +/// ```swift +/// @Spyable +/// protocol ServiceProtocol { +/// var data: Data { get } +/// func fetchData(id: String) -> Data +/// } +/// ``` +/// +/// This example would generate a spy class named `ServiceProtocolSpy` that implements `ServiceProtocol`. +/// The generated class includes properties and methods for tracking the number of method calls, the arguments +/// passed, whether the method was called, and so on. +/// +/// Example of generated code: +/// ```swift +/// class ServiceProtocolSpy: ServiceProtocol { +/// var data: Data { +/// get { underlyingData } +/// set { underlyingData = newValue } +/// } +/// var underlyingData: Data! +/// +/// var fetchDataIdCallsCount = 0 +/// var fetchDataIdCalled: Bool { +/// return fetchDataIdCallsCount > 0 +/// } +/// var fetchDataIdReceivedArguments: String? +/// var fetchDataIdReceivedInvocations: [String] = [] +/// var fetchDataIdReturnValue: Data! +/// var fetchDataIdClosure: ((String) -> Data)? +/// +/// func fetchData(id: String) -> Data { +/// fetchDataIdCallsCount += 1 +/// fetchDataIdReceivedArguments = id +/// fetchDataIdReceivedInvocations.append(id) +/// if fetchDataIdClosure != nil { +/// return fetchDataIdClosure!(id) +/// } else { +/// return fetchDataIdReturnValue +/// } +/// } +/// } +/// ``` +/// +/// - NOTE: The `@Spyable` macro should only be applied to protocols. Applying it to other +/// declarations will result in an error. @attached(peer, names: arbitrary) public macro Spyable() -> () = #externalMacro( module: "SpyableMacro", diff --git a/Sources/SpyableMacro/Diagnostics/SpyableDiagnostic.swift b/Sources/SpyableMacro/Diagnostics/SpyableDiagnostic.swift index ed2dbb3..bfe4b22 100644 --- a/Sources/SpyableMacro/Diagnostics/SpyableDiagnostic.swift +++ b/Sources/SpyableMacro/Diagnostics/SpyableDiagnostic.swift @@ -1,5 +1,12 @@ import SwiftDiagnostics +/// `SpyableDiagnostic` is an enumeration defining specific error messages related to the Spyable system. +/// +/// It conforms to the `DiagnosticMessage` and `Error` protocols to provide comprehensive error information +/// and integrate smoothly with error handling mechanisms. +/// +/// - Note: The `SpyableDiagnostic` enum can be expanded to include more diagnostic cases as +/// the Spyable system grows and needs to handle more error types. enum SpyableDiagnostic: String, DiagnosticMessage, Error { case onlyApplicableToProtocol diff --git a/Sources/SpyableMacro/Extractors/Extractor.swift b/Sources/SpyableMacro/Extractors/Extractor.swift index 63b5025..fe7a274 100644 --- a/Sources/SpyableMacro/Extractors/Extractor.swift +++ b/Sources/SpyableMacro/Extractors/Extractor.swift @@ -1,5 +1,13 @@ import SwiftSyntax +/// `Extractor` is designed to extract a `ProtocolDeclSyntax` instance +/// from a given `DeclSyntaxProtocol` instance. +/// +/// It contains a single method, `extractProtocolDeclaration(from:)`, which +/// attempts to cast the input `DeclSyntaxProtocol` into a `ProtocolDeclSyntax`. +/// If the cast is successful, the method returns the `ProtocolDeclSyntax`. If the cast fails, +/// meaning the input declaration is not a protocol declaration, the method throws +/// a `SpyableDiagnostic.onlyApplicableToProtocol` error. struct Extractor { func extractProtocolDeclaration(from declaration: DeclSyntaxProtocol) throws -> ProtocolDeclSyntax { guard let protocolDeclaration = declaration.as(ProtocolDeclSyntax.self) else { diff --git a/Sources/SpyableMacro/Factories/CalledFactory.swift b/Sources/SpyableMacro/Factories/CalledFactory.swift index b5796c3..178c73f 100644 --- a/Sources/SpyableMacro/Factories/CalledFactory.swift +++ b/Sources/SpyableMacro/Factories/CalledFactory.swift @@ -1,6 +1,29 @@ import SwiftSyntax import SwiftSyntaxBuilder +/// The `CalledFactory` is designed to generate a representation of a Swift variable +/// declaration to track if a certain function has been called. +/// +/// The resulting variable's of type Bool and its name is constructed by appending +/// the word "Called" to the `variablePrefix` parameter. This variable uses a getter +/// that checks whether another variable (with the name `variablePrefix` + "CallsCount") +/// is greater than zero. If so, the getter returns true, indicating the function has been called, +/// otherwise it returns false. +/// +/// > Important: The factory assumes the existence of a variable named `variablePrefix + "CallsCount"`, +/// which should keep track of the number of times a function has been called. +/// +/// The following code: +/// ```swift +/// var fooCalled: Bool { +/// return fooCallsCount > 0 +/// } +/// ``` +/// would be generated for a function like this: +/// ```swift +/// func foo() +/// ``` +/// and an argument `variablePrefix` equal to `foo`. struct CalledFactory { func variableDeclaration(variablePrefix: String) -> VariableDeclSyntax { VariableDeclSyntax( diff --git a/Sources/SpyableMacro/Factories/CallsCountFactory.swift b/Sources/SpyableMacro/Factories/CallsCountFactory.swift index ce3cc1b..d3d6806 100644 --- a/Sources/SpyableMacro/Factories/CallsCountFactory.swift +++ b/Sources/SpyableMacro/Factories/CallsCountFactory.swift @@ -1,6 +1,25 @@ import SwiftSyntax import SwiftSyntaxBuilder +/// The `CallsCountFactory` is designed to generate a representation of a Swift variable +/// declaration and its associated increment operation. These constructs are typically used to +/// track the number of times a specific function has been called during the execution of a test case. +/// +/// The resulting variable's of type integer variable with an initial value of 0. It's name +/// is constructed by appending "CallsCount" to the `variablePrefix` parameter. +/// The factory's also generating an expression that increments the count of a variable. +/// +/// The following code: +/// ```swift +/// var fooCallsCount = 0 +/// +/// fooCallsCount += 1 +/// ``` +/// would be generated for a function like this: +/// ```swift +/// func foo() +/// ``` +/// and an argument `variablePrefix` equal to `foo`. struct CallsCountFactory { func variableDeclaration(variablePrefix: String) -> VariableDeclSyntax { VariableDeclSyntax( diff --git a/Sources/SpyableMacro/Factories/ClosureFactory.swift b/Sources/SpyableMacro/Factories/ClosureFactory.swift index b45facd..ec0f122 100644 --- a/Sources/SpyableMacro/Factories/ClosureFactory.swift +++ b/Sources/SpyableMacro/Factories/ClosureFactory.swift @@ -1,8 +1,37 @@ import SwiftSyntax import SwiftSyntaxBuilder +/// The `ClosureFactory` is designed to generate a representation of a Swift +/// variable declaration for a closure, as well as the invocation of this closure. +/// +/// The generated variable represents a closure that corresponds to a given function +/// signature. The name of the variable is constructed by appending the word "Closure" +/// to the `variablePrefix` parameter. +/// +/// The factory also generates a call expression that executes the closure using the names +/// of the parameters from the function signature. +/// +/// The following code: +/// ```swift +/// var fooClosure: ((String, Int) async throws -> Data)? +/// +/// try await fooClosure!(text, count) +/// ``` +/// would be generated for a function like this: +/// ```swift +/// func foo(text: String, count: Int) async throws -> Data +/// ``` +/// and an argument `variablePrefix` equal to `foo`. +/// +/// - Note: The `ClosureFactory` is useful in scenarios where you need to mock the +/// behavior of a function, particularly for testing purposes. You can use it to define +/// the behavior of the function under different conditions, and validate that your code +/// interacts correctly with the function. struct ClosureFactory { - func variableDeclaration(variablePrefix: String, functionSignature: FunctionSignatureSyntax) -> VariableDeclSyntax { + func variableDeclaration( + variablePrefix: String, + functionSignature: FunctionSignatureSyntax + ) -> VariableDeclSyntax { let elements = TupleTypeElementListSyntax { TupleTypeElementSyntax( type: FunctionTypeSyntax( diff --git a/Sources/SpyableMacro/Factories/FunctionImplementationFactory.swift b/Sources/SpyableMacro/Factories/FunctionImplementationFactory.swift index 50a64f1..0850338 100644 --- a/Sources/SpyableMacro/Factories/FunctionImplementationFactory.swift +++ b/Sources/SpyableMacro/Factories/FunctionImplementationFactory.swift @@ -1,6 +1,56 @@ import SwiftSyntax import SwiftSyntaxBuilder +/// The `FunctionImplementationFactory` is designed to generate Swift function declarations +/// based on protocol function declarations. It enriches the declarations with functionality that tracks +/// function invocations, received arguments, and return values. +/// +/// It leverages multiple other factories to generate components of the function body: +/// - `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. +/// - `ClosureFactory`: to generate a closure expression that mirrors the function signature. +/// - `ReturnValueFactory`: to provide the return statement from the `ReturnValue`. +/// +/// If the function doesn't have output, the factory uses the `ClosureFactory` to generate a call expression, +/// otherwise, it generates an `IfExprSyntax` that checks whether a closure is set for the function. +/// If the closure is set, it is called and its result is returned, else it returns the value from the `ReturnValueFactory`. +/// +/// > Important: This factory assumes that certain variables exist to store the tracking data: +/// > - `CallsCount`: A variable that tracks the number of times the function has been invoked. +/// > - `ReceivedArguments`: A variable to store the arguments that were passed in the latest function call. +/// > - `ReceivedInvocations`: A list to record all invocations of the function. +/// > - `ReturnValue`: A variable to hold the return value of the function. +/// +/// For example, given a protocol function: +/// ```swift +/// func display(text: String) +/// ``` +/// the `FunctionImplementationFactory` generates the following function declaration: +/// ```swift +/// func display(text: String) { +/// displayCallsCount += 1 +/// displayReceivedArguments = text +/// displayReceivedInvocations.append(text) +/// displayClosure?(text) +/// } +/// ``` +/// +/// And for a protocol function with return type: +/// ```swift +/// func fetchText() async throws -> String +/// ``` +/// the factory generates: +/// ```swift +/// func fetchText() -> String { +/// fetchTextCallsCount += 1 +/// if let closure = fetchTextClosure { +/// return try await closure() +/// } else { +/// return fetchTextReturnValue +/// } +/// } +/// ``` struct FunctionImplementationFactory { private let callsCountFactory = CallsCountFactory() private let receivedArgumentsFactory = ReceivedArgumentsFactory() diff --git a/Sources/SpyableMacro/Factories/ReceivedArgumentsFactory.swift b/Sources/SpyableMacro/Factories/ReceivedArgumentsFactory.swift index f163a91..82c75f4 100644 --- a/Sources/SpyableMacro/Factories/ReceivedArgumentsFactory.swift +++ b/Sources/SpyableMacro/Factories/ReceivedArgumentsFactory.swift @@ -1,6 +1,40 @@ import SwiftSyntax import SwiftSyntaxBuilder +/// The `ReceivedArgumentsFactory` is designed to generate a representation of a Swift +/// variable declaration to keep track of the arguments that are passed to a certain function. +/// +/// The resulting variable's type is either the same as the type of the single parameter of the function, +/// or a tuple type of all parameters' types if the function has multiple parameters. +/// The variable is of optional type, and its name is constructed by appending the word "Received" +/// and the parameter name (with the first letter capitalized) to the `variablePrefix` parameter. +/// If the function has multiple parameters, "Arguments" is appended instead. +/// +/// The factory also generates an expression that assigns a tuple of parameter identifiers to the variable. +/// +/// The following code: +/// ```swift +/// var fooReceivedText: String? +/// +/// fooReceivedText = text +/// ``` +/// would be generated for a function like this: +/// ```swift +/// func foo(text: String) +/// ``` +/// and an argument `variablePrefix` equal to `foo`. +/// +/// For a function with multiple parameters, the factory generates a tuple: +/// ```swift +/// var barReceivedArguments: (text: String, count: Int)? +/// +/// barReceivedArguments = (text, count) +/// ``` +/// for a function like this: +/// ```swift +/// func bar(text: String, count: Int) +/// ``` +/// and an argument `variablePrefix` equal to `bar`. struct ReceivedArgumentsFactory { func variableDeclaration(variablePrefix: String, parameterList: FunctionParameterListSyntax) -> VariableDeclSyntax { let identifier = variableIdentifier(variablePrefix: variablePrefix, parameterList: parameterList) diff --git a/Sources/SpyableMacro/Factories/ReceivedInvocationsFactory.swift b/Sources/SpyableMacro/Factories/ReceivedInvocationsFactory.swift index 8ddfda0..315a667 100644 --- a/Sources/SpyableMacro/Factories/ReceivedInvocationsFactory.swift +++ b/Sources/SpyableMacro/Factories/ReceivedInvocationsFactory.swift @@ -1,6 +1,45 @@ import SwiftSyntax import SwiftSyntaxBuilder +/// The `ReceivedInvocationsFactory` is designed to generate a representation of a Swift +/// variable declaration to keep track of the arguments passed to a certain function each time it is called. +/// +/// The resulting variable is an array, where each element either corresponds to a single function parameter +/// or is a tuple of all parameters if the function has multiple parameters. The variable's name is constructed +/// by appending the word "ReceivedInvocations" to the `variablePrefix` parameter. +/// +/// The factory also generates an expression that appends a tuple of parameter identifiers to the variable +/// each time the function is invoked. +/// +/// The following code: +/// ```swift +/// var fooReceivedInvocations: [String] = [] +/// +/// fooReceivedInvocations.append(text) +/// ``` +/// would be generated for a function like this: +/// ```swift +/// func foo(text: String) +/// ``` +/// and an argument `variablePrefix` equal to `foo`. +/// +/// For a function with multiple parameters, the factory generates an array of tuples: +/// ```swift +/// var barReceivedInvocations: [(text: String, count: Int)] = [] +/// +/// barReceivedInvocations.append((text, count)) +/// ``` +/// for a function like this: +/// ```swift +/// func bar(text: String, count: Int) +/// ``` +/// and an argument `variablePrefix` equal to `bar`. +/// +/// - Note: While the `ReceivedInvocationsFactory` keeps track of every individual invocation of a function +/// and the arguments passed in each invocation, the `ReceivedArgumentsFactory` only keeps track +/// of the arguments received in the last invocation of the function. If you want to test a function where the +/// order and number of invocations matter, use `ReceivedInvocationsFactory`. If you only care +/// about the arguments in the last invocation, use `ReceivedArgumentsFactory`. struct ReceivedInvocationsFactory { func variableDeclaration(variablePrefix: String, parameterList: FunctionParameterListSyntax) -> VariableDeclSyntax { let identifier = variableIdentifier(variablePrefix: variablePrefix) diff --git a/Sources/SpyableMacro/Factories/ReturnValueFactory.swift b/Sources/SpyableMacro/Factories/ReturnValueFactory.swift index 496af8e..2e8c504 100644 --- a/Sources/SpyableMacro/Factories/ReturnValueFactory.swift +++ b/Sources/SpyableMacro/Factories/ReturnValueFactory.swift @@ -1,6 +1,42 @@ import SwiftSyntax import SwiftSyntaxBuilder +/// The `ReturnValueFactory` is designed to generate a representation of a Swift +/// variable declaration to store the return value of a certain function. +/// +/// The generated variable type is implicitly unwrapped optional if the function has a non-optional +/// return type, otherwise, the type is the same as the function's return type. The name of the variable +/// is constructed by appending the word "ReturnValue" to the `variablePrefix` parameter. +/// +/// The factory also generates a return statement that uses the stored value as the return value of the function. +/// +/// The following code: +/// ```swift +/// var fooReturnValue: Int! +/// +/// return fooReturnValue +/// ``` +/// would be generated for a function like this: +/// ```swift +/// func foo() -> Int +/// ``` +/// and an argument `variablePrefix` equal to `foo`. +/// +/// If the return type of the function is optional, the generated variable type is the same as the return type: +/// ```swift +/// var barReturnValue: String? +/// +/// return barReturnValue +/// ``` +/// for a function like this: +/// ```swift +/// func bar() -> String? +/// ``` +/// and an argument `variablePrefix` equal to `bar`. +/// +/// - Note: The `ReturnValueFactory` allows you to specify the return value for a function in +/// your tests. You can use it to simulate different scenarios and verify that your code reacts +/// correctly to different returned values. struct ReturnValueFactory { func variableDeclaration(variablePrefix: String, functionReturnType: TypeSyntax) -> VariableDeclSyntax { VariableDeclSyntax( diff --git a/Sources/SpyableMacro/Factories/SpyFactory.swift b/Sources/SpyableMacro/Factories/SpyFactory.swift index 4667ed1..9be6c2f 100644 --- a/Sources/SpyableMacro/Factories/SpyFactory.swift +++ b/Sources/SpyableMacro/Factories/SpyFactory.swift @@ -1,6 +1,78 @@ import SwiftSyntax import SwiftSyntaxBuilder +/// `SpyFactory` is a factory that creates a test spy for a given protocol. A spy is a type of test double +/// that captures method and property interactions for later verification. The `SpyFactory` creates a new +/// class that implements the given protocol and keeps track of interactions with its properties and methods. +/// +/// The `SpyFactory` utilizes several other factories, each with its own responsibilities: +/// +/// - `VariablePrefixFactory`: It creates unique prefixes for variable names based on the function +/// signatures. This helps to avoid naming conflicts when creating the spy class. +/// +/// - `VariablesImplementationFactory`: It is responsible for generating the actual variable declarations +/// within the spy class. It creates declarations for properties found in the protocol. +/// +/// - `CallsCountFactory`, `CalledFactory`, `ReceivedArgumentsFactory`, `ReceivedInvocationsFactory`: +/// 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. +/// +/// - `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 +/// define custom behavior for each method. +/// +/// - `FunctionImplementationFactory`: It generates function declarations for the spy class, each function will +/// manipulate the corresponding variables (calls count, received arguments etc.) and then call the respective +/// closure if it exists. +/// +/// The `SpyFactory` generates the spy class by first iterating over each property in the protocol and creating +/// corresponding variable declarations using the `VariablesImplementationFactory`. +/// +/// Next, it iterates over each method in the protocol. For each method, it uses the `VariablePrefixFactory` to +/// create a unique prefix for that method. Then, it uses other factories to generate a set of variables for that +/// method and a method implementation using the `FunctionImplementationFactory`. +/// +/// The result is a spy class that implements the same interface as the protocol and keeps track of interactions +/// with its methods and properties. +/// +/// For example, given a protocol: +/// ```swift +/// protocol ServiceProtocol { +/// var data: Data { get } +/// func fetch(text: String, count: Int) async -> Decimal +/// } +/// ``` +/// the factory generates: +/// ```swift +/// class ServiceProtocolSpy: ServiceProtocol { +/// var data: Data { +/// get { underlyingData } +/// set { underlyingData = newValue } +/// } +/// var underlyingData: Data! +/// +/// var fetchTextCountCallsCount = 0 +/// var fetchTextCountCalled: Bool { +/// return fetchTextCountCallsCount > 0 +/// } +/// var fetchTextCountReceivedArguments: (text: String, count: Int)? +/// var fetchTextCountReceivedInvocations: [(text: String, count: Int)] = [] +/// var fetchTextCountReturnValue: Decimal! +/// var fetchTextCountClosure: ((String, Int) async -> Decimal)? +/// +/// func fetch(text: String, count: Int) async -> Decimal { +/// fetchTextCountCallsCount += 1 +/// fetchTextCountReceivedArguments = (text, count) +/// fetchTextCountReceivedInvocations.append((text, count)) +/// if fetchTextCountClosure != nil { +/// return await fetchTextCountClosure!(text, count) +/// } else { +/// return fetchTextCountReturnValue +/// } +/// } +/// } +/// ``` struct SpyFactory { private let variablePrefixFactory = VariablePrefixFactory() private let variablesImplementationFactory = VariablesImplementationFactory() diff --git a/Sources/SpyableMacro/Factories/VariablesImplementationFactory.swift b/Sources/SpyableMacro/Factories/VariablesImplementationFactory.swift index 6c35e6e..5780d97 100644 --- a/Sources/SpyableMacro/Factories/VariablesImplementationFactory.swift +++ b/Sources/SpyableMacro/Factories/VariablesImplementationFactory.swift @@ -1,6 +1,45 @@ import SwiftSyntax import SwiftSyntaxBuilder +/// The `VariablesImplementationFactory` is designed to generate Swift variable declarations +/// that mirror the variable declarations of a protocol, but with added getter and setter functionality. +/// +/// It takes a `VariableDeclSyntax` instance from a protocol as input and generates two kinds +/// of variable declarations for non-optional type variables: +/// 1. A variable declaration that is a copy of the protocol variable, but with explicit getter and setter +/// accessors that link it to an underlying variable. +/// 2. A variable declaration for the underlying variable that is used in the getter and setter of the protocol variable. +/// +/// For optional type variables, the factory simply returns the original variable declaration without accessors. +/// +/// The name of the underlying variable is created by appending the name of the protocol variable to the word "underlying", +/// with the first character of the protocol variable name capitalized. The type of the underlying variable is always +/// implicitly unwrapped optional to handle the non-optional protocol variables. +/// +/// For example, given a non-optional protocol variable: +/// ```swift +/// var text: String { get set } +/// ``` +/// the `VariablesImplementationFactory` generates the following declarations: +/// ```swift +/// var text: String { +/// get { underlyingText } +/// set { underlyingText = newValue } +/// } +/// var underlyingText: String! +/// ``` +/// And for an optional protocol variable: +/// ```swift +/// var text: String? { get set } +/// ``` +/// the factory returns: +/// ```swift +/// var text: String? +/// ``` +/// +/// - Note: If the protocol variable declaration does not have a `PatternBindingSyntax` or a type, +/// the current implementation of the factory returns an empty string or does not generate the +/// variable declaration. These cases may be handled with diagnostics in future iterations of this factory. struct VariablesImplementationFactory { private let accessorRemovalVisitor = AccessorRemovalVisitor() diff --git a/Sources/SpyableMacro/Macro/SpyableMacro.swift b/Sources/SpyableMacro/Macro/SpyableMacro.swift index 470d41c..2208943 100644 --- a/Sources/SpyableMacro/Macro/SpyableMacro.swift +++ b/Sources/SpyableMacro/Macro/SpyableMacro.swift @@ -1,9 +1,24 @@ -import SwiftCompilerPlugin import SwiftSyntax -import SwiftSyntaxBuilder import SwiftSyntaxMacros -import SwiftDiagnostics +/// `SpyableMacro` is an implementation of the `Spyable` macro, which generates a test spy class +/// for the protocol to which the macro is added. +/// +/// The macro uses an `Extractor` to ensure that the `@Spyable` attribute is being used correctly, i.e., it is +/// applied to a protocol declaration. If the attribute is not applied to a protocol, an error is thrown. +/// +/// After verifying the protocol, `SpyableMacro` uses a `SpyFactory` to generate a new spy class declaration +/// that implements the given protocol and records interactions with its methods and properties. The resulting +/// class is added to the source file, thus "expanding" the `@Spyable` attribute into this new declaration. +/// +/// Example: +/// ```swift +/// @Spyable +/// protocol ServiceProtocol { +/// func fetch(text: String, count: Int) async -> Decimal +/// } +/// ``` +/// This will generate a `ServiceProtocolSpy` class that implements `ServiceProtocol` and records method calls. public enum SpyableMacro: PeerMacro { private static let extractor = Extractor() private static let spyFactory = SpyFactory()