Spyable is a powerful tool for Swift that simplifies and automates the process of creating spies for testing. By using
the @Spyable
annotation on a protocol, the macro generates a spy class that implements the same interface and tracks
interactions with its methods and properties.
A "spy" is a test double that replaces a real component and records all interactions for later inspection. It's particularly useful in behavior verification, where the interaction between objects is the subject of the test.
The Spyable macro revolutionizes the process of creating spies in Swift testing:
- Automatic Spy Generation: Annotate a protocol with
@Spyable
, and let the macro generate the corresponding spy class. - Interaction Tracking: The generated spy records method calls, arguments, and return values, making it easy to verify behavior in your tests.
- Import Spyable:
import Spyable
- Annotate your protocol with
@Spyable
:
@Spyable
protocol ServiceProtocol {
var name: String { get }
func fetchConfig(arg: UInt8) async throws -> [String: String]
}
This generates a spy class named ServiceProtocolSpy
that implements ServiceProtocol
. The generated class includes
properties and methods for tracking method calls, arguments, and return values.
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 fetchConfigArgThrowableError: (any Error)?
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 let fetchConfigArgThrowableError {
throw fetchConfigArgThrowableError
}
if fetchConfigArgClosure != nil {
return try await fetchConfigArgClosure!(arg)
} else {
return fetchConfigArgReturnValue
}
}
}
- Use the spy in your tests:
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])
}
Spyable supports generic functions, but their implementation involves special handling. Due to limitations in Swift, generic parameters in a function are replaced with Any
in the spy class to store arguments, return values, and closures.
For example:
func foo<T, U>(_ bar: T) -> U
Generates the following spy:
class MyProtocolSpy: MyProtocol {
var fooCallsCount = 0
var fooCalled: Bool {
return fooCallsCount > 0
}
var fooReceivedBar: Any?
var fooReceivedInvocations: [Any] = []
var fooReturnValue: Any!
var fooClosure: ((Any) -> Any)?
func foo<T, U>(_ bar: T) -> U {
fooCallsCount += 1
fooReceivedBar = (bar)
fooReceivedInvocations.append((bar))
if fooClosure != nil {
return fooClosure!(bar) as! U
} else {
return fooReturnValue as! U
}
}
}
-
Type Matching: Ensure the expected types align with the injected
returnValue
orclosure
. Mismatched types will result in runtime crashes due to force casting. -
Example:
@Spyable
protocol ServiceProtocol {
func wrapDataInArray<T>(_ data: T) -> Array<T>
}
struct ViewModel {
let service: ServiceProtocol
func wrapData<T>(_ data: T) -> Array<T> {
service.wrapDataInArray(data)
}
}
Test for wrapData()
:
func testWrapData() {
serviceSpy.wrapDataInArrayReturnValue = [123]
XCTAssertEqual(sut.wrapData(1), [123])
XCTAssertEqual(serviceSpy.wrapDataInArrayReceivedData as? Int, 1)
// Incorrect usage: mismatched type
// serviceSpy.wrapDataInArrayReturnValue = ["hello"] // ⚠️ Causes runtime error
}
Tip
If you see a crash in the generic function, check the type alignment between expected and injected values.
You can limit where Spyable's generated code can be used by using the behindPreprocessorFlag
parameter:
@Spyable(behindPreprocessorFlag: "DEBUG")
protocol MyService {
func fetchData() async
}
This wraps the generated spy in an #if DEBUG
preprocessor macro, preventing its use where the DEBUG
flag is not defined.
Important
The behindPreprocessorFlag
argument must be a static string literal.
If you need spies in Xcode Previews while excluding them from production builds, consider using a custom compilation flag (e.g., SPIES_ENABLED
):
The following diagram illustrates how to set up your project structure with the SPIES_ENABLED
flag:
graph TD
A[MyFeature] --> B[MyFeatureTests]
A --> C[MyFeaturePreviews]
A -- SPIES_ENABLED = 0 --> D[Production Build]
B -- SPIES_ENABLED = 1 --> E[Test Build]
C -- SPIES_ENABLED = 1 --> F[Preview Build]
style A fill:#ff9999,stroke:#333,stroke-width:2px,color:#000
style B fill:#99ccff,stroke:#333,stroke-width:2px,color:#000
style C fill:#99ffcc,stroke:#333,stroke-width:2px,color:#000
style D fill:#ffcc99,stroke:#333,stroke-width:2px,color:#000
style E fill:#99ccff,stroke:#333,stroke-width:2px,color:#000
style F fill:#99ffcc,stroke:#333,stroke-width:2px,color:#000
Set this flag under "Active Compilation Conditions" for both test and preview targets.
Find examples of how to use Spyable here.
The latest documentation is available here.
Add Spyable as a package dependency:
https://github.com/Matejkob/swift-spyable
Add to your Package.swift
:
dependencies: [
.package(url: "https://github.com/Matejkob/swift-spyable", from: "0.3.0")
]
Then, add the product to your target:
.product(name: "Spyable", package: "swift-spyable"),
This library is released under the MIT license. See LICENSE for details.