Mockito
Swift
swift4

Mockを自動生成してくれるCuckooフレームワークで単体テストを楽に早く書く(Swift4)

はじめに

こんにちは、みなさん、単体テスト書くのは大好きですよね?
swift歴3ヶ月目にしてSwiftのテストタブルなコードを、自前で書くことに面倒臭さを感じていたので、何か良いライブラリはないかな〜。と探していたところ、 という、MockGenerateライブラリがある事をrealmのtry! Swift翻訳ページで知りました。(2017年3月のTry! SwiftTokyoなのでもともとswiftをやっている方はみんな知っている?)

早速CuckooのREADME.mdを見に行ったところ、CuckooはJavaの単体テストフレームワークである、Mockito ライクなテストコードが書ける事が判明しました。

もともとJavaエンジニアな私にとって使い慣れているMockitoとほぼ同じ書き方ができるのは、非常に嬉しい事だったので、早速試してみましたので、簡単な導入方法と、どんな事が出来るのか?をまとめたいと思います。

Cuckooって?

オープンソースのモック自動生成フレームワークです。
利用することにより、Mockの自動生成、Stubの用意が楽。テストコードが書きやすくなる。などの機能があります。

導入方法

早速簡単にCarthageの導入方法をまとめます。
前提として、一度はCarthageによるフレームワークの導入を行ったことのある方。 RunScriptを導入した事のある方向けにざっくりと記載しています。

環境情報

Xcode 9.4.1
SwiftKit/Cuckoo 0.11.3

導入

Cartfileに定義

通常のフレームワーク同様、 Cartfileに以下を記載します

github "SwiftKit/Cuckoo"

Mock化したいクラスがあるProjectのRunscriptにMockGenerateするためのスクリプトを定義する

OUTPUT_FILE="$PROJECT_DIR/${PROJECT_NAME}Tests/GeneratedMocks.swift"

INPUT_DIR="${PROJECT_DIR}/${PROJECT_NAME}"

"Carthage/Checkouts/Cuckoo/run" generate --testable "$PROJECT_NAME" \
--output "${OUTPUT_FILE}" \
"$INPUT_DIR/HogeHuga.swift" # ここにMock化したい自身のプロジェクトのプロジェクトファイルを定義する

echo "$SRCROOT"
  • Mock化対象プロジェクトのRunscriptに上記を記載します
  • これは、先述したように、Cuckooのアプローチは、MockをGenerateし、GenerateされたMockによりStubbingをするというライブラリなため、必要な作業となります。
  • Generateされるファイルは、1行目の OUTPUT_FILE の先に吐かれるようになるため、Generateされたファイルを1度 Add fileしてあげる必要があります。

テストプロジェクトのLink Binary With LibrariesにCuckooを追加

スクリーンショット 2018-07-15 19.20.05.png

  • 上記のように、Carthageで落として来た Cuckoo.framewor を指定し、追加してください。

準備完了

ここまで出来たら、1度ビルドします。
すると、先ほどのRun Scriptに記載した OUTPUT_FILE に指定した出力先をFinderやコマンドウィンドウなどで確認すれば、 GeneratedMocks.swift というファイルが出来上がっているかと思いますので、テストプロジェクトに対して Add File to projectname... します。

テストにおける使い方

ここからは、実際に生成されたMockを利用してStubbingのやり方を記載していきます。

検証用のクラスたち

検証用の適当に作った CuckooGenerator.swift をMockGenerateします。
依存関係は、 ViewModel から、 CuckooGeneratorを呼び出し、何かの処理をDelegateする. というようにしました。 
意味のわからない処理を書いていますが、検証用という事で見逃してください...泣

CuckooGenerator.swift
/// Mock化対象のクラス
import Foundation

internal enum CuckooType {
    case joy
    case angry
    case piyo
    case crow
    case normal
}

internal class CuckooGenerator {

    func generate(_ source: String, type: CuckooType) -> String {
        switch type {
        case .angry:
            return source + "!!!!!!!!!!!"
        case .joy:
            return source + "^_^"
        case .normal:
            return source
        default:
            return "ぴよぴよカアカア..."
        }
    }

}
ViewModel.swift
/// Mock化対象クラスを利用する呼び出し側のクラス
import Foundation

internal protocol ViewModelDelegate: class {

