この記事は「ミクシィグループ Advent Calendar 2020」の10日目です。

現在自分が携わっている6gramのiOSクライアントではモック生成ライブラリとしてMockoloを導入して半年ほど運用しています。 今回はMockoloの選定理由や簡単な紹介をしたいと思います。

はじめに

6gramのiOSクライアントは、アプリ全体のアーキテクチャとしてVIPERを採用しています。 VIPERでは各画面ごとに単一責任の原則に基づいてView・Interactor・Presenter・(Entity)・Routerに分割され、protocol経由でお互いにアクセスすることで依存分離やメンテナンス性、そしてTestabilityを確保しています。

以下はかなり簡略化したVIPERのコードです。 Viewの viewDidLoad() 起点で View→Presenter→Interactor→Presenter→View とprotocolを経由で情報が伝搬されていきます。

// View の protocol
protocol HogeView {
    func updateUserName(_ name: String)
}
// Presenter の protocol
protocol HogePresentation {
    func viewDidLoad()
}
// Interactor の protocol
protocol HogeUsecase {
    func fetchUser()
}
// Interactor の処理結果を通知する Delegate の protocol
// 実体は Presenter になる。
protocol HogeInteractorDelegate {
    func didFetchUser(_ user: User)
}
// Router の protocol
protocol HogeWireframe {
}

// View の実装
class HogeViewController: UIViewController, HogeView {
    // View は Presenter を protocol として保有。
    private let presenter: HogePresentation

    private lazy var nameLabel = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()
        // View -> Presenter
        presenter.viewDidLoad()
    }

    // Presenter -> View
    func updateUserName(_ name: String) {
        nameLabel.text = name
    }
}
// Presenter の実装
class HogePresenter: HogePresentation, HogeInteractorDelegate {
    // Presenter も各要素に protocol 経由でアクセスする
    private unowned var view: HogeView
    private var interactor: HogeUsecase
    private var router: HogeWireframe

    // View -> Presenter
    func viewDidLoad() {
        // Presenter -> View
        view.updateUserName("")
        // Presenter -> Interactor
        interactor.fetchUser()
    }

    // Interactor -> Presenter
    func didFetchUser(_ user: User) {
        // Presenter -> View
        view.updateUserName(user.name)
    }
}
// Interactor, Router は省略

そしてPresenterのテスト時にはPresenter以外の各要素のモックを作成してPresenterにセット、Presenterの入出力を検査することでテストを行います。

// View の protocol の継承したモック
class HogeViewMock: HogeView {
    var values = [String]()

    func updateUserName(_ name: String) {
        values.append(name)
    }
}
// ほかは省略

var mockView = HogeViewMock()
var mockInteractor = HogeUsecaseMock()
var mockRouter = HogeWireframeMock()

func testPresenter() throws {
    // モックをセットした Presenter
    let presenter = HogePresenter(view: mockView, interactor: mockInteractor, router: mockRouter)

    presenter.viewDidLoad()
    XCTAssertEqual(mockView.values, [""])
    XCTAssertEqual(mockInteractor.fetchUserCallCount, 1)

    let user = User(id: "1", name: "NAME")
    presenter.didFetchUser(user)
    XCTAssertEqual(mockView.values.last, "NAME")
}

このモックの作成ですが、protocolが多いVIPERアーキテクチャにおいてそのコストはバカになりません。 そこでモックライブラリを導入することにしました。

ライブラリの候補

選定は以下の要件で行いました。

  • protocolを対象にしてモックが作れること
    • この時点ではクラスのモックは特に考えていませんでした。
  • 関数とプロパティについて呼ばれた回数・引数の検証ができること
    • 冒頭のコードのように updateUserName(_:) 的なメソッドが増えがちな設計においては引数の検証は必須です。
  • 捨てやすいこと
    • 個人的な経験からライブラリの導入を検討するときはこれを要件に含めるようにしています。
    • 他のライブラリへの乗り換えや、自前での実装に置き換えることが容易であると良いです。
      • すべてのライブラリが捨てやすくあるべきだとは思いませんし、アプリ本体ではなくテスト用のライブラリなので依存も許容できる分野ですが、努力目標として観点に含めています。

以下に当時検討したものを書いていきます。

SwiftyMocky

  • MakeAWishFoundation/SwiftyMocky
  • Sourceryベースのモックジェネレータ&ライブラリ
  • 用意されているメソッド名が GivenVerify といったようにUpper Camel Caseなのが気になる
  • protocolのモック: ○
    • モックしたいprotocolに対して AutoMockable を適用するか、Sourceryのアノテーションをつけることでモックが生成できます。
protocol HogeView: AutoMockable {
    // ...
}

