25
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

iOS (その2)Advent Calendar 2018

Day 16

Sourceryを活用してテストを書くハードルを下げる

Last updated at Posted at 2018-12-15

テスト書いてますか?

みなさん、こんにちは。突然ですがiOSアプリ開発ででテストを書いていますか? きっとこの記事を読んでいるのはプロダクトにテストがまだ導入できていなくてこれからテストを始めたい人。テストを書いているが書くことに苦労している人。そういった人たちが多いのではと考えています。今回はそんなテストを書くハードルを Sourcery を活用して下げて行こうと思います。

Sourceryで下げられるハードル

テストを書くハードルといっても様々です。 処理を1つのクラスにまとめて責務分割ができていなかったりグローバル変数やシングルトンの多用等の置き換えの効きづらいものが残っているせいでテストが書けない。といった悩みもあるでしょう。この記事ではどちらかというとこういった悩みを解決した後の話になります。今回はテストを書く上で必要なものを用意する手間を省きテストを書くハードルを下げて行きます。

Sourceryについて

ここで改めて Sourcery について軽く紹介します。
Sourceryとはボイラープレートを無くすためのSwift製のライブラリになっています。具体的にこのライブラリがやることは以下のような流れになります。

  • Swiftのコードを読み込む。
    • プロダクションのコード等をSourceryに読み込ませます。
  • 要素ごとに分解して変数に保持している。
    • 要素とはメソッドやクラスといった単位になります。
  • 保持している変数と指定したstencilテンプレートからSwiftのコードを自動で生成する

とりあえず Swift のコードを元に Swift のコードが組み立てられるツールなんだな。ということがわかってもらえたら良いと思います。
ここまで聞くとなんだかだんだんとどういう風に楽ができるのか。想像できてきたのではないでしょうか。
では前置きが長くなってしまったのですが、次のセクションから Sourcery の活用法について紹介していきます。

具体的な Sourcery の活用方法を紹介する前に例としてユースケースを想定してテストを一つ書いていきたいと思います。
まずWebAPIからJSONを取得してそれを元に View の描画を行う画面があると想定します。この画面を構成する機能を今回のテスト対象とします。
そしてこの機能は3つのクラスで責務分割されていて実現されていると考えます。

  • ViewController
    • 言わずとしれた UIViewController を継承したクラスです
  • Presenter
    • ViewController で発生・ハンドリングしたイベントごとに処理を行う
  • APIClient
    • WebAPIを叩くためのクライアント

そして、API のレスポンスは User という構造体の形を想定します。

struct User: Codable {
    let id: Int
    let name: String
}

今回は API のレスポンスにエラーがあると想定して、 Result 型でAPIのレスポンスを表現したいと思います。
まだ Swift では標準で Result が入っていないので、 Swiftで採択されたResult型よりも簡易なものを定義します。  

enum Result<T> {
    case success(T)
    case failure(Swift.Error)
}

上から順に責務分割されている3つのクラスのコードも載せます。

protocol APIClient {
    func fetch(endpoint: String, handler: @escaping (Result<User>) -> Void)
}

struct APIClientImpl: APIClient {
    func fetch(endpoint: String, handler: @escaping (Result<User>) -> Void) {
        // Fetch data from api server
    }
}
protocol PresenterOutput: class {
    func reload(user: User)
    func presentAlert(error: Error)
}
protocol Presenter {
    func fetch()
}
class PresenterImpl: Presenter {
    weak var output: PresenterOutput?
    
    let apiClient: APIClient
    init(apiClient: APIClient) {
        self.apiClient = apiClient
    }
    func fetch() {
        apiClient.fetch(endpoint: "users/1") { [weak self] (result) in
            switch result {
            case .success(let user):
                self?.output?.reload(user: user)
            case .failure(let error):
                self?.output?.presentAlert(error: error)
            }
        }
    }
}
class ViewController: UIViewController {
    let presenter: Presenter
    init(presenter: Presenter) {
        self.presenter = presenter
        
        super.init(nibName: nil, bundle: nil)
    }
    required init?(coder aDecoder: NSCoder) { fatalError() }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        presenter.fetch()
    }
}
extension ViewController: PresenterOutput {
    func reload(user: User) {
        // Reload views using user
    }
    
    func presentAlert(error: Error) {
        // Catch error and presenting error with alert view
    }
}

処理の流れとしては viewDidLoad のタイミングで Presenter 経由で APIClient を叩き User を取得する。となります。

どこをテストするか・何をテストするか