    func viewModel(_ vm: ViewModel, shoudShowCuckooLabel cuckoo: String)
    func viewModel(_ vm: ViewModel, shoudShowAngryLabel cuckoo: String)
    func viewModel(_ vm: ViewModel, shoudShowJoyLabel cuckoo: String)

}

internal class ViewModel {

    weak var delegate: ViewModelDelegate?

    let cuckooGenerator: CuckooGenerator

    init(cuckooGenerator: CuckooGenerator = CuckooGenerator()) {
        self.cuckooGenerator = cuckooGenerator
    }

    func tapCuckooButton(_ cuckoo: String) {
        delegate?.viewModel(self, shoudShowCuckooLabel: cuckooGenerator.generate(cuckoo, type: .normal))
    }

    func tapAngryButton(_ cuckoo: String) {
        delegate?.viewModel(self, shoudShowAngryLabel: cuckooGenerator.generate(cuckoo, type: .angry))
    }

    func tapJoyButton(_ cuckoo: String) {
        delegate?.viewModel(self, shoudShowJoyLabel: cuckooGenerator.generate(cuckoo, type: .joy))
    }

}

テストクラス

以下がCuckooによりGenerateしたMockを利用したテストクラスです。

ViewModelTest.swift
import XCTest
import Cuckoo // テストクラス側ではCuckooをインポートする必要がある.
@testable import cuckooSample

class CuckooSampleTests: XCTestCase {

    // MARK: - Tests
    private var angryExpectation: XCTestExpectation!

    // MockCuckooGeneratorが、CuckooによりGenerateされた CuckooGeneratorのMockクラス
    private let mock = MockCuckooGenerator() 

    lazy var viewModel = {
        // ViewModelの生成引数にCuckooによりGenerateされたMockクラスを注入することにより、Stubbingが可能となる
        ViewModel(cuckooGenerator: self.mock)
    }()

    override func setUp() {
        super.setUp()

        angryExpectation = self.expectation(description: "angryExpectation")
    }

    override func tearDown() {
        super.tearDown()
    }

    /// cuckooがGenerateしたstubで設定したthenReturnの文字列がdelegateにそのまま引き継がれる事を確認する
    func testNormalStubbing() {
        // mockの振る舞いを設定する(stubbing)
        stub(mock) { mock in
            // 引数が何であっても "buhyyyyy" を返却する
            when(mock.generate(any() , type: any())).thenReturn("buhyyyyy")
        }

        viewModel.delegate = self
        // viewModelのtapAngryButtonを呼ぶ. CuckooGeneratorがMock化されているため、先ほど設定したwhen()内の振る舞いが適用される
        viewModel.tapAngryButton("") 

        wait(for: [angryExpectation], timeout: 1)
    }
}

extension CuckooSampleTests: ViewModelDelegate {
    // MARK: Delegate
    func viewModel(_ vm: ViewModel, shoudShowCuckooLabel cuckoo: String) {
        XCTAssertEqual(cuckoo, "buhyyyyy")
    }

    func viewModel(_ vm: ViewModel, shoudShowAngryLabel cuckoo: String) {
        // when内で設定した振る舞いにより, cuckooはbuhyyyyyが返却されるため、このテストケースは通る
        XCTAssertEqual(cuckoo, "buhyyyyy")
        angryExpectation.fulfill()
    }

    func viewModel(_ vm: ViewModel, shoudShowJoyLabel cuckoo: String) {
        XCTAssertEqual(cuckoo, "buhyyyyy")
    }
}

コメントに色々記載しましたが、個々に見て行きます。

import

import Cuckoo // テストクラス側ではCuckooをインポートする必要がある.


- MockをGenerateするのは、先述した通りにRunScriptで行うため本流のプロジェクトに依存関係は貼らなくても良いのですが、TestクラスでStubbingするには依存関係を貼る必要があります。

Mockクラスの初期化

    // MockCuckooGeneratorが、CuckooによりGenerateされた CuckooGeneratorのMockクラス
    private let mock = MockCuckooGenerator() 
  • RunScriptにより生成された GenerateMocks.swift の中には、RunScriptで指定したInputFileのPrefixに Mock が付与されたクラスが生成されます。
  • なので、本流プロジェクトでBuildを走らせておく + GenerateMocks.swift をプロジェクトにAdd File しておけば、Mock~まで打った後にEscを押下すれば補完候補としてMockクラスが登場します。