//sourcery: AutoMockable
protocol HogeView {
    // ...
}
  • 関数・プロパティの呼び出し・引数の検証: △
    • 検討時点ではある条件に合う引数で何回呼ばれたかは検証できるが、順番の検証や最後の引数を取り出すといったことは難しそうでした。
  • 捨てやすさ: ✗
    • Homebrew(or Mint)で入れるCLIとCarthage(or CocoaPods)で入れるフレームワークの2つで成り立つ
    • 生成されたモックやテストコードで import SwiftMocky が出てくる
      • ライブラリを抜くときにテストコードやモックの置き換えが必要になるのが懸念点

Cuckoo

  • Brightify/Cuckoo
  • AndroidのMockitoに似たモックジェネレータ&ライブラリ
  • protocolのモック: ○
    • generate コマンドに渡すファイル内のprotocol/classに対してモックが生成されます。
  • 関数・プロパティの呼び出し・引数の検証: ○
    • Argument captureを使えばやりたいことはできそうでした。
      • 毎度 ArgumentCaptor を生成するがちょっと面倒。
  • 捨てやすさ: ✗
    • Carthage(or CocoaPods or SPM)でインストールするとライブラリとジェネレーターが入ってきます。
    • SwiftyMocky と同じように生成されたモックやテストコードで import Cuckoo が出てくる

Mockolo

  • uber/mockolo
  • 今回の本命
  • Uber製のモックジェネレータ
    • パフォーマンスをモチベーションの一つとしており、200万行のコード・1万個のprotocolのような環境でも秒単位で生成できるらしい
    • 機能は最低限。他2つのような高級なVerifyの仕組みはなく XCTest で愚直にassertしていくことになります。
  • protocolのモック: ○
    • ディレクトリやファイル群を指定して、その中にコメントで @mockable アノテーションがついているprotocol(当時。現在はクラスのモックもサポート)のモックを生成します。
/// @mockable
protocol HogeView {
}
  • 関数・プロパティの呼び出し・引数の検証: △ (当時)
    • 選定当時は呼び出し回数の検証機能しかありませんでした。
    • また関数にClosureを差し込む機能はあるため自前でコードを書けばなんとかなる。
  • 捨てやすさ: ○
    • ここまでの2つと違いMockoloはHomebrewやMintで入れるモックジェネレータのみからなります。
    • 生成されるモックはPure Swiftなコードで、テストコードにも特別なライブラリへの依存はありません。
      • Mockoloの利用をやめてもそれまで利用していたモックは依存無しで引き続き利用することができます。

選定・機能追加

検討の結果、 CuckooMockolo の2択になりました。

Cuckooであればやりたいことができるが、がっつりロックインされてしまうのが気がかりでした。 また、使いたかった機能についても毎度 ArgumentCaptor を生成しなければならず、ちょっと面倒だなと感じていました。

// Cuckoo
// README の ArgumentCaptor のサンプルコード

mock.readWriteProperty = 10
mock.readWriteProperty = 20
mock.readWriteProperty = 30

let argumentCaptor = ArgumentCaptor<Int>() // ←毎度これの作成が必要
verify(mock, times(3)).readWriteProperty.set(argumentCaptor.capture())
argumentCaptor.value // Returns 30
argumentCaptor.allValues // Returns [10, 20, 30]

一方でMockoloは当時関数にClosureを差し込む機能だけはあり、自前でCaptureするコードを書けばなんとか引数の履歴の検証を実現することはできそうではありました。

// Mockolo

let mock = FooMock()
var fooFuncHistory: [Int] = []
mock.fooFuncHandler = { val in
     fooFuncHistory.append(val)
}
mock.fooFunc(1)
mock.fooFunc(2)
mock.fooFunc(3)

XCTAssertEqual(fooFuncHistory, [1, 2, 3])

しかしこれをテストするすべての場所で書くのは現実的ではありません。

そこで他のライブラリの検討と同じようにMockoloのソースコードを呼んでみました。 MockoloはSwiftSyntax(と当時はSourceKittenも)でソースコードを解析し、その結果をSwiftの String の操作で直にモックのコードへ変換するというシンプルな構造をしていました。

↓関数生成の一部

template = """
\(template)
\(1.tab)\(acl)\(staticStr)var \(handlerVarName): \(handlerVarType)
\(1.tab)\(acl)\(staticStr)\(overrideStr)\(keyword)\(name)\(genericTypesStr)(\(paramDeclsStr)) \(suffixStr)\(returnStr)\(genericWhereStr) {
\(wrapped)
\(1.tab)}
"""

https://github.com/uber/mockolo/blob/3e3c1b3a777992b3de7f0169ed8fff456ea7294e/Sources/MockoloFramework/Templates/MethodTemplate.swift#L139-L145

