Spyable is a powerful tool for Swift that automates the process of creating protocol-conforming classes. Initially designed to simplify testing by generating spies, it is now widely used for various scenarios, such as SwiftUI previews or creating quick dummy implementations.
Spyable enhances your Swift workflow with the following features:
- Automatic Spy Generation: Annotate a protocol with
@Spyable
, and let the macro generate a corresponding spy class. - Access Level Inheritance: The generated class automatically inherits the protocol's access level.
- Explicit Access Control: Use the
accessLevel
argument to override the inherited access level if needed. - Interaction Tracking: For testing, the generated spy tracks method calls, arguments, and return values.
- Import Spyable:
import Spyable
- Annotate your protocol with
@Spyable
:
@Spyable
public protocol ServiceProtocol {
var name: String { get }
func fetchConfig(arg: UInt8) async throws -> [String: String]
}
This generates a spy class named ServiceProtocolSpy
with a public
access level. The generated class includes properties and methods for tracking method calls, arguments, and return values.
public class ServiceProtocolSpy: ServiceProtocol {
public var name: String {
get { underlyingName }
set { underlyingName = newValue }
}
public var underlyingName: (String)!
public var fetchConfigArgCallsCount = 0
public var fetchConfigArgCalled: Bool {
return fetchConfigArgCallsCount > 0
}
public var fetchConfigArgReceivedArg: UInt8?
public var fetchConfigArgReceivedInvocations: [UInt8] = []
public var fetchConfigArgThrowableError: (any Error)?
public var fetchConfigArgReturnValue: [String: String]!
public var fetchConfigArgClosure: ((UInt8) async throws -> [String: String])?
public 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])
}
By default, the generated spy inherits the access level of the annotated protocol. For example:
@Spyable
internal protocol InternalProtocol {
func doSomething()
}
This generates:
internal class InternalProtocolSpy: InternalProtocol {
internal func doSomething() { ... }
}
You can override this behavior by explicitly specifying an access level:
@Spyable(accessLevel: .fileprivate)
public protocol CustomProtocol {
func restrictedTask()
}
Generates:
fileprivate class CustomProtocolSpy: CustomProtocol {
fileprivate func restrictedTask() { ... }
}
Supported values for accessLevel
are:
.public
.package
.internal
.fileprivate
.private
Use the behindPreprocessorFlag
parameter to wrap the generated code in a preprocessor directive:
@Spyable(behindPreprocessorFlag: "DEBUG")
protocol DebugProtocol {
func logSomething()
}
Generates:
#if DEBUG
internal class DebugProtocolSpy: DebugProtocol {
internal func logSomething() { ... }
}
#endif
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.