# swift-sourcery-templates [![CI](https://github.com/modaal-agent/swift-sourcery-templates/actions/workflows/ci.yml/badge.svg)](https://github.com/modaal-agent/swift-sourcery-templates/actions/workflows/ci.yml) Advanced Protocol Mock, Type Erasure and dependency-forwarding Code-generation templates for Swift (using [Sourcery](https://github.com/krzysztofzablocki/Sourcery)). Three templates: 1. **`Mocks.swifttemplate`** — generates protocol mock classes for [test doubles](https://martinfowler.com/bliki/TestDouble.html) with call counting, handler closures, and smart defaults 2. **`TypeErase.swifttemplate`** — generates [type erasure](https://www.bignerdranch.com/blog/breaking-down-type-erasure-in-swift/) wrappers 3. **`Component.swifttemplate`** — generates the forwarding class that satisfies a dependency-injection protocol, for trees where each level declares what it consumes. See [Components](#components) The first two support protocols with associated types, generic functions with constraints, `@escaping` closure parameters, and provide smart defaults for RxSwift and Combine types. Compatible with [RIBs](https://github.com/uber/RIBs) architecture patterns. Generated mocks compile with **zero diagnostics** under `-strict-concurrency=complete` and in the Swift 6 language mode: `async` and `nonisolated` are carried through to the mock, a protocol's global actor is declared on the mock class, and a `Sendable` protocol gets an `@unchecked` conformance. See [Concurrency](#concurrency). ## Generated Mock API For a protocol method: ```swift /// sourcery: ProtocolMock protocol DataService { func fetchData(id: String, completion: @escaping (String?, Error?) -> Void) } ``` The template generates: ```swift final class DataServiceMock: DataService { func fetchData(id: String, completion: @escaping (String?, Error?) -> Void) { fetchDataCallCount += 1 fetchDataArgs.append(id) if let __fetchDataHandler = self.fetchDataHandler { __fetchDataHandler(id, completion) } } var fetchDataCallCount: Int = 0 var fetchDataArgs: [String] = [] var fetchDataHandler: ((_ id: String, _ completion: @escaping (String?, Error?) -> Void) -> ())? = nil } ``` **The naming rule: a mock member is the declared name plus a suffix.** The name is taken verbatim, with backticks dropped and nothing else changed — no case change, no underscore removal, no first-word lowercasing. `func perform1_0()` gives `perform1_0CallCount`, `func ID()` gives `IDCallCount`, ``func `do`()`` gives `doCallCount`, `var setting4_2: Int` gives `setting4_2GetCount`. An overload is the one case where the prefix is not the declared name: the overload with the fewest parameters keeps it, and every other one appends the capitalized argument label of each parameter, or the parameter name where there is none. The generated file states the rule at its top and under every `// MARK: - `, and every member whose prefix is not its declared name carries a comment naming both spellings — above the witness, and in an index under that class's `MARK:`. | suffix | on | emitted when | | --- | --- | --- | | `CallCount` | method | always | | `Args` | method | at least one recordable parameter | | `Handler` | method | always | | `GetCount`, `GetHandler` | property | always | | `SetCount` | property | the requirement is `{ get set }` | | `SetArgs` | property | the requirement is `{ get set }` and its type is not a closure | | `SetHandler` | property | the requirement is `{ get set }` | | `_` | property | the stored value, seeded and read without moving a counter — and the only way to seed a `{ get }` requirement, whose witness is get-only | | `Subject` | both | `AnyPublisher`, `Observable`, `Single` | | `SubscribeCount`, `SubscribeCancelCount` | both | `AnyPublisher` — the code under test subscribed, and cancelled | | `OutputCount`, `Outputs`, `OutputHandler` | both | `AnyPublisher` — values the member delivered | | `CompletionCount` | both | `AnyPublisher` — the stream finished or failed | | `EventCallCount`, `Events`, `EventHandler` | both | `AnyObserver` — events pushed in | | `CancelCallCount`, `CancelHandler` | method | the return type is `AnyCancellable` | | `DisposeCallCount`, `DisposeHandler` | method | the return type is `Disposable` | A returned token's suffix is the call that token exposes: `cancel()` on `AnyCancellable`, `dispose()` on RxSwift's `Disposable`. Every name is built in [`templates/Mocks/MockNaming.swift`](templates/Mocks/MockNaming.swift), and two members of one mock that would carry the same name fail generation naming both. **Key features:** - **Call counting** — `methodCallCount` tracks invocation count - **Argument recording** — `methodArgs` holds what each call was passed, in order. See [Recorded arguments](#recorded-arguments) - **Handler closures** — `methodHandler` lets tests control behavior - **`@escaping` / `@Sendable` preservation** — closure parameters retain both attributes in the method signature and the handler type, so handlers can capture, async-dispatch, and hand off closures that cross isolation boundaries - **`async` / `throws` preservation** — an `async` requirement generates an `async` method with an `async` handler, so a spec can control *when* the call returns, not only what it returns - **Smart defaults** — Optional returns `nil`, Void returns nothing, known types get sensible defaults, and RxSwift / Combine types get a subject the test drives - **Overload disambiguation** — overloaded methods get distinct handler names automatically - **Property read and write counting** — every property requirement generates `GetCount`, `GetHandler` and a `_` store, and a `{ get set }` one also generates `SetCount`, `SetArgs` and `SetHandler`; `mock._draft` reads and seeds the value without moving a counter or recording a write - **Effectful property requirements** — `{ get async }`, `{ get throws }` and `{ get async throws }` generate the accessor they declare, with the same effects on `GetHandler` ## Recorded arguments Every call appends what it was passed to `Args`, so asserting on an argument needs no handler and no side object: ```swift let service = DataServiceMock() // func fetchData(id: String, completion: …) let registry = AppServicesRegisteringMock() // func registerURLHandler(_ tag: String, priority: Int) sut.load(id: "m1") XCTAssertEqual(service.fetchDataArgs, ["m1"]) // one parameter: its own type XCTAssertEqual(registry.registerURLHandlerArgs.last?.priority, 3) // two: a labelled tuple service.fetchDataArgs = [] // clearing it is the reset ``` | the method's parameters | `Args` | | --- | --- | | none | not generated | | one recordable | `[T]` — Swift has no single-element labelled tuple to put it in | | two or more recordable | `[(first: A, second: B)]`, labelled with the parameter names, one element per call | | a mix of recordable and closure | the recordable ones only | | `inout T` | `[T]` — the value the caller passed in; the handler still gets the reference | A `{ get set }` requirement records its writes the same way. Every assignment appends the value to `SetArgs: [T]`, whether or not `SetHandler` is set; the store is then assigned and the handler called, so code the handler calls back into reads the value just written. Seeding `_` and the generated initializer record nothing: ```swift sut.mute() XCTAssertEqual(player.volumeSetArgs, [0]) ``` Three things are deliberately not recorded: - **Closures** — a closure parameter, and a value written to a closure-typed property. A non-escaping closure cannot be stored at all, and storing an escaping one would keep the caller's captures alive for as long as the mock — which a leak or churn spec reads as a retain by the code under test. What a spec does with a closure is call it, and the handler hands it over. The generated file states this in a comment above that handler. - **Generic methods.** Their parameter types name the *method's* generic parameters; a stored property can only name the class's. - **Anything annotated `skipArgumentRecording`** — on the method or the property, or on the protocol for all of them. A recorded argument or written value lives as long as the mock, so this is the opt-out for a value whose deallocation a spec asserts. Counting and the handlers are unaffected. ## Concurrency The generated file is held to zero diagnostics under `-swift-version 5 -strict-concurrency=complete` and under `-swift-version 6` — see [`Tests/Checks/`](Tests/Checks). | protocol declares | mock gets | | --- | --- | | `@MainActor` (or any attribute ending in `Actor`) | the same attribute on the class, so the conformance is isolated rather than inferred | | `nonisolated func` / `nonisolated var` on an isolated protocol | `nonisolated` on the member and `nonisolated(unsafe)` on its call counter and handler — without both, the member cannot mutate its own bookkeeping | | `func f() async throws -> T` | `func f() async throws -> T` with handler `((…) async throws -> (T))?`, awaited at the call | | `: Sendable` | `final class …: P, @unchecked Sendable` — a test double holds mutable counters, so the conformance cannot be checked | | `@Sendable` closure parameter | the attribute restated in the signature and the handler type, so the captured closure can be handed to `@Sendable`-constrained code | | a global actor Sourcery cannot name from the attribute | declare it: `/// sourcery: globalActor = "MyIsolation"` | Two deliberate non-goals: - **Member-level global actors are not propagated.** A protocol that is not isolated but whose method is `@MainActor` produces a non-isolated mock method. That satisfies the requirement, and it keeps the mock callable from a non-isolated test body. - **Mock classes are `final`.** Subclassing a generated mock is not supported; set a handler instead. ## Conditional compilation Sourcery reads every clause of an `#if` and records no condition, so a requirement declared inside `#if canImport(UIKit)` is generated on every platform unless its declaration says otherwise. Say it with `sourcery: if`, whose value is the text after `#if`: ```swift /// sourcery: ProtocolMock protocol AvatarRepositoryProtocol { func fetch(id: String) -> String #if canImport(UIKit) // sourcery: if = "canImport(UIKit)" func uploadOwnPhoto(_ image: UIImage) #endif } ``` | the declaration carries | generated | | --- | --- | | `if` on a method or a property | its members between `#if ` and `#endif`, in the mock, the Component and the type erasure | | `if` on the protocol | the whole mock, Component or type erasure inside `#if ` | | several `if` lines | the values joined with `&&` | | `// sourcery:begin: if = ""` … `// sourcery:end` | the value on every declaration between the two lines | | the same method in two clauses, each with its own `if` | one member, under the two conditions joined with `\|\|` | | one property name with a different type in each clause, each with its own `if` | both, each under its own condition, with the same member names | | `canImport()` in an `if` value | the generated file's `import ` between `#if canImport()` and `#endif`, added when the config lists no such import | A property inside its own `if` that the mock's initializer takes — its type has no default value, or it carries `init` — fails generation, because the initializer is generated once, outside the condition. Put `sourcery: handler` on it, or give it a type with a default value. ## Combine An `AnyPublisher` requirement is backed by a subject the test drives: ```swift /// sourcery: ProtocolMock @MainActor protocol UserRepositoryProtocol { var meStream: AnyPublisher { get } func bootstrap(displayName: String?) -> AnyPublisher } ``` ```swift mock.meStreamSubject.send(UserSummary(uid: "u1", displayName: "Ada")) // state, replayed mock.bootstrapSubject.send(()) // event ``` **The member hands back a construct the mock owns, not the erased subject**, so it records what the code under test did with the stream rather than only that it asked for one: | counter | what it answers | | --- | --- | | `GetCount` / `CallCount` | it asked for the stream | | `SubscribeCount` | it subscribed | | `OutputCount`, `Outputs` | a value reached it — counted on **delivery**, so a value sent while nobody is subscribed counts nothing and one delivered to two subscribers counts twice | | `CompletionCount` | the stream finished or failed | | `SubscribeCancelCount` | it cancelled | `OutputHandler` runs after the counter and the recorder and sees each value as it lands, which is how a test sends the next one from inside it. `/// sourcery: skipArgumentRecording` on the member or the protocol drops `Outputs`, leaving the counters and the handler. A property's `GetHandler` is read when the code under test **subscribes**, not when it reads the member, so a handler seeded after the publisher was captured still decides the stream. A method's `Handler` stays at call time, where its arguments are and where its `async` and `throws` apply, and a method handler that answers bypasses the subject — `SubscribeCount` then stays at zero. A publisher requirement declared `{ get async }` or `{ get throws }` fails generation: the closure that reads its handler is synchronous. The subject is a **`PassthroughSubject`**, for a variable and for a method alike — the same rule the RxSwift members follow with `PublishSubject`. The double emits what the test sends it and nothing else, so a subscriber that attaches after a send has missed it. **A stream that replays is the test's to supply**, through the closure every publisher member already has — `GetHandler` on a variable, `Handler` on a method: ```swift let state = CurrentValueSubject(me) mock.meStreamGetHandler = { state.eraseToAnyPublisher() } // now it replays ``` Seeding by default instead would make the double emit a value nobody wrote — `""`, `[:]`, `[]` — the moment the code under test subscribes, and turn the test's own `send` into a *second* element; a bridge awaiting the first value then resumes its continuation twice and traps. It is also un-opt-out-able by construction, since assigning a `PassthroughSubject` to a `CurrentValueSubject`-typed property does not compile. `/// sourcery: subject = "CurrentValue"` states the seeded form at the declaration, for the member where every test wants it; asking for it where `Output` has no default value is an error, not a silent downgrade. A method returning `AnyCancellable` gets a token whose `cancel()` is counted — `CancelCallCount` and `CancelHandler` — mirroring the RxSwift `Disposable` case, so a registration API's deregistration is observable without setting a handler. ## Components In a dependency-injection tree where every level declares an `Dependency` protocol naming exactly what it consumes, the class that satisfies that protocol by forwarding to the parent is mechanical: one `var m: T { dependency.m }` per requirement. `Component.swifttemplate` writes it. ```swift /// sourcery: DuetComponent protocol TimelineDependency: AnyObject { var themeProvider: ThemeProviding { get } var memoryRepository: MemoryRepositoryProtocol { get } } ``` ```swift final class TimelineComponent: TimelineDependency { private let dependency: TimelineDependency init(dependency: TimelineDependency) { self.dependency = dependency } var memoryRepository: MemoryRepositoryProtocol { dependency.memoryRepository } var themeProvider: ThemeProviding { dependency.themeProvider } } ``` The parent satisfies a child's Dependency with an empty extension when its own surface covers it (`extension MainComponent: TimelineDependency {}`), so the tree is written once, in the protocols. **A level that owns something** annotates `DuetComponent, owns`. A generated type cannot carry a hand-written `lazy var`, so the emission becomes a non-`final` `ComponentBase` and the level writes the subclass that holds what it owns: ```swift /// sourcery: DuetComponent, owns protocol MainDependency: AnyObject { /* … */ } final class MainComponent: MainComponentBase { lazy var feedAudioPlayer: FeedAudioPlaying = FeedAudioPlayer( memoryRepository: memoryRepository) // reads the generated forwarders } ``` Getting `owns` wrong is a compile error, not a silent defect: a `final class` cannot be subclassed, and a stored property cannot be added in an extension. **What it forwards.** Read-only and settable requirements, `async` / `throws` / `rethrows` methods, effectful property requirements (`{ get async throws }`), `inout` and unlabelled parameters, generic methods, `@escaping` / `@Sendable` closure parameters, and requirements inherited from a refined protocol. Isolation follows the same rules as the mocks: the protocol's global actor lands on the class, a `nonisolated` requirement is restated, and the storage becomes `nonisolated(unsafe)` where a nonisolated forwarder has to read it. **What it refuses**, each with a diagnostic naming the member: `static` requirements, `init` requirements, `subscript` requirements, and associated types. None can be discharged by forwarding to a stored instance, so generation fails rather than emitting a class that will not conform. A requirement or a protocol carrying `sourcery: if` is forwarded inside the same `#if` ([Conditional compilation](#conditional-compilation)). **The emitted type is internal by default**, even when the protocol is public — a Component is consumed by its own module's builders, and widening a module's API surface as a side effect of generating boilerplate is not a decision a template should make. `componentAccess = "public"` opts in. Mocks and Components compose: a protocol annotated `/// sourcery: ProtocolMock, DuetComponent` gets the double a spec drives *and* the production class that forwards. Both read the same isolation rules from the same helpers, so they cannot disagree about which member is `nonisolated`. ## Tests | lane | command | covers | | --- | --- | --- | | fast | `Tests/Checks/run-checks.sh` | snapshot of every template's generated output, both language modes, runtime behaviour. Mocks and Components are typechecked together, so the two cannot disagree about isolation. No simulator, no third-party packages, seconds | | plugin | `Tests/Checks/run-plugin-checks.sh` | the SPM build-tool plugin, black-box over a fixture package: the derived source closure, the synthesized config, the defaults it supplies, and five red controls that must stay red. No simulator | | xcode | `Tests/Checks/run-xcode-checks.sh` | the same plugin through `XcodeBuildToolPlugin`, over an Xcode project XcodeGen generates from a committed spec: config discovery, the one-directory expansion, a hand-listed `${SOURCERY_PROJECT}` entry, a target depending on a sibling target, and a bare template name that must fail. Needs `xcodegen`, no simulator | | full | `Tests/Examples/ExampleProjectSpm/test-ios.sh` | RxSwift smart defaults, RIBs external annotation, type erasure, the SPM plugin. Needs an iOS Simulator | All of them run on every push ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)), the fast lane first. Run them locally before cutting a tag. Release notes, including what each version changes in the generated output and what breaks: [CHANGELOG.md](CHANGELOG.md). Working on the templates themselves — layout, where to change what, the release procedure: [CONTRIBUTING.md](CONTRIBUTING.md). ## Rationale Code-generation is an extremely powerful technique to improve the developer team's productivity by eliminating time-consuming manual tasks, and ensuring consistency between different parts of the codebase. Typical examples include generating API wrappers for database and network services, data structure representations as native types in a specific programming language, generating test doubles and mocks for unit-testing, etc. Due to Swift language's static nature and a (very) limited runtime reflection capabilities, it's arguably [the only modern language with no mocking framework](https://blog.pragmaticengineer.com/swift-the-only-modern-language-with-no-mocking-framework/), which makes it hard for development teams to apply patterns typical for more dynamic languages when writing unit-tests. The common approach to improving developer's productivity has been to write tools that parse the source code of the program, and automatically generate mock class definitions that can be later used in place of actual object's dependencies in unit tests, to be able to instantiate the object in isolation from the rest of the system and test its behavior by analyzing side effects it performs on dependencies. There are tools like [Sourcery](https://github.com/krzysztofzablocki/Sourcery), [SwiftyMocky](https://github.com/MakeAWishFoundation/SwiftyMocky), [Cuckoo](https://github.com/Brightify/Cuckoo), there's even a [plug-in](https://plugins.jetbrains.com/plugin/9601-swift-mock-generator-for-appcode) for the JetBrain's AppCode. These tools and plug-ins are capable of auto-generating mock class definitions based on reading source type declarations, however, I was personally struggling to find a comprehensive solution so far, that would cover some advanced coding patterns, in particular, typical for programs that combine `RxSwift` and Uber's `RIBs` frameworks. ## Usage ### Swift Package Manager (SPM) prebuild plugin A [prebuild SPM plugin](https://github.com/apple/swift-package-manager/blob/main/Documentation/Plugins.md#build-tool-plugins) runs before the project is built, allowing to generate code based on the project source files. 1. To add the plugin to your project, add the plugin dependency to your `Package.swift`: ```swift // Package.swift // swift-tools-version: 5.9 // The minimum supported swift-tools version is 5.6 import PackageDescription let package = Package( name: "YourPackageName", products: [ // ... ], dependencies: [ // ... .package(url: "https://github.com/modaal-agent/swift-sourcery-templates.git", from: "0.8.0"), ], targets: [ .target( name: "YourTarget", dependencies: [ // ... ], plugins: [ .plugin(name: "SourcerySwiftCodegenPlugin", package: "swift-sourcery-templates") ] ), .testTarget( name: "YourTargetTests", dependencies: [ // ... .target(name: "ExampleProjectSpm"), ], plugins: [ .plugin(name: "SourcerySwiftCodegenPlugin", package: "swift-sourcery-templates") ] ), ] ) ``` The `SourcerySwiftCodegenPlugin` depends on a binary artifact bundle — [the one this repository publishes](#the-released-artifact-bundle), which carries the Sourcery engine and the `templates/` tree together, so resolving the package gets both at one version. The first time it is invoked, Xcode/build system would ask for a permission to run the plugin. You might also get an unverified developer warning when the plugin tries to invoke Sourcery for the first time. To fix it, please use the [woraround](https://github.com/krzysztofzablocki/Sourcery/#issues) for removing Sourcery from quarantine: ``` xattr -dr com.apple.quarantine <...Derived Data Folder>/SourcePackages/artifacts/swift-sourcery-templates/sourcery/swift-sourcery-templates-.artifactbundle/sourcery/bin/sourcery ``` 2. Configuring code generation Code generation is configured per target. Put a `*.sourcery*.yml` config file in the target's source folder (refer to the [example project](Tests/Examples/ExampleProjectSpm/)): Sourcery config files per target Here, `ExampleProjectSpm` and `ExampleProjectSpmTests` are product targets for which code generation is enabled. Type erasures should be available in the main target, mocks in the test target — so each target carries its own config, and each target's generated files land on that target's compile path. **A config says what to generate. The plugin supplies where.** The smallest config that works is three lines: ```yml # .Sourcery.Mocks.yml — beside the target it generates for templates: - Mocks ``` That is a complete config. The plugin fills in the target's sources, the output directory the build actually collects from, and — for a test target that directly depends on exactly one module of your package — `args.testable`. #### Where to read: the source closure `${SOURCERY_SOURCES}` expands to the target's own sources **plus the recursive closure of its dependencies**, first-party and external alike, one directory per module: ```yml sources: - ${SOURCERY_SOURCES} - ${SOURCERY_TARGET_MyModuleTests}/SourceryAnnotations # a subset — yours to choose ``` This is what lets a mock carry requirements a protocol inherits from a module further down the graph. A protocol refining one from a package your target reaches only through another package has no `SOURCERY_TARGET_*` variable naming it, and no config could reach it before — the mock came out missing those requirements, and the failure showed up as *"does not conform to protocol"* in the consumer's build. `${SOURCERY_SOURCES}` closes that. There are three ways a config can say what to read, and the plugin treats them differently: | the config | what is scanned | | --- | --- | | no `sources:`, `project:` or `package:` key | the closure, appended by the plugin | | `sources:` containing `- ${SOURCERY_SOURCES}` | the closure, **plus** every other entry in the list | | `sources:` without the placeholder | exactly what is listed — the pre-0.3 behaviour, untouched | `${SOURCERY_SOURCES}` is deliberately **not** an environment variable: nothing exports it, so if the plugin ever failed to substitute it, Sourcery would report an unexpanded path rather than silently scanning less than you meant. #### Naming a template A `templates:` entry can be the bare name of a template this package ships: ```yml templates: - Mocks # or Component, or TypeErase ``` A name is resolved in this order, first match winning: 1. it contains `/` or starts with `$` — a path, left as written; 2. a file of that exact name sits beside your config — yours wins, so a project with its own `Mocks.swifttemplate` keeps getting its own; 3. a template shipped by this package; 4. neither — the plugin warns, lists the shipped templates, and Sourcery then fails on it. The shipped `templates/` directory in step 3 is whichever of these is a directory on disk, first one winning, and the build log names the one that answered: | | where it looks | works in | | --- | --- | --- | | 1 | this package in your dependency graph | a Swift package | | 2 | the plugin's own source file, three components up | a package or an Xcode project | | 3 | the artifact bundle `Package.swift` pins, beside the engine | a package or an Xcode project | Routes 2 and 3 read no package graph, which is why a bare template name works in an Xcode project too. At a released version all three name the same files: the bundle a tag pins was built from that tag's own `templates/`. `SOURCERY_TEMPLATES` is exported too, pointing at that directory, for a config that would rather write the path itself. #### Where to write `output:` is the plugin's. A prebuild command's outputs are collected only from the directory it declared, so a file generated anywhere else is written and then ignored — the target compiles without it, and the failure surfaces as a missing type far from its cause. So: - omit `output:` and the plugin supplies it; - write `output: ${SOURCERY_OUTPUT_DIR}` and it resolves to the same directory; - name any other directory and **the build fails**, naming both paths. #### The options that stay yours `args.import`, `args.excludedSwiftLintRules` and the choice of template have no single right answer, so the plugin carries them through unread. `args.testable` is the one exception, and only where the answer is unambiguous: for a test target with exactly one direct dependency on a module of your own package, the plugin inserts `testable: []`. With none, or with two or more, it inserts nothing and names the candidates in the build log. An `import:` or `testable:` item for a module some platform of the target lacks is written `UIKit // if canImport`, and is generated between `#if canImport(UIKit)` and `#endif`. A release before the one that reads that form generates `import UIKit // if canImport`, an unconditional import. #### One config, several templates Sourcery writes one `