さて、実装部を作ったところで今度はどこをテストするか明確にしていきます。今回は PresenterImplfetch メソッドをテストする前提でお話を進めます。テスト内容は下記の通りです。

  • apiClient.fetch(endpoint:handler:)において、handlerResult.success(User) が渡ってきた場合に、self?.output?.reload(user:) メソッドが呼ばれているかどうか
  • apiClient.fetch(endpoint:handler:)において、handlerResult.failure(Error) が渡ってきた場合に、self?.output?.presentAlert(error:) メソッドが呼ばれているかどうか

前のセクションでお見せしたのクラスたちは全て protocol でインタフェースが定義されています。
上記の挙動のテストのためにこのインタフェースに沿った 実装を偽装したクラス を作ると便利です。
protocol名+Mockという命名でクラスを作っていきます。

class APIClientMock: APIClient {
    var fetchEndpointHandlerClosure: ((String, @escaping (Result<User>) -> Void) -> Void)?
    func fetch(endpoint: String, handler: @escaping (Result<User>) -> Void) {
        fetchEndpointHandlerClosure?(endpoint, handler)
    }
}

class PresenterOutputMock: PresenterOutput {
    var reloadUserCalled: Bool = false
    var presentAlertErrorCalled: Bool = false
    
    func reload(user: User) {
        reloadUserCalled = true
    }
    
    func presentAlert(error: Error) {
        presentAlertErrorCalled = true
    }
}
  • APIClientMockfetchEndpointHandlerClosure というプロパティを持ちます。これにより handlerResult.success(User) もしくは Result.failure(Error) を自由に設定ができます。
  • PresenterOutputMock は どのメソッドが通ったかをプロパティで表現しています。このプロパティを見れば APIClient からの戻り値によってどのメソッドが通ったかわかりますね。

この 実装を偽装したクラス を利用したPresenterImpl.fetch のテストを下記に記します。

func testFetch() {
    XCTContext.runActivity(named: "Presenter called reload(user:) when it fetched user from api") { (_) in
        let apiClient = APIClientMock()
        apiClient.fetchEndpointHandlerClosure = { endpoint, handler in
            handler(.success(User(id: 1, name: "bannzai")))
        }
        
        let output = PresenterOutputMock()
        let presenter = PresenterImpl(apiClient: apiClient)
        presenter.output = output
        
        presenter.fetch()
        
        XCTAssertTrue(output.reloadUserCalled)
    }
    XCTContext.runActivity(named: "Presenter called presentAlert(error:) when it got error from api") { (_) in
        struct AnyError: Error {
            
        }
        
        let apiClient = APIClientMock()
        apiClient.fetchEndpointHandlerClosure = { endpoint, handler in
            handler(.failure(AnyError()))
        }
        
        let output = PresenterOutputMock()
        let presenter = PresenterImpl(apiClient: apiClient)
        presenter.output = output
        
        presenter.fetch()
        
        XCTAssertTrue(output.presentAlertErrorCalled)
    }
}

このテストは見事成功します。そして、とても簡単ですね。テストを用意することで僕たちは安心感を得られます。しかし、簡単に書くことができたと言っても、多少テストを書くことに面倒さも覚えますね。例えば 実装を偽装したクラス として用意した 処理を実行したかどうかを調べるクラス 処理の実装を偽装するためのクラスを用意することは毎回同じようなコードを書くことになります。仮に実装が変わればこれに追随して Mock の調整も必要になることもあります。次第にテストを書くことが億劫になってくるかもしれません。この明らかなボイラープレートコードの書く手間を Sourcery を使って無くしていきましょう。

Mockを自動で用意する

テストに関心がある方は Mock という単語を耳にしたことがあると思います。
ここでいう Mock の意味としてはテストしたい対象の内部的挙動を把握するためのオブジェクト、くらいの認識で書いてあります。(正確な理解ではない可能性あり) このセクションで説明することは GitHub にも紹介されているものがあります。ですので、言葉を合わせるといった意味でも Mock という単語を今後使っていきます。 Mock の意味については別途調べていただけたらと思います。
では Mock の準備を Sourcery を使用して自動で生成したいと思います。

Sourceryを使う

インストール方法はいくつかあるのでお好みで選んでください。インストールが終わればあとはテンプレートを用意して Sourcery に用意されているスクリプトを実行するだけになります。今回 Mock を作るためのテンプレートはGitHubにテンプレートとして用意されているものを使います。

テンプレートについての細かい説明は ドキュメント をご覧になってください。
この中でも下記の行に注目します。

{% for type in types.protocols where type.based.AutoMockable or type|annotated:"AutoMockable" %}