Dependencyの差し替え(Mock対象依存クラスの差し替え)

    lazy var viewModel = {
        // ViewModelの生成引数にCuckooによりGenerateされたMockクラスを注入することにより、Stubbingが可能となる
        ViewModel(cuckooGenerator: self.mock)
    }()
  • 本流側のプロジェクトのInitでProtocolで宣言していなくても、Initializeで差し替える事が出来ます。
  • この例では、 private let で初期化宣言したMockクラスをViewModelのInitに注入してあげることにより、 CuckooGenerator を差し替えてあげています。
  • 上記により、`本来テストがしたいクラスの関心事にのみ注力したテストが書けるようになります。

Stubbing

        // mockの振る舞いを設定する(stubbing)
        stub(mock) { mock in
            // 引数が何であっても "buhyyyyy" を返却する
            when(mock.generate(any() , type: any())).thenReturn("buhyyyyy")
        }
  • これまで記載してきたことにより、Mockの準備は完了しているので、Mockの振る舞いを決めるための処理を書きます。
  • stubは、Cuckooライブラリの中のファンクションです。ここでは、Cuckooは省略していますが、stubwhenの前にCuckooと記述してももちろん動きます。 仮にPromiseKitを使っている場合はフレームワーク名を記載しなければ動かないと思います。
  • stub(mock)の構文により、どのmockをstubするかを決めた後に、when(mock.(${stubbingしたファンクション名とstubbingが発動する引数の型や条件}).thenReturn(${結果として何を返却するのか})を記述します。
  • 例の場合、 when(mock.generate(any(), type: any())).thenReturn("buhyyyyy") となっていますが、これはgenerateファンクションの第一、第二引数が何であった場合でも戻り値として buhyyyyy を返却する。という意味合いになります。
  • any()の部分は、値でも設定できるし型も設定できるしEqutableも設定できます。

結果的にどうなるか?

ここまでの設定で、 generate をどんな条件で叩いても、結果は buhyyyy を返却するというstubが出来上がっているので、以下のDelegateに記載しているXCTAssertEqualsのテストケースは成功となります。

    func viewModel(_ vm: ViewModel, shoudShowAngryLabel cuckoo: String) {
        // when内で設定した振る舞いにより, cuckooはbuhyyyyyが返却されるため、このテストケースは通る
        XCTAssertEqual(cuckoo, "buhyyyyy")
        angryExpectation.fulfill()
    }

上記までが、Cuckooの導入とMock生成の仕方、Stubbingの仕方の一通りの流れです。
いかがでしたしょうか? 正直、ほとんどREADME.mdに書いてある事を割と素直にそのまま書いた感じになってしまいましたが、Cuckoo自体の雰囲気とその強力さはわかっていただけたかと思います。
当該の記事に書いた事よりもう少し多くのパターンを試したサンプルコードを cuckoo-sample-projectにあげているので、興味のある方は覗いて見てください。
他に検証した結果、できる事がわかった場合、当該リポジトリにプッシュしていきたいと思います。

終わりに

何が嬉しい?

  • mock化/振る舞いをstubしたいクラスのProtocolを自分で書かなくてもよくなる
  • stubが柔軟に用意出来るようになり、Cuckoo自体がテストコードの書き方を統一してくれる
    • valueとしてこの値の時に... 型がこの型の時にこれを返却するという書き方が容易に書ける
    • N回目に呼ばれた時に何を返却するか?のような振る舞いが簡単に書ける
  • 処理が呼ばれていないこと/呼ばれたことの確認が簡単に行える。これは個人的にはとても嬉しく思いました。 今までは自作Mock側で呼ばれた回数をカウントして1回目の時の期待値、2回目の時の期待値...というようにif文を書いていましたので

出来なさそうなこと

  • spy的な振る舞い
    • 外部フレームワークの中のfuncの一部のみをstubbingしてテスト可能にする..ということは出来なさそうです
    • やるとしたら、外部フレームワークの処理をラップしたクラスを用意して、CuckooでラップクラスをMock化する。という方法になりそうです(試してないので、試したら加筆しますmm)
    • READMEを見たら、swiftのアップデートにより、この振る舞いが難しくなってしまった...というような事を書いてありました(雑な意訳

参考文献

Cuckoo - README.md
Swiftにおける現実的なMock
SwiftのモックライブラリCuckoo