Skip to content

Repository files navigation

swift-mockable

Test Coverage Release Swift Platforms License

swift-mockable provides a @Mockable macro that generates protocol mocks for tests.

  • Generated mocks are emitted inside #if DEBUG by default; the condition: 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.

Installation

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.

Quick Start

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)

What Gets Generated

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)
  • Subscripts:
    • subscript<suffix>CallCount
    • subscript<suffix>CallArgs
    • subscript<suffix>Handler
    • subscript<suffix>SetHandler for get/set subscripts
  • Initializers:
    • initCallCount
    • initCallArgs
    • (overloaded inits add a parameter-type suffix, e.g. initStringCallCount)
  • Utility:
    • resetMock()

Handlers

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>CallArgs is 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.

Choosing When Mocks Are Compiled

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_CONDITIONS in Xcode, or .define("FLAG") under swiftSettings in a package manifest.
  • .always — emits the mock with no #if guard, 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.

Supported Features

  • Access-level-aware generation (including private / fileprivate edge cases)
  • Sync / async / throws / rethrows methods
  • Typed throws (throws(MyError), SE-0413) on methods, properties, and subscripts
  • Variadic parameters (captured as arrays)
  • @autoclosure parameters (evaluated once per call; handlers and CallArgs receive the evaluated value)
  • Non-escaping closure parameters (forwarded to the handler; excluded from CallArgs)
  • inout parameters 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 Any in storage/handlers)
  • Overloaded methods (unique suffixes are added to generated names when needed)
  • Initializer requirements (init(...)) generated as recording required init witnesses (Sendable/actor mocks record behind the lock)
  • Associated types (generated as typealias, using the requirement's default when it has one, otherwise Any; a constrained associated type needs a default, since Any cannot 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 throws subscripts)
  • #if / #elseif / #else conditional compilation inside protocols
  • Configurable compilation condition for the generated mock (condition:#if DEBUG by default, a custom flag, or no guard)
  • Protocol inheritance (child mock inherits from first parent mock when applicable)
  • Sendable protocol support (@unchecked Sendable mock generation)
  • Actor protocol support (actor mock generation with nonisolated helper members)
  • nonisolated requirements of a @MainActor protocol (the mock's tracking state moves behind the lock, and the members that requirement reaches are nonisolated)

Behavioral Notes

  • 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 calls fatalError.
  • Properties with effectful getters (get async/get throws) generate <name>CallCount and <name>Handler instead of _<name> backing storage; the same unset-handler defaults apply.
  • Void-return methods and subscript setters are no-op when handler is nil.
  • @autoclosure arguments 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.
  • rethrows methods generate a non-throwing handler that receives the throwing closure arguments (a stored handler cannot satisfy rethrows on its own). The handler decides whether to invoke those closures; the mock itself does not re-throw their errors.
  • Typed throws (throws(MyError)) keeps the throws(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 @autoclosure argument 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 without try. throws(any Error) (and the bare throws(Error) spelling) is mocked exactly like untyped throws, with no re-throw.
  • resetMock() clears handlers, call counts, call arguments, and backing properties.
  • For inherited protocols, resetMock() calls super.resetMock() before resetting child members, and the child mock inherits the parent mock's initializers (including a parent init requirement's required init).

Diagnostics and Limitations

  • @Mockable can only be applied to protocols.
  • The only argument @Mockable accepts is condition:, and its value must be written literally as .debug, .always, or .custom("CONDITION") where CONDITION is a compilation condition expression (identifiers, true/false, !, &&, ||, parentheses, and platform checks such as os(iOS) or canImport(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 example func 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, so func load<T>(_ make: () -> T) is mocked normally.
  • init requirements are supported for standalone protocols (including Sendable and actor mocks) and are inherited by child mocks; declaring a new init requirement 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 spelled Swift.-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 Never and any Error typed-throws error types are recognized by spelling, so a generic parameter or type alias named Never or Error is 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 (fetch becomes fetchCallCount, name becomes _name), so such names cannot produce legal identifiers.
  • For protocols with multiple parent protocols, the first parent is used as the mock superclass.

Troubleshooting

  • The <Protocol>Mock type 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 a condition: to @Mockable when 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>Handler in 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.

Documentation

Requirements

  • Swift 5.9, 5.10, and 6.2+
  • macOS 10.15+ / iOS 13+ / tvOS 13+ / watchOS 6+ / visionOS 1+ / macCatalyst 13+
  • Generated Sendable and actor mocks synchronize their state with Mutex (Synchronization) on iOS 18.0+ / macOS 15.0+ / tvOS 18.0+ / watchOS 11.0+ / visionOS 2.0+, falling back to an NSLock-based lock on older OS versions

License

MIT

About

A Swift Macro that generates mock classes from protocols for testing.

Topics

Resources

Contributing

Security policy

Stars

6 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages