Skip to content

Latest commit

 

History

History
589 lines (431 loc) · 21.3 KB

File metadata and controls

589 lines (431 loc) · 21.3 KB

Advanced Usage

This guide explains generated naming, edge-case behavior, and constraints of @Mockable.

Naming Conventions

Methods

For a non-overloaded method fetch, generated members are:

  • fetchCallCount
  • fetchCallArgs
  • fetchHandler

For overloaded methods, a suffix is appended:

  1. Start with sanitized parameter type names.
  2. If that still collides, append:
    • return type (if non-Void)
    • Async (for async)
    • Throwing (for throws)

Example:

func get(url: URL) async -> String
func get(url: URL) async throws -> Data

Generates distinct handlers like:

  • getURLStringAsyncHandler
  • getURLDataAsyncThrowingHandler

A suffixed name can also spell out a name another requirement already uses. Identifiers are assigned across the whole protocol, so the requirement tracked under a name it declares — a method, or a property — keeps it, and the suffixed one continues counting:

@Mockable
protocol Service {
    func load()
    func load(_ item: Item)   // loadItem is taken -> loadItem2CallCount, loadItem2Handler, ...
    func loadItem()           // loadItemCallCount, loadItemHandler, ...
}

This holds whichever requirement is declared first, and applies to subscripts and initializers alike.

Subscripts

Subscript-generated names use subscript<suffix>.... The suffix is based on subscript parameter types.

Example:

subscript(index: Int) -> String { get }

Generates:

  • subscriptIntCallCount
  • subscriptIntCallArgs
  • subscriptIntHandler

Get/set subscripts also generate subscript<suffix>SetHandler.

Initializers

A sole init requirement generates members based on the identifier init:

  • initCallCount
  • initCallArgs

Overloaded initializers append a parameter-type suffix, matching the method scheme:

init(host: String)
init(host: String, port: Int)

Generates:

  • initStringCallCount / initStringCallArgs
  • initStringIntCallCount / initStringIntCallArgs

Generic and Associated Types

Generic Methods

When method signatures contain generic type parameters:

  • CallArgs storage uses type erasure (Any) where needed.
  • Handler parameter/return types are also erased where needed.
  • Generated method implementations cast generic returns back to the requested type.

This keeps generated mocks concrete while preserving call tracking.

A generic parameter nested in a collection is erased in place, so the erased type keeps its shape ([T] becomes [Any], [String: T] becomes [String: Any]):

func transform<T>(_ map: [String: T]) -> [String: T]
// generates:
// var transformCallArgs: [[String: Any]] = []
// var transformHandler: (@Sendable ([String: Any]) -> [String: Any])? = nil

A dictionary whose key mentions a generic parameter is erased as a whole ([T: String] becomes Any), because Any is not Hashable.

Any other type that mentions a generic parameter is erased to Any: a bare parameter (T), a generic type applied to one (UserDefaultsKey<T>, Box<[T]>), a qualified spelling of either (MyModule.Box<T>, Swift.Array<T>), a type nested in a parameter (T.Element), an existential (any Sequence<T>), and a metatype (T.Type). Such a type cannot be erased in place the way the sugared collections are: rewriting Box<T> to Box<Any> would require Box to accept Any, which its own generic constraints may forbid.

A closure that mentions a generic parameter is erased like any other type, and keeps its parentheses when it is nested in an optional: (() -> T)? becomes (() -> Any)?.

Two closure positions cannot be mocked and emit a diagnostic:

  • a return type that mentions a generic parameter inside a function type, such as func makeSetter<T>() -> (T) -> Void. The mock casts the erased handler result back to the declared type, and Swift cannot convert between function types at runtime.
  • a closure parameter whose own parameters mention a generic parameter, such as func observe<T>(_ handler: (T) -> Void). A closure's parameters are contravariant, so (T) -> Void cannot be passed where (Any) -> Void is expected. Erasing a closure's result is fine, so func load<T>(_ make: () -> T) is mocked normally.

A function type reached through another type (Box<() -> T>) is unaffected in either position, because that type is erased, forwarded, and cast back as a whole.

Associated Types

Each associated type generates a typealias in the mock:

  • If the protocol provides a default associated type, that type is used.
  • Otherwise, Any is used.

Example:

associatedtype Value = Int

Generates:

typealias Value = Int

Without default:

associatedtype Value

Generates:

typealias Value = Any

An associated type carrying a constraint — a conformance or a where clause — needs a default, because Any does not satisfy it and the mock would not conform. Declaring one without a default emits a diagnostic:

associatedtype Item: Decodable          // reported: give it a default
associatedtype Item: Decodable = Data   // mocked as `typealias Item = Data`

A typealias the protocol declares is re-emitted on the mock with its generic parameter and where clauses intact.

@autoclosure Parameters

@autoclosure arguments are evaluated exactly once per call, before the call is recorded. CallArgs and handlers observe the evaluated value, not the closure:

func log(_ message: @autoclosure () -> String)

Generates:

var logCallArgs: [String] = []
var logHandler: (@Sendable (String) -> Void)? = nil

Notes:

  • The argument is evaluated even when no handler is set, so the call can be recorded.
  • If evaluating a throwing autoclosure throws, the error propagates before the call is recorded (CallCount is not incremented).
  • An autoclosure's own effects must be covered by the requirement: a throwing autoclosure requires a throws requirement and an async autoclosure requires an async requirement; otherwise a compile-time diagnostic is emitted. Effectful autoclosures are not supported in subscript requirements.

Non-Escaping Closure Parameters

A non-escaping closure parameter cannot be stored, so it is excluded from CallArgs. The call is still counted, and the closure is still forwarded to the handler:

func run(label: String, _ body: () -> Void)

Generates:

var runCallArgs: [String] = []                         // only the storable `label`
var runHandler: (@Sendable (String, () -> Void) -> Void)? = nil

Escaping (@escaping), optional, and variadic closures are storable and remain in CallArgs as before.

rethrows Methods

A stored handler cannot satisfy a rethrows requirement on its own — a rethrows body may only throw through the requirement's own closure parameters. The mock therefore keeps the rethrows signature but generates a non-throwing handler that receives those closures:

func run(_ body: () throws -> Void) rethrows
// generates:
// var runHandler: (@Sendable (() throws -> Void) -> Void)? = nil
// func run(_ body: () throws -> Void) rethrows { ... _handler(body) ... }

The handler receives the throwing closures and decides whether to invoke them. Because the handler is non-throwing, the mock does not itself re-throw their errors; verify behavior through the handler and the call count.

Typed Throws (SE-0413)

Typed throws (throws(MyError)) on methods, effectful properties, and effectful subscripts is supported. The mock keeps the throws(MyError) signature, but the handler is a plain untyped-throwing closure and the generated body re-throws its error as the requirement's type:

func load(id: Int) throws(LoadError) -> String
// generates:
// var loadHandler: (@Sendable (Int) throws -> String)? = nil
// func load(id: Int) throws(LoadError) -> String {
//     ...
//     do { return try _handler(id) } catch { throw error as! LoadError }
// }

Configure the handler as usual — no error-type annotation needed:

mock.loadHandler = { id in throw LoadError() }

This keeps the package's full deployment range (an untyped handler avoids the Swift 6 runtime requirement for typed-throws function values) and supports generic error types (func run<E: Error>(_ body: () throws(E) -> Void) throws(E)). If a handler throws an error of a different type, the mock traps — throwing the requirement's declared error type is a contract you control.

A typed-throws closure parameter (func run(_ body: () throws(MyError) -> Void)) is likewise stored untyped: its CallArgs/handler entry uses () throws -> Void, so the mock never embeds a typed-throws function value.

A throwing @autoclosure argument is evaluated inside the same conversion, so its error is re-thrown as the declared type too — the mock wraps its whole body rather than only the handler call:

func compute(_ value: @autoclosure () throws -> Int) throws(ComputeError) -> Int
// generates:
// do {
//     let value = try value()
//     ...
//     return try _handler(value)
// } catch {
//     throw error as! ComputeError
// }

As with an unrecorded call elsewhere, an autoclosure that throws does so before the call is recorded, so CallCount is not incremented.

throws(Never) and throws(any Error)

Two error types describe what an untyped clause already says, so the mock skips the re-throw for them.

throws(Never) declares a requirement that cannot throw. Its mock keeps the signature but takes the non-throwing path — a non-throwing handler, called without try:

func load(id: Int) throws(Never) -> String
// generates:
// var loadHandler: (@Sendable (Int) -> String)? = nil
// func load(id: Int) throws(Never) -> String { ... return _handler(id) }

let value = mock.load(id: 1)  // no `try` needed

The same applies to a get throws(Never) property or subscript, and to an @autoclosure () throws(Never) -> T parameter, which is evaluated without try. Because the handler cannot throw, a throws(Never) requirement whose @autoclosure parameter can throw is reported as a diagnostic rather than mocked.

throws(any Error) is mocked exactly like untyped throws — a throwing handler invoked with try, and no re-throw, since the handler's error already has the declared type. The bare throws(Error) spelling is treated the same way.

Both are recognized by spelling, like the macro's other type checks: a generic parameter or type alias named Never or Error is classified by its name.

inout and Variadic Parameters

Variadic

Variadic parameters are tracked as arrays in CallArgs.

inout

CallArgs stores the input snapshot before mutation.

For handlers:

  • Single inout, no return value:
    • handler returns the updated value
  • Multiple inout, no return value:
    • handler returns a tuple with updated values
  • inout + return value:
    • handler returns (returnValue: ..., inoutArgs: ...)

Example:

func removeFirst(_ array: inout [String]) -> String

Expected handler shape:

mock.removeFirstHandler = { array in
    let first = array.first!
    return (returnValue: first, inoutArgs: Array(array.dropFirst()))
}

Effectful Read-Only Properties

Read-only properties with get async, get throws, or get async throws are mocked with a handler and a call counter instead of _name backing storage — a stored value cannot model a thrown error, and the handler mirrors the function model:

var token: String { get async throws }

Generates:

var tokenCallCount: Int = 0
var tokenHandler: (@Sendable () async throws -> String)? = nil
var token: String {
    get async throws { ... }
}

Configure it in tests like a method handler:

mock.tokenHandler = { "secret" }
mock.tokenHandler = { throw AuthError.expired }

If the handler is unset, the same defaults as methods apply: Optionals return nil, arrays and sets return an empty collection, dictionaries return an empty dictionary, and any other type calls fatalError.

Read-only subscripts with the same effects work identically:

subscript(key: String) -> Int { get async throws }
// generates:
// var subscriptStringHandler: (@Sendable (String) async throws -> Int)? = nil
// subscript(key: String) -> Int { get async throws { ... } }

Initializer Requirements

A protocol init requirement is satisfied by a generated required init witness that mirrors the requirement's signature and records the call:

@Mockable
protocol Repository {
    init(configuration: Configuration)
}

Generates:

var initCallCount: Int = 0
var initCallArgs: [Configuration] = []
required init(configuration: Configuration) {
    initCallCount += 1
    initCallArgs.append(configuration)
}

This unlocks protocols built around the init(configuration:) pattern — for example code that constructs a conformer generically (Service(configuration:) where Service is a generic constraint), which previously could not be mocked at all.

Notes:

  • Initializers record only — there is no initHandler. The recording state lives on the instance being created, so a per-instance handler could never be set before the initializer runs. Inspect initCallCount / initCallArgs (or the arguments you passed) to verify construction.
  • async, throws, failability (init?), and generic clauses are preserved on the witness. A throwing initializer keeps its throws signature but never throws.
  • The required keyword makes the initializer part of the mock's protocol conformance so that subclasses of the (non-final) mock inherit it.
  • When a protocol declares its own init requirements, the synthesized parameterless init() (normally generated for public / package mocks) is omitted — the required init witnesses already provide accessible initializers.
  • resetMock() clears initCallCount and initCallArgs alongside the other tracking state.
  • For Sendable and actor mocks the recording goes through MockableLock like every other member, so initCallCount / initCallArgs are lock-backed (and nonisolated on actors). An actor witness omits required, since actors are final.

A child mock inherits its parent mock's initializers, so a protocol whose parent declares an init requirement is mockable through the inherited required init (and its recording). Declaring a new init requirement directly on an inheriting protocol is not yet supported — the witness would need to chain through the parent mock's initializer, which the macro cannot see — and emits a diagnostic (see Diagnostics).

Sendable and Actor Mocks

Sendable

If a protocol inherits from Sendable (or uses @Sendable at protocol level), generated mocks:

  • conform to @unchecked Sendable
  • store mutable state behind MockableLock

Actor

If a protocol inherits from Actor, generated mock type is an actor.

For test ergonomics, helper members are generated as nonisolated where possible, including:

  • call counters
  • call argument collections
  • handlers
  • backing properties (for setup)
  • resetMock()

Static Members

Static methods/properties are always lock-backed through a shared static storage.

resetMock() also resets static generated members, clearing them in a single lock acquisition so a concurrent caller never observes a partly reset mock.

Thread Safety

A mock's tracking state is protected when the protocol says it should be: a Sendable protocol, an Actor protocol, and a nonisolated requirement of a global-actor-isolated protocol all select lock-backed storage, and static state is lock-backed regardless. Everything else is plain stored properties, which is what a mock used from one task needs.

