Skip to content

Commit

Permalink
Add documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
Matejkob committed Jun 14, 2023
1 parent 9f4bd40 commit ac1b89b
Show file tree
Hide file tree
Showing 14 changed files with 568 additions and 4 deletions.
140 changes: 140 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

<!--The latest documentation for the Spyable macro is available [here][docs].-->

## 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.
53 changes: 53 additions & 0 deletions Sources/Spyable/Spyable.swift
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 7 additions & 0 deletions Sources/SpyableMacro/Diagnostics/SpyableDiagnostic.swift
Original file line number Diff line number Diff line change
@@ -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

Expand Down
8 changes: 8 additions & 0 deletions Sources/SpyableMacro/Extractors/Extractor.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions Sources/SpyableMacro/Factories/CalledFactory.swift
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
19 changes: 19 additions & 0 deletions Sources/SpyableMacro/Factories/CallsCountFactory.swift
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
31 changes: 30 additions & 1 deletion Sources/SpyableMacro/Factories/ClosureFactory.swift
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
50 changes: 50 additions & 0 deletions Sources/SpyableMacro/Factories/FunctionImplementationFactory.swift
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
Loading

0 comments on commit ac1b89b

Please sign in to comment.