MockoloをSPMで導入する

# Mockolo とは

Mockoloの説明は公式READMEに頼ります。

>Mockolo is an efficient mock generator for Swift. Swift doesn't provide mocking support, and Mockolo provides a fast and easy way to autogenerate mock objects that can be tested in your code. One of the main objectives of Mockolo is fast performance. Unlike other frameworks, Mockolo provides highly performant and scalable generation of mocks via a lightweight commandline tool, so it can run as part of a linter or a build if one chooses to do so.

日本語訳
>Mockolo は、Swift 用の効率的なモック ジェネレーターです。Swift はモックをサポートしていませんが、Mockolo はコード内でテストできるモック オブジェクトを高速かつ簡単に自動生成する方法を提供します。Mockolo の主な目的の 1 つは、高速なパフォーマンスです。他のフレームワークとは異なり、Mockolo は軽量のコマンドライン ツールを介して、非常に高性能でスケーラブルなモック生成を提供するため、必要に応じてリンターまたはビルドの一部として実行できます。

https://github.com/uber/mockolo#welcome-to-mockolo

通信を行う処理などは、単体テストなどではそのまま使うと結果を制御するのが難しいので、大体はMockクラスを作って戻り値を制御したりすることでテストの安定性などを担保しつつ通信などが絡む振る舞いのテストを行います。
ただMockクラスをいちいち作るのは大変という問題もあります。(XcodeはMockの生成をサポートしていないので)
MockoloではMockクラスの自動生成を行ってくれるため、テストを行うためのMock作成のコストを大幅に削減できるようになります。

# SPM で導入するモチベーション

そもそも、Swift のライブラリ管理ツールは SPM の他にも CocoaPods や Carthage、ツール系で言えば、Mint や Bundler などたくさんあります。
管理ツールによっては対応していないライブラリなどもありますが、SPMの場合は、Apple 純正のツールということもあり、最近だと SPM で導入できるライブラリはかなり増えています。(タイトルの通りMockoloもSPMに対応しています！)
ライブラリ管理ツールはできれば統一できたほうが、ライブラリの管理も楽になるので
であれば、近年でデファクトスタンダードになりつつある SPM で導入した方が、ライブラリ管理ツールの統一に一歩近づくという判断でSPMによる導入を検討してみました。

# SPM での導入手順

公式の README に基本的な導入方法は記載していたので、これを参考に導入していきます。

https://github.com/uber/mockolo

## 手順 1. Package.swift を作成する

:::note
Mockolo を使う対象が SPM の Package.swift で管理されている場合は、この手順で新しく Package.swift を作らずとも、もともとある Package.swift に Mockolo を追加できるので必要ない
:::

`Xcode` -> `File` -> `New` -> `Package`から Package.swift のプロジェクトを作成します。

作成された Pakcage.swift の例が以下です。

```swift
// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "MockoloPluginLib",
    products: [
        // Products define the executables and libraries a package produces, making them visible to other packages.
        .library(
            name: "MockoloPluginLib",
            targets: ["MockoloPluginLib"]),
    ],
    targets: [
        // Targets are the basic building blocks of a package, defining a module or a test suite.
        // Targets can depend on other targets in this package and products from dependencies.
        .target(
            name: "MockoloPluginLib"),
        .testTarget(
            name: "MockoloPluginLibTests",
            dependencies: ["MockoloPluginLib"]
        ),
    ]
)
```

## 手順 2. Package.swift に Mockolo の依存を追加する

`dependencies`に`mockolo`を追加します。
バージョンは 2025/3/22 時点の最新バージョンである`2.3.1`を指定します。

```diff_swift
// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "MockoloPluginLib",
    products: [
        .library(
            name: "MockoloPluginLib",
            targets: ["MockoloPluginLib"]),
    ],
+   dependencies: [
+       .package(url: "https://github.com/uber/mockolo", exact: "2.3.1")
+   ],
    targets: [
        .target(
            name: "MockoloPluginLib"),
        .testTarget(
            name: "MockoloPluginLibTests",
            dependencies: ["MockoloPluginLib"]
        ),
    ]
)
```

## 手順 3. ターゲットに Plugin を追加

`target`に Mockolo 実行用の plugin を定義します。
また必要ないターゲットを全て削除します(今回必要なのは Mockolo を実行するための plugin のみのため)

```diff_swift
// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "MockoloPluginLib",
    products: [
-       .library(
-           name: "MockoloPluginLib",
-           targets: ["MockoloPluginLib"]),
-           products: [
+       .plugin(name: "RunMockolo", targets: ["RunMockolo"])
    ],
    ],
    dependencies: [
        .package(url: "https://github.com/uber/mockolo", exact: "2.3.1")
    ],
    targets: [
-       .target(
-           name: "MockoloPluginLib"),
-       .testTarget(
-           name: "MockoloPluginLibTests",
-           dependencies: ["MockoloPluginLib"]
-       ),
+       .plugin(
+           name: "RunMockolo",
+           capability: .buildTool(),
+           dependencies: [.target(name: "mockolo")]
+       ),
+       .binaryTarget(
+           name: "mockolo",
+           url: "https://github.com/uber/mockolo/releases/download/2.3.1/mockolo-macos.artifactbundle.zip",
+           checksum: "daefaebdb24bf0f0a5f830523330b81850e9e95a3e2fc2011a50054309a55eae"
+       ),
   ]
)
```

`binaryTarget`に指定した値は、README でも書いてあるが、リリースノートに記載されているものを設定しました。

```swift
.binaryTarget(
    name: "mockolo",
    url: "https://github.com/uber/mockolo/releases/download/2.3.1/mockolo-macos.artifactbundle.zip",
    checksum: "daefaebdb24bf0f0a5f830523330b81850e9e95a3e2fc2011a50054309a55eae"
),
```