(ここだけ呼んでもわけがわからないかと思いますが、パースしたものが入ってきて↓のような感じになります)

    var hogeCallCount = 0
    var hogeHandler: ((Int) -> (String))?
    func hoge(arg: Int) -> String {
        hogeCallCount += 1
        if let hogeHandler = hogeHandler {
            return hogeHandler(arg)
        }
        return ""
    }

おそらくこのシンプルな構造が高速な生成を支えているものと思われます。

この時点で以下のような状況でした。

  • Mockolo vs CuckooでMockoloに足りないのは引数の履歴の検証機能のみ
  • Mockoloのシンプルな構造のおかげで、機能実装の目処は立っていた
    • 自分で実装するのであればCuckooで毎度 ArgumentCaptor を生成するよりは楽なものが作れるだろうという気持ちもあった。
  • (OSSにコントリビュートとかしてみたい)

よって必要な機能のPRを送って、Mockoloを導入することにしました。

詳細は割愛しますがIssueでの提案とPRが無事受け入れられて機能を実装することができました。

ここまでが6gram iOSでのMockoloの選定理由や導入までの流れです。

Mockoloの簡単な紹介

ここからは自分が実装した機能の宣伝も含めてMockoloの簡単な紹介をします。 ほとんどはREADMEに書いてあることなので、適宜そちらを参照してください。

導入

MintHomebrewで導入ができます。

6gramではMintのライブラリを Mintfile にて管理しているので以下のように記述し、

// Mintfile
uber/mockolo@1.2.9

以下のコマンドでインストール・実行しています。

$ mint bootstrap
$ mint run mockolo -h

基本の使い方

基本的な使い方は、まずモックしたいprotocol/classにコメントで @mockable アノテーションを付与します。

/// @mockable
public protocol Foo {
    var num: Int { get set }
    func bar(arg: Float) -> String
}

そして mockolo コマンドを実行します。 このときに最低限必要なオプションは以下の通りです。

  • --destination, -d
    • 生成するモックファイルの出力先です
  • --sourcedirs, -s or --sourcefiles, -srcs
    • --sourcedirs, -s
      • このオプションに続いて対象のファイルがあるディレクトリをスペース区切りで渡せます。
    • --sourcefiles, -srcs
      • このオプションに続いて対象のファイルをスペース区切りで渡せます。 --sourcedirs, -s による指定がある場合はこちらは無視されます。
# Mint で導入した場合
$ mint run mockolo --sourcedirs ./Sources -d ./Mock.generated.swift

コマンドを実行すると以下のようなモックが指定したパスに生成されます。 これが基本の形でPure Swiftなコードであることがわかります。

public class FooMock: Foo {
    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 ""
    }
}

あとはXCTestの各種Assertionを利用してユニットテストをします。

func testMock() {
    let mock = FooMock(num: 5)
    XCTAssertEqual(mock.numSetCallCount, 1)
    mock.barHandler = { arg in
        return String(arg)
    }
    XCTAssertEqual(mock.barCallCount, 1)
}

各種機能

Mockoloではアノテーションにオプションを付けることで、

  • associatedtype つきのprotocolを typealias を指定してモック生成 (@mockable(typealias: T = AnyObject; U = StringProtocol))
  • RxSwiftの Observable<T> なインターフェースのプロパティの実体を指定 (@mockable(rx: intStream = ReplaySubject; doubleStream = BehaviorSubject))

などの機能を利用したり、コマンドラインオプションを利用して

  • 生成されるモックファイルで追加でモジュールを import する (--custom-imports, -c)
  • 生成されるモックファイルで追加でモジュールを @testable import する (--testable-imports, -i)

することもできます。

Function Arguments History Captor

これが導入検討時に実装した機能で、対象の関数が呼び出されたときの引数の履歴をモックに保持することができます。 これを実装したことで選定の要件を満たすことができ、Mockoloの導入に繋がりました。

具体的には冒頭のVIPERのViewのprotocolがあったとき、

protocol HogeView {
    func updateUserName(_ name: String)
}

通常生成される updateUserNameCallCount などの他に、 updateUserNameArgValues という変数が生成されます。

public class HogeViewMock: HogeView {
    // ...

    var updateUserNameArgValues = [String]()   // 履歴が保持される変数

    // ...

    func updateUserName(_ name: String) {
        // ...

        updateUserNameArgValues.append(name)   // 関数呼び出しの中で履歴を保存

        // ...
    }
}

そしてこれを利用して簡潔にPresenterのテストすることができます。

@testable import App
import XCTest
// Mockolo 関係の import は必要ない

class HogePresenterTest: XCTestCase {
    var presenter: HogePresenter!

    var mockView: HogeViewMock!
    var mockInteractor: HogeUsecaseMock!
    var mockRouter: HogeWireframeMock!