Two things the macro cannot decide for you, because it only ever sees the protocol it is attached to:

  • Inheritance chains. A mock is Sendable if its own protocol is, and it subclasses its parent's mock whatever that parent chose. Mixing the two leaves half the state unprotected: a Sendable child of a non-Sendable parent inherits plain stored properties, and a non-Sendable child of a Sendable parent adds plain ones of its own while inheriting the parent's @unchecked Sendable conformance. Declare Sendable on every protocol in the chain, or none.
  • Global actors. Isolation is read from the protocol's own attributes, so a protocol that inherits its isolation from a parent produces an unisolated mock, and a protocol isolated to a custom global actor produces one too. Annotate each protocol you mock with the global actor it belongs to.

Inheritance and resetMock()

If a protocol inherits from another protocol and a parent mock exists:

  • child mock inherits from <Parent>Mock
  • child resetMock() calls super.resetMock() first
  • the child mock inherits the parent mock's initializers (it does not synthesize its own), so a parent init requirement is satisfied through the inherited required init

For multiple parent protocols, the first parent is used as the superclass target.

Conditional Compilation

Protocol members inside #if / #elseif / #else are preserved in generated mocks.

resetMock() includes matching conditional branches so reset behavior stays aligned with active compilation conditions.

The mock declares the members of every branch, each under the branch's own condition, so requirements in sibling branches share one namespace: two same-name requirements in different branches of the same block are treated as overloads and get the usual disambiguating suffixes.

@Mockable
protocol Service {
    #if CUSTOM
    func fetch(id: Int) -> Int      // fetchIntCallCount, fetchIntHandler, ...
    #else
    func fetch(name: String) -> Int // fetchStringCallCount, fetchStringHandler, ...
    #endif
}

Choosing When Mocks Are Compiled

By default the generated mock is wrapped in #if DEBUG. The condition: argument selects a different compilation condition, or removes the guard:

@Mockable                                    // #if DEBUG (default)
protocol UserService { ... }

@Mockable(condition: .custom("MOCKING"))     // #if MOCKING
protocol PaymentService { ... }

@Mockable(condition: .always)                // no #if guard
protocol PreviewDataService { ... }

This unlocks setups where #if DEBUG is too narrow:

  • a test-support module that is built in the release configuration,
  • SwiftUI preview stubs that should not depend on the build configuration,
  • a UI-test host app built for release.

Notes:

  • .custom("CONDITION") accepts 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 such as os(...), arch(...), swift(...), compiler(...), canImport(...), and targetEnvironment(...) — for example "DEBUG || UITESTS" or "os(iOS) && !RELEASE". Define each flag in every target that references the mock, via SWIFT_ACTIVE_COMPILATION_CONDITIONS (Xcode) or .define("FLAG") in swiftSettings (SwiftPM).
  • .always emits the mock in every build configuration, including release. Use it deliberately — typically in a module that never ships.
  • The condition must be written literally at the attachment site. Macro expansion happens at compile time, so runtime values (condition: myFlag) and interpolated strings emit a diagnostic.
  • The condition only controls the #if wrapper around the mock; members of the protocol that sit inside their own #if blocks keep those inner guards.

Diagnostics

Compilation errors are emitted when:

  • @Mockable is applied to non-protocol declarations
  • unsupported members are present (for example a static subscript)
  • a method or property requirement is named with an operator (static func == (lhs: Self, rhs: Self) -> Bool) or with a backtick-escaped identifier (func `repeat`(), var `default`: Int { get })
  • a new init requirement is declared directly on an inheriting protocol (not yet supported; inherited initializers still work)
  • a requirement's return type mentions a generic parameter inside a function type (for example func makeSetter<T>() -> (T) -> Void)
  • a requirement takes a closure whose own parameters mention a generic parameter (for example func observe<T>(_ handler: (T) -> Void))
  • an argument other than condition: is passed to @Mockable
  • the condition: value is not written literally as .debug, .always, or .custom("CONDITION"), or the custom condition is not a valid compilation condition expression (identifiers, true/false, !, &&, ||, parentheses, and platform checks)

Current Constraints

  • Static/class subscripts are not supported.
  • Operator requirements and method or property names that need backtick escaping are not supported. Every generated member is named after the requirement (fetch becomes fetchCallCount, name becomes _name), so a name that is not a plain identifier cannot produce legal identifiers.
  • 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 an inheriting protocol is not yet supported.
  • Return-value methods and get-only subscript getters trigger fatalError when the handler is unset, unless the return type has a natural empty value: Optionals return nil, arrays and sets return an empty collection, and dictionaries return an empty dictionary.