swift-mockable provides a @Mockable macro that generates protocol mocks for tests.
- Generated mocks are emitted inside
#if DEBUGby default; thecondition:argument selects a different compilation condition, or none (see Choosing When Mocks Are Compiled). - Generated names follow a predictable convention (
<name>CallCount,<name>CallArgs,<name>Handler). resetMock()is generated to clear all tracking state.
Add the package:
dependencies: [
.package(url: "https://github.com/yysskk/swift-mockable.git", from: "1.12.0")
]Add Mockable to your target:
.target(
name: "YourTarget",
dependencies: ["Mockable"]
)Note
The first time you build a target that uses @Mockable, Xcode shows a
"trust macro" prompt. Choose Trust & Enable to allow the macro to run.
On the command line, swift build runs macros without prompting.
import Mockable
@Mockable
protocol UserService {
func fetchUser(id: Int) async throws -> User
func saveUser(_ user: User) async throws
var currentUser: User? { get }
var isLoggedIn: Bool { get set }
}
let mock = UserServiceMock()
mock.fetchUserHandler = { id in
User(id: id, name: "Test User")
}
mock._currentUser = User(id: 1, name: "Current")
mock.isLoggedIn = true
let user = try await mock.fetchUser(id: 42)
#expect(user.id == 42)
#expect(mock.fetchUserCallCount == 1)
#expect(mock.fetchUserCallArgs == [42])
mock.resetMock()
#expect(mock.fetchUserCallCount == 0)For each protocol requirement, @Mockable generates test-friendly members:
- Functions:
<method>CallCount<method>CallArgs<method>Handler
- Properties:
- Backing storage for setup (for example
_<property>) - Computed protocol-conforming property (
property)
- Backing storage for setup (for example
- Subscripts:
subscript<suffix>CallCountsubscript<suffix>CallArgssubscript<suffix>Handlersubscript<suffix>SetHandlerfor get/set subscripts
- Initializers:
initCallCountinitCallArgs- (overloaded
inits add a parameter-type suffix, e.g.initStringCallCount)
- Utility:
resetMock()
A handler for a member with two or more parameters takes individual parameters, so it
can be written as { a, b in ... } (no tuple destructuring needed):
@Mockable
protocol Calculator {
func add(a: Int, b: Int) -> Int
}
// var addHandler: (@Sendable (Int, Int) -> Int)? = nil
mock.addHandler = { a, b in a + b }Notes:
- This applies to methods and subscripts alike (subscript getter
(Int, Int) -> V, setter(Int, Int, V) -> Void). - Zero- and single-parameter members pass their argument directly.
<name>CallArgsis a labeled tuple array (e.g.[(a: Int, b: Int)]) — the call history keeps parameter labels even though the handler takes individual parameters.- A parameter the mock cannot name after — a wildcard (
func handle(_: Event)) — is recorded under a positional label (param0,param1), and a parameter named with a keyword keeps its own name (mock.logCallArgs[0].for). Argument labels, and therefore call sites, are unchanged.
By default, generated mocks are wrapped in #if DEBUG, so they never ship in
release builds. When a mock must exist elsewhere — a test-support module built in
the release configuration, SwiftUI preview stubs, or a UI-test host app — pass a
condition: to @Mockable:
@Mockable // #if DEBUG (default)
protocol UserService { ... }
@Mockable(condition: .custom("MOCKING")) // #if MOCKING
protocol PaymentService { ... }
@Mockable(condition: .always) // no #if guard
protocol PreviewDataService { ... }.debug— wraps the mock in#if DEBUG. This is the default and matches the previous behavior..custom("CONDITION")— wraps the mock in#if CONDITION. The condition is any compilation condition expression, spelled as a string literal: a flag ("MOCKING"), or a compound condition built from identifiers,true/false,!,&&,||, parentheses, and platform checks ("DEBUG || UITESTS","os(iOS) && !RELEASE","canImport(XCTest)"). Define each flag in every target that needs the mock:SWIFT_ACTIVE_COMPILATION_CONDITIONSin Xcode, or.define("FLAG")underswiftSettingsin a package manifest..always— emits the mock with no#ifguard, in every build configuration. Use this deliberately, for example in a dedicated test-support module that is never linked into a shipping product.
The condition must be written literally at the attachment site (.debug,
.always, or .custom("CONDITION") with a string literal) — the macro expands
at compile time and cannot read runtime values.
- Access-level-aware generation (including
private/fileprivateedge cases) - Sync /
async/throws/rethrowsmethods - Typed throws (
throws(MyError), SE-0413) on methods, properties, and subscripts - Variadic parameters (captured as arrays)
@autoclosureparameters (evaluated once per call; handlers andCallArgsreceive the evaluated value)- Non-escaping closure parameters (forwarded to the handler; excluded from
CallArgs) inoutparameters with write-back support- Parameter specifiers (
consuming,borrowing,sending,isolated), dropped from the stored and handler types where they are not valid - Generic methods (generic parameters are type-erased to
Anyin storage/handlers) - Overloaded methods (unique suffixes are added to generated names when needed)
- Initializer requirements (
init(...)) generated as recordingrequired initwitnesses (Sendable/actormocks record behind the lock) - Associated types (generated as
typealias, using the requirement's default when it has one, otherwiseAny; a constrained associated type needs a default, sinceAnycannot satisfy the constraint) - Static methods and static properties
- Get-only / get-set / optional properties
- Effectful read-only properties (
get async,get throws,get async throws) mocked with handlers - Get-only / get-set subscripts (including effectful
get async/get throwssubscripts) #if/#elseif/#elseconditional compilation inside protocols- Configurable compilation condition for the generated mock (
condition:—#if DEBUGby default, a custom flag, or no guard) - Protocol inheritance (child mock inherits from first parent mock when applicable)
Sendableprotocol support (@unchecked Sendablemock generation)Actorprotocol support (actor mock generation with nonisolated helper members)nonisolatedrequirements of a@MainActorprotocol (the mock's tracking state moves behind the lock, and the members that requirement reaches arenonisolated)
- Return-value methods and get-only subscripts return a default when their handler is not set if the return type has one: Optionals return
nil, arrays and sets return an empty collection, and dictionaries return an empty dictionary. Any other return type callsfatalError. - Properties with effectful getters (
get async/get throws) generate<name>CallCountand<name>Handlerinstead of_<name>backing storage; the same unset-handler defaults apply. - Void-return methods and subscript setters are no-op when handler is
nil. @autoclosurearguments are evaluated exactly once per call (even when no handler is set); if evaluating a throwing autoclosure throws, the error propagates before the call is recorded.- Non-escaping closure arguments are forwarded to the handler but excluded from
CallArgs(a non-escaping value cannot be stored); the call is still counted. rethrowsmethods generate a non-throwing handler that receives the throwing closure arguments (a stored handler cannot satisfyrethrowson its own). The handler decides whether to invoke those closures; the mock itself does not re-throw their errors.- Typed throws (
throws(MyError)) keeps thethrows(MyError)signature and generates a plain untyped-throwing handler; the body re-throws the handler's error as the declared type. Configure the handler normally (mock.loadHandler = { id in throw MyError() }). If the handler throws a different error type, the mock traps. An error thrown while evaluating a throwing@autoclosureargument is re-thrown as the declared type in the same way. throws(Never)keeps its signature but is mocked as non-throwing: the handler cannot throw and the mock is called withouttry.throws(any Error)(and the barethrows(Error)spelling) is mocked exactly like untypedthrows, with no re-throw.resetMock()clears handlers, call counts, call arguments, and backing properties.- For inherited protocols,
resetMock()callssuper.resetMock()before resetting child members, and the child mock inherits the parent mock's initializers (including a parentinitrequirement'srequired init).
@Mockablecan only be applied to protocols.- The only argument
@Mockableaccepts iscondition:, and its value must be written literally as.debug,.always, or.custom("CONDITION")whereCONDITIONis a compilation condition expression (identifiers,true/false,!,&&,||, parentheses, and platform checks such asos(iOS)orcanImport(UIKit)). Anything else emits a compile-time diagnostic. - Unsupported protocol members (for example a
static subscript) emit compile-time diagnostics. - A requirement whose return type mentions a generic parameter inside a function type (for example
func makeSetter<T>() -> (T) -> Void), or that takes a closure whose own parameters mention one (for examplefunc observe<T>(_ handler: (T) -> Void)), is not supported and emits a diagnostic: Swift cannot convert between function types at runtime, and a closure's parameters are contravariant. Erasing a closure's result is fine, sofunc load<T>(_ make: () -> T)is mocked normally. initrequirements are supported for standalone protocols (includingSendableandactormocks) and are inherited by child mocks; declaring a newinitrequirement directly on a protocol whose mock subclasses a parent mock is not yet supported and emits a diagnostic.- A parent protocol must itself be
@Mockable: the mock subclasses the parent's generated mock. Conformances that declare nothing to witness (Sendable,Actor,AnyObject,AnyActor,Error, also spelledSwift.-qualified) are not parents and are left to the mock's own conformance. Inheriting a standard-library protocol with requirements (Hashable,Codable,Identifiable,Comparable,Sequence, and the like) emits a diagnostic, because the mock has no way to witness them — drop the conformance, or satisfy it in an extension of the generated mock. A parent written with generic arguments (Container<Int>) emits a diagnostic as well. - Static/class subscripts are not supported.
- The
Neverandany Errortyped-throws error types are recognized by spelling, so a generic parameter or type alias namedNeverorErroris classified by its name. - Operator requirements (for example
static func == (lhs: Self, rhs: Self) -> Bool) and method or property names that need backtick escaping (func `repeat`(),var `default`: Int { get }) are not supported and emit a diagnostic. The generated members are named after the requirement (fetchbecomesfetchCallCount,namebecomes_name), so such names cannot produce legal identifiers. - For protocols with multiple parent protocols, the first parent is used as the mock superclass.
- The
<Protocol>Mocktype can't be found. By default the generated mock lives inside#if DEBUG, so it only exists in debug builds. Reference it from test targets or debug configurations — or pass acondition:to@Mockablewhen the mock is needed in other configurations (see Choosing When Mocks Are Compiled). For.custom("CONDITION"), make sure every flag the condition references is defined in the target that uses the mock. - "Macro expansion" / trust prompt in Xcode. Choose Trust & Enable the
first time you build a target that uses
@Mockable(see the note in Installation). - A handler is required. A return-value method or get-only subscript with an
unset handler calls
fatalError, unless the return type has a natural empty value (see Behavioral Notes). Set the corresponding<name>Handlerin your test setup. - Overloaded calls need a type annotation. For methods overloaded only by
return type, annotate the result (e.g.
let value: String = mock.get(...)) so Swift selects the right overload.
- Swift 5.9, 5.10, and 6.2+
- macOS 10.15+ / iOS 13+ / tvOS 13+ / watchOS 6+ / visionOS 1+ / macCatalyst 13+
- Generated
Sendableandactormocks synchronize their state withMutex(Synchronization) on iOS 18.0+ / macOS 15.0+ / tvOS 18.0+ / watchOS 11.0+ / visionOS 2.0+, falling back to anNSLock-based lock on older OS versions
MIT