    override func setUpWithError() throws {
        mockView = HogeViewMock()
        mockInteractor = HogeUsecaseMock()
        mockRouter = HogeWireframeMock()

        // Presenter には Mockolo で生成したモックを挿入
        presenter = HogePresenter(wireframe: mockRouter,
                                  view: mockView,
                                  interactor: mockInteractor)
    }

    func testInitialize() throws {
        presenter.viewDidLoad()

        // 引数履歴の .last を見ることで呼び出した回数は気にせず、その時点で View に反映されている値を検証
        XCTAssertEqual(mockView.updateUserNameArgValues.last, "")
        XCTAssertEqual(mockInteractor.fetchUserCallCount, 1)

        presenter.didFetchUser(User(id: "1", name: "NAME"))

        XCTAssertEqual(mockView.updateUserNameArgValues.last, "NAME")
    }
}

テストコードを見てもMockoloが生成するモックが非常に薄いものであることがわかるかと思います。

また Function Arguments History Captor はデフォルトでは無効になっていて、 history オプション付きのアノテーションをつけて特定の関数のみ有効にするか、

/// @mockable(history: updateUserName = true)
protocol HogeView {
    func updateUserName(_ name: String)
}

mockolo コマンドに --enable-args-history オプションを渡すことですべての関数に対して有効にすることができます。

$ mint run mockolo --sourcedirs ./Sources -d ./Mock.generated.swift --enable-args-history

Tips: Xcode Previews用にMockoloで生成したモックを使う

6gramではメルカリさんの以下の記事を参考に、アプリ本体と別でDeployment Target = 13.0にしたターゲットを追加してUIKitベースの開発にXcode Previewsを利用しています。

しかし冒頭の HogeViewController のように viewDidLoad() でPresenterにアクセスするようなView ControllerをPreviewしようとすると、実際に通信が発生してしまうためモックを利用することになります。 6gramではMockoloで生成したモックを利用しています。

struct HogeViewControllerPreview: PreviewProvider {
    static var previews: some View {
        ForEach([ColorScheme](arrayLiteral: .light, .dark), id: \.self) { scheme in
            // UIViewController をいい感じに UIViewControllerRepresentable にしてくれるラッパー
            ViewControllerWrapper {
                let controller = HogeViewController()

                // Mockolo で生成した Presenter のモックを挿入
                let presenter = HogePresentationMock()
                controller.presenter = presenter

                return controller
            }
            .environment(\.colorScheme, scheme)
        }
    }

    static let platform: PreviewPlatform? = .iOS
}

Mockoloで生成されたモックはデフォルトではいい感じに何もしないので、この用途にもぴったりでした。 またClosureを渡すことで動作を指定できるので、Preview用のデータを渡すのも簡単です。

let controller = HogeViewController()

let presenter = HogePresentationMock()
presenter.viewDidLoadHandler = {
    controller.updateUserName("山田 太郎")
}
controller.presenter = presenter

return controller

Preview用のモックはPreviewのターゲットのBuild Phaseにて行っていますが、このときモックの内容が変わらなくてもPreviewが頻繁に停止してしまうため、6gramでは一時ディレクトリに書き出してから必要があれば置き換えるようにしています。

# 一時ディレクトリに書き出し
mint run mockolo --sourcedirs "$SRCROOT/Sources" --testable-imports Core --custom-imports RxSwift --destination "$TEMP_DIR/Mock.Preview.generated.swift" --enable-args-history

# 毎度更新すると Xcode Previews が止まってしまうので、一時ディレクトリに書き出して変化があったときのみ置き換える
if [ ! -e "$SRCROOT/Mock.Preview.generated.swift" ] || ! diff -q "$SRCROOT/Mock.Preview.generated.swift" "$TEMP_DIR/Mock.Preview.generated.swift"; then
    mv "$TEMP_DIR/Mock.Preview.generated.swift" "$SRCROOT/Mock.Preview.generated.swift"
    echo "Mock.Preview.generated.swift was updated."
else
    rm "$TEMP_DIR/Mock.Preview.generated.swift"
    echo "Updating Mock.Preview.generated.swift was skipped. There were no changes."
fi

まとめ

モックジェネレータMockoloの6gramでの選定の経緯や使い方・Tipsの紹介を行いました。

Mockoloは Function Arguments History Captor を導入したことで、6gramのような形のVIPERを採用しているプロダクトでは使いやすいモックジェネレータになったと思います。 さらに 捨てやすさ の観点や、(6gramでは導入時は重視はしていませんでしたが)Mockoloの特徴の高い生成パフォーマンスを考えると、プロダクトのアーキテクチャによらず便利ではないかと思います。

またそのシンプルな構造から機能追加が容易というのもメリットだと思います。 実際にOSSへのコントリビュート初挑戦の自分もすんなりとPRを送ることができました。

この記事が皆様のモックライブラリ選定の参考になれば幸いです。