テスト書いてますか?
みなさん、こんにちは。突然ですが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 を取得する。となります。
どこをテストするか・何をテストするか
さて、実装部を作ったところで今度はどこをテストするか明確にしていきます。今回は PresenterImpl の fetch メソッドをテストする前提でお話を進めます。テスト内容は下記の通りです。
- apiClient.fetch(endpoint:handler:)において、
handlerでResult.success(User)が渡ってきた場合に、self?.output?.reload(user:)メソッドが呼ばれているかどうか - apiClient.fetch(endpoint:handler:)において、
handlerでResult.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
}
}
-
APIClientMockはfetchEndpointHandlerClosureというプロパティを持ちます。これによりhandlerでResult.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 の一覧を取得して AutoMockable を base とした型・AutoMockable とコメントが付いている型 を全て取得してテンプレートからの描画処理が始まることになります。
つまり、上記のテンプレートを使って Mock を生成したいのであれば AutoMockable を目印としてつけてあげる必要があります。
protocol AutoMockable を用意して準拠していきましょう。
protocol AutoMockable {
}
そして、 Mock を生成したい protocol の base として 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)
}
}
APICient と PresenterOutput の Mock が吐き出されましたね。これを Test Target に import して使いましょう。
ここでもう一つ注意点があるのですが、 @testable import XXXX をする必要があります。こちらは適宜 stencil テンプレートの方にも記載して sourcery コマンドを実行した時に挿入されるようにしてください。
先ほど手で書いたAPIClientMockとPresenterOutputMock は削除してます。
テストが成功したら無事に Sourcery 移行が完了です。
終わりに
いかがでしたでしょうか。
テストがあるととても助かりますが、それを継続して書き続けるのは困難な場合もあると思います。
テスト書く際の手間を減らして、テストをガンガン書いていきましょう。
おしまい \(^o^)/