この行は Mock を生成する処理の入り口になっています。 Sourcery ではコマンドの引数として Swift のコードを渡してあげ、それをパースしてテンプレートに沿った内容を出力してくれます。上記の stencil テンプレートの内容で押さえておきたいポイントは以下の3つです。

  • types.protocols: 引数として渡したSwiftコードの中で protocol として存在するものを配列として持っています。ちなみに typesは配列じゃないです
  • type.based.AutoMockable: こちらは AutoMockable をベースとした type(型) 情報を取得してくれます。
  • type|annotated:"AutoMockable": こちらは AutoMockable というコメントで Annotation が付いているものを取得してきてくれます。

合わせて上記の for 文を読むと protocol の一覧を取得して AutoMockablebase とした型・AutoMockable とコメントが付いている型 を全て取得してテンプレートからの描画処理が始まることになります。

つまり、上記のテンプレートを使って Mock を生成したいのであれば AutoMockable を目印としてつけてあげる必要があります。
protocol AutoMockable を用意して準拠していきましょう。

protocol AutoMockable {
    
}

そして、 Mock を生成したい protocolbase として AutoMockable を設定してあげます。

protocol APIClient: AutoMockable {
    func fetch(endpoint: String, handler: @escaping (Result<User>) -> Void)
}

protocol PresenterOutput: class, AutoMockable {
    func reload(user: User)
    func presentAlert(error: Error)
}

そして、テンプレートを任意の位置に追加してあげます。
内容はここからコピペしましょう。

$ mkdir templates
$ touch templates/AutoMockable.stencil

最後に sourcery を実行します。

$ sourcery --sources ./SourceryDemo/ViewController.swift --templates ./templates/AutoMockable.stencil --output ./SourceryDemo/Generated/Mock.generated.swift 

これで ./SourceryDemo/Generated/Mock.generated.swift というファイルができて、その中に Mock が吐き出されていると思います。

と思ったが

この記事を書いている時点のAutoMockableを使用するとエラーが出てしまいました。
原因はわかったので PR をあげました。あげたPRの内容のAutoMockableのテンプレートを使っていきます。
PR がマージされるかはわかりませんが、上記の PR で想定している動きになります。

というわけで続きなのですが、 sourcery の実行後のファイルの中身は以下のようになっていると思います。

class APIClientMock: APIClient {

    //MARK: - fetch

    var fetchEndpointHandlerCallsCount = 0
    var fetchEndpointHandlerCalled: Bool {
        return fetchEndpointHandlerCallsCount > 0
    }
    var fetchEndpointHandlerReceivedArguments: (endpoint: String, handler: (Result<User>) -> Void)?
    var fetchEndpointHandlerClosure: ((String, @escaping (Result<User>) -> Void) -> Void)?

    func fetch(endpoint: String, handler: @escaping (Result<User>) -> Void) {
        fetchEndpointHandlerCallsCount += 1
        fetchEndpointHandlerReceivedArguments = (endpoint: endpoint, handler: handler)
        fetchEndpointHandlerClosure?(endpoint, handler)
    }

}
class PresenterOutputMock: PresenterOutput {

    //MARK: - reload

    var reloadUserCallsCount = 0
    var reloadUserCalled: Bool {
        return reloadUserCallsCount > 0
    }
    var reloadUserReceivedUser: User?
    var reloadUserClosure: ((User) -> Void)?

    func reload(user: User) {
        reloadUserCallsCount += 1
        reloadUserReceivedUser = user
        reloadUserClosure?(user)
    }

    //MARK: - presentAlert

    var presentAlertErrorCallsCount = 0
    var presentAlertErrorCalled: Bool {
        return presentAlertErrorCallsCount > 0
    }
    var presentAlertErrorReceivedError: Error?
    var presentAlertErrorClosure: ((Error) -> Void)?

    func presentAlert(error: Error) {
        presentAlertErrorCallsCount += 1
        presentAlertErrorReceivedError = error
        presentAlertErrorClosure?(error)
    }

}

APICientPresenterOutputMock が吐き出されましたね。これを Test Targetimport して使いましょう。
ここでもう一つ注意点があるのですが、 @testable import XXXX をする必要があります。こちらは適宜 stencil テンプレートの方にも記載して sourcery コマンドを実行した時に挿入されるようにしてください。
先ほど手で書いたAPIClientMockPresenterOutputMock は削除してます。
テストが成功したら無事に Sourcery 移行が完了です。

終わりに

いかがでしたでしょうか。
テストがあるととても助かりますが、それを継続して書き続けるのは困難な場合もあると思います。
テスト書く際の手間を減らして、テストをガンガン書いていきましょう。

おしまい \(^o^)/

25
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?