Swiftのモック生成ライブラリ「Mockolo」のセットアップ&操作方法


はじめに

Xcodeにはモック生成機能が搭載されておらず、手動で実装するのが大変だと感じてきたため、導入することにしました。

「Mockolo」とは?

Swift用のモック生成ライブラリです。

現在はプロトコルのモック生成のみ対応しており、クラスのモック生成は追加予定とのことです。
1.1.3でクラスのモック生成もサポートされました。
https://github.com/uber/mockolo/releases/tag/1.1.3

環境

  • OS:macOS Catalina 10.15.2
  • Swift:5.1.3
  • Xcode:11.3 (11C29)
  • Mockolo:1.1.1

セットアップ

Mockoloのインストール

Mintからインストールします。

Mintfile
$ mint bootstrap

手動でインストールするには、公式ドキュメントをご参照ください。
https://github.com/uber/mockolo#build--install

ビルド時にモックを生成するようにする

Mockoloはモックの生成時間が速いため、ビルドするたびにモックを生成するようにします。
ビルド時間が気になる場合、手動でコマンドを実行してモックを生成してください。

Xcodeでプロジェクトを開く
TARGETSで製品ターゲットを選択 > Build Phases > +をクリック > New Run Script Phase >
ドラッグ&ドロップで「Compile Sources」の直前に移動

スクリプトは「Generate Mocks with Mockolo」のようにわかりやすい名前を付けるといいです。

展開して以下のスクリプトを記述します。

if which mint >/dev/null; then
  rm -f $SRCROOT/MockResults.swift
  mint run mockolo mockolo --sourcedirs $SRCROOT/{製品ターゲット名} --destination $SRCROOT/MockResults.swift
else
  echo "warning: Mint not installed, download from https://github.com/yonaskolb/Mint"
fi

すでにモックがあるとビルドエラーになることがあるため、生成前に rm で削除しています。

Output Files > +をクリック
--destination で指定しているファイルパスを記述します。

$SRCROOT/MockResults.swift

生成されるファイルを記述しないと、CI/CD時に以下のエラーが発生します。

error: Build input file cannot be found:

以下の記事を参考にさせていただきました。
https://qiita.com/lovee/items/fa3ef5e60cfbf31996c0

Mintを使っていない場合、 mint run mockolo を外し、if文の条件を変更してください。

使っているオプションを説明します。
以下の2つは必須であり、必要に応じて値を変更してください。

オプション 説明
--sourcedirs 生成対象のフォルダパス
製品ターゲット名のフォルダを指定すれば、通常は全ファイルを対象にできる
--destination モックの生成パス

--mock-final は任意ですが、私は付けるのが好みです。

オプション 説明
--mock-final モックに final を付ける
1.2.8で追加された

その他のオプションは公式ページまたは mockolo --help をご参照ください。

プロジェクトをビルドし、「$SRCROOT(通常はプロジェクトのルートフォルダ)」に「MockResults.swift」が生成されたら、プロジェクトにドラッグ&ドロップします。

[Copy items if needed]チェックをOFFにし、[Finish]をクリックします。

バージョン管理から無視する

不要な競合を防ぐため、生成された「MockResults.swift」をバージョン管理の対象外にします。

Gitを使っている場合、以下を「.gitignore」に追加するのみでOKです。

.gitignore
+ MockResults.swift

操作方法

モックを生成したいプロトコルに @mockable のドキュメンテーションコメントを付けます。
タイプエイリアスがある場合、カッコ内に書きます。

Foo.swift
/// @mockable(typealias: T = AnyObject; U = StringProtocol)
public protocol Foo {
    associatedtype T
    associatedtype U: Collection where U.Element == T 
    associatedtype W 

    var num: Int { get set }

    func bar(arg: Float) -> String
}

ビルドすると、モックが生成されます。

MockResults.swift
// クラス名は `{プロトコル名}Mock` となる
public class FooMock: Foo {
    typealias T = AnyObject
    typealias U = StringProtocol
    typealias W = Any // 指定しないと `Any` になる

    init() {}
    init(num: Int = 0) {
        self.num = num
    }

    var numSetCallCount = 0
    var underlyingNum: Int = 0
    var num: Int {
        get {
            return underlyingNum
        }
        set {
            underlyingNum = newValue
            numSetCallCount += 1
        }
    }

    var barCallCount = 0
    var barHandler: ((Float) -> (String))?
    func bar(arg: Float) -> String {
        barCallCount += 1
        if let barHandler = barHandler {
            return barHandler(arg)
        }
        return ""
    }
}

自動生成されたコードのうち、テストで使うプロパティのみ説明します。

プロパティ 説明
{プロパティ名}SetCallCount セッターの呼び出し回数
{メソッド名}CallCount メソッドの呼び出し回数
{メソッド名}Handler メソッドの呼び出し時に実行されるクロージャ

テスト時は以下のように使います。

FooTests.swift
func testMock() {
    // モックを生成する
    let mock = FooMock(num: 5)

    // 対象プロパティのセット回数を確認する
    XCTAssertEqual(mock.numSetCallCount, 1)

    // ハンドラは対象メソッドの呼び出し前に自分で代入する
    mock.barHandler = { arg in
        return String(arg)
    }

    // 対象メソッドの呼び出し回数を確認する
    XCTAssertEqual(mock.barCallCount, 0)
}

おわりに

とても簡単にモックを生成できました!
これでテスト時にVIPERのモックを手動で実装する手間が省けるぞ😊

もっと早く導入すればよかったと思いました笑

参考リンク