https://github.com/uber/mockolo/releases

ついでにここで、先ほど削除したターゲットのフォルダは全て削除しておきます。
`Sources`と`Tests`のフォルダをそのまま削除します（以下キャプチャの赤枠箇所）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/d9e1965f-0ebc-496e-9410-55f3c22f3368.png)


## 手順 4. plugin の実装を追加する

`Plugins`ディレクトリを作成し、その配下に`RunMockolo`ディレクトリを作成します。
以下のようなディレクトリ構成になるはずです。(`Plugins`配下のディレクトリ名は Package.swift で定義した plugin のターゲット名が入るので適宜変更すること)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/8efcb032-0f40-4179-beb8-b616605c70e3.png)


`RunMockolo`に以下内容のファイルを作成します。

```swift
import Foundation
import PackagePlugin

@main
struct RunMockoloPlugin: BuildToolPlugin {
	func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {

		let engine = try RunMockoloEngine(pluginWorkDirectory: context.pluginWorkDirectoryURL,
		                                  targetUrl: URL(string: target.directory.string)!,
		                                  mockoloPath: context.tool(named: "mockolo").url)
		return engine.build()
	}
}

#if canImport(XcodeProjectPlugin)
	import XcodeProjectPlugin

	extension RunMockoloPlugin: XcodeBuildToolPlugin {

		func createBuildCommands(context: XcodeProjectPlugin.XcodePluginContext,
		                         target: XcodeProjectPlugin
		                         	.XcodeTarget) throws -> [PackagePlugin.Command] {

			let engine = try RunMockoloEngine(pluginWorkDirectory: context.pluginWorkDirectoryURL,
			                                  targetUrl: context.xcodeProject.directoryURL,
			                                  mockoloPath: context.tool(named: "mockolo").url)
			return engine.build()
		}
	}

#endif

struct RunMockoloEngine {

	let pluginWorkDirectory: URL
	let targetUrl: URL
	let mockoloPath: URL

	func build() -> [Command] {

		let generatedSourcePath = pluginWorkDirectory.appending(component: "GeneratedMocks.swift")

		return [.prebuildCommand(displayName: "Run mockolo",
		                         executable: mockoloPath,
		                         arguments: ["-s", targetUrl.path(),
		                                     "-d", generatedSourcePath.path()],
		                         outputFilesDirectory: pluginWorkDirectory)]
	}
}
```

やっていることは、plugin を使用するターゲット内でビルドしたときに、モックを生成するだけです。

`XcodeBuildToolPlugin`に準拠した拡張を実装した理由は、project ファイルのビルドフェーズの`Run Build Tool Plug-ins`にで使用できるようにするためです。
`XcodeBuildToolPlugin`に準拠していないと、`Plugin doesn't support Xcode projects`のエラーが発生してしまいます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/9b7a920f-5ef2-403c-a576-8213832ade88.png)


ここまでの手順で、一旦 Mockolo を使うための Plugin を作成する作業は終了です。

## project ファイルで Mockolo を使う

以下のように、package dependencies から上記手順で作成したローカルの SPM モジュール(Package.swift が入っているディレクトリ)を指定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/ff1b205a-d6b2-42fa-8df6-0d476e32fa4b.png)


あとは、アプリケーションターゲットのビルドフェーズの設定を開いて、`Run Build Tool Plug-ins`で`RunMockolo`を指定するだけです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/9b7a920f-5ef2-403c-a576-8213832ade88.png)

これができたらアプリケーションターゲットのビルドを行うと、`/// @mockable`がついた`protocol`のモックが自動生成されるようになります。

導入後の詳しい使い方は以下記事がとてもわかりやすかったです。

https://qiita.com/uhooi/items/0226a28190aea6392806

## SPM モジュール内で Mockolo を使う

Package.swift の target 定義で、以下のように plugin を指定すれば、ビルド時にモックを自動生成してくれるようになります。

```swift
targets: [
        ...
        .target(
            name: "RxSwiftExample",
            dependencies: [
                ...
            ],
            plugins: [
                .plugin(name: "RunMockolo")
            ]
        ),
        ...
    ]
```

SPM で Mockolo を使用している Package.swift の例は以下の通り。

```swift
// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "RxSwiftExample",
    platforms: [.iOS(.v15)],
    products: [
        .library(
            name: "RxSwiftExample",
            targets: ["RxSwiftExample"]),
    ],
    dependencies: [
        .package(url: "https://github.com/ReactiveX/RxSwift.git", exact: "6.9.0"),
        .package(url: "https://github.com/uber/mockolo", exact: "2.3.1")
    ],
    targets: [
        .plugin(
            name: "RunMockolo",
            capability: .buildTool(),
            dependencies: [.target(name: "mockolo")]
        ),
        .binaryTarget(
            name: "mockolo",
            url: "https://github.com/uber/mockolo/releases/download/2.3.1/mockolo-macos.artifactbundle.zip",
            checksum: "daefaebdb24bf0f0a5f830523330b81850e9e95a3e2fc2011a50054309a55eae"
        ),
        .target(
            name: "RxSwiftExample",
            dependencies: [
                "RxSwift",
                .product(name: "RxCocoa", package: "RxSwift")
            ],
            plugins: [
                .plugin(name: "RunMockolo")
            ]
        ),
        .testTarget(
            name: "RxSwiftExampleTests",
            dependencies: ["RxSwiftExample"]
        ),
    ]
)
```

SPM モジュール内で Mockolo の Plugin を使っているリポジトリは以下になります。

https://github.com/stotic-dev/RxSwiftExample