Quickを使ってビューコントローラをテストする

ビューコントローラをテストすることは、実はそんなに難しいことではありません。これからRachel Bobbinsが開発チームでQuickを使ってさまざまなパターンのテストをどのように効率的に実践しているかをお見せします。ここでのテストとは、例えば、ボタンのタップをトリガーとしたネットワークリクエストで成功時や失敗時のレスポンスをハンドリングすることや、画面遷移などで適切なビューコントローラが表示されてるかを確認することなどです。


イントロダクション (0:00)

はじめまして、Rachel Bobbinsです。わたしは、Stitch Fix社でソフトウェアエンジニアとして働いています。ここで働く前はPivotal Labsでソフトウェアエンジニアをしていました。主な作業はiOSアプリの開発です。

最近、私は採用担当者としてある候補者の方とお話をしたんですが、その方がとても頑固な考え方の持ち主で、私に対して「周知のことと思いますが、ビューコントローラをテストするなんてそんなばかげた話ってありますか?」と言ったんです。とても嘆かわしい気持ちになったのですが、同時に、このことについてきちんと話をしなければならないなという気持ちにさせてくれたんです。確かに、多くの人はビューコントローラをテストすることが難しいと感じ、実際にテストをしないケースがほとんどだと思われます。しかし、今日私がお話する中でビューコントローラをテストすることが決して難しくないということを示せればと思っていますし、実際とてもシンプルにそれを実現することができます。おそらく皆さんもそのように感じていただけるのではないかと考えています。

良いテストを書くことは良いUXにつながる (0:35)

なぜ、ビューコントローラテストすることは大切なのでしょうか?それは、端的にいってUX(ユーザー体験)の制御にもっとも隣接した領域だからです。もし、あなたがユーザー体験を大切にするなら、可能な限りそれをコントロールすることを意識しなくてはいけません。実際のところ、それはとても簡単で実装のコストも高くないです。また、アプリ開発全体の作業を短縮することにもつながります。

ビューコントローラに対するテストをすることに対してよく言われる反論として、「KIFなどのライブラリをつかってUIテストができるんだから、そもそも必要性はないのではないか?」があります。もちろん、KIFなどをつかったテストはアプリ開発でとても有益ですが、テストの実行に長い時間がかかるというデメリットがあります。テストの実行に時間がかかるということは、通常の開発プロセスの中に取り込んでいくことは難しく、結局はテストをしなくなるということにつながります。なので結局KIFなどをつかう事自体が無駄なことになってしまいます。

ビューコントローラのテストはとてもすばらしいです。なぜなら、KIFなどでのテストでは検討できなかった詳細な点をテストすることができるうえ、アプリの開発者が想定していなかった観点で、例えばエラーケースやネットワーク接続状況が悪いケースでテストしてくれるからです。

デモアプリ (1:59)

今回、今後のコードサンプル用に構築したデモのアプリを用意しました。そして、このコードはGitHubからご覧になることができます。実際にソースを確認してみてください。ビデオでも見ることができます。ほんとうに簡単なんです。

もしTwitterアカウントの@earthquakesSFをご存知なら、これがベイエリアでの地震についての自動ツイートをするものだとご理解いただけると思います。私はこのアカウントに先週気が付きましたが、サン・ロマンエリアでこんなに地震が多いものだとびっくりしました。今回、私はこのアカウントと同様に地震情報を伝えてくれるアプリを実装しました。このアプリはシンプルで地震測定のためのボタンをタップすると2015年の10月1日以降のサン・ロマンエリアでの地震情報を伝えてくれるものになっています。これは、ネットワークリクエストを生成し、そのレスポンスの結果をアラートで表示するというシンプルな仕様になっています。

記事の更新情報を受け取る

Quickフレームワーク (3:10)

このサンプルコードでは、すべてQuickを使ったテストを実装しています。もちろん、多くの人はすでに馴染みのあるフレームワークがあるでしょうが、今回私がこれからお話する原理については、XCTestや、Cedar、または、他のテストのためのライブラリを使ったとしても応用できる考え方です。

実のところ、私はQuickがとても好きです。というのも、Quickは行動スタイルに沿った書き方を推奨してくれているからです。どういう意味かというと、Quickで書くテストの流れは、まるでユーザーがアプリを使った流れと同じようになっているということです。

原則1: インターフェースのテスト (3:59)

これから、何点か原則について実例を交えながらお話をさせていただきたいと思います。

原則の1つ目は、インターフェースをテストをするということです。ほとんどのユニットテストの場合、実際に行っている内容は、例えば、他のオブジェクトから呼ばれているオブジェクトのパブリックなメソッドをテストし、戻り値または副作用をテストするというものだと思います。しかし、ビューコントローラの場合で考えると、そもそもあるビューコントローラがそれが呼ぶ別のオブジェクトを保持すべきではありません。ここでいうところのインターフェースのテストとは、どちらかというとコードのインターフェースではなく、ユーザー のインターフェースを意味しています。実際、あなたもビューコントローラの動きに関して、ユーザーがアプリを操作する手順と近い形でテストを書きたいと望んでいるはずです。

以下のコードに関してどのように機能しているかを説明している動画を用意しました。動画では、今回用意したデモアプリの初期のバージョンをもとに解説を行っています。見ていだければわかりますが、画面はとてもシンプルな構成です。ビューコントローラ上にボタンが設置されており、ボタンを押すとスタートとストップが切り替わり、スタートしている間はスピナーが動いています。

import Quick
import Nimble
@testable import EarthquakeCounter

class WelcomeViewControllerSpec: QuickSpec {
    override func spec() {
        var subject: WelcomeViewController!

        beforeEach {
            subject = WelcomeViewController()

            //Trigger the view to load and assert that it's not nil
            expect(subject.view).notTo(beNil())
            expect(subject.welcomeLabel).notTo(beNil())
        }

        it("says welcome") {
            expect(subject.welcomeLabel.text).to(equal("Welcome!"))
        }

        it("has a spinner that is not moving") {
            expect(subject.spinner.isAnimating()).to(beFalse())
        }

        describe("toggleSpinner:") {
            beforeEach {
                Subject.toggleSpinner(subject.spinnerButton)
            }

            it("animates the spinner") {
                expect(subject.spinner.isAnimating()).to(beTrue())
            }

            describe("toggleSpinner: again") {
                beforeEach {
                    subject.toggleSpinner(subject.spinnerButton)
                }

                it("stops the spinner") {
                    expect(subject.spinner.isAnimating()).to(beFalse())
                }
            }
        }
    }
}

まず、従来通りのユニットについて見ていきましょう。例えばtoggleSpinner()というメソッドでのテストでは、そのメソッドが呼ばれるとスピナーが回転のアニメーションを開始し、再度そのメソッドを呼ぶとスピナーが回転を停止するということをテストします。もし、Quickを今まで使ったことがないのであれば、Quickの仕様が直感的にわかりづらいと感じるかもしれません。しかし、itはテストの実行であり、 describebeforeEachを包含しており、スコープ内でのテストの共有の設定を意味します。

これらは、テストをするために有益な構造であり、今、実際にデモのアプリでテストを正常に完了していることを確認することができます。つまり、先ほどの例でいうと、スタートボタンをタップするとスピナーが回転し、再度タップするとスピナーが止まるという挙動の確認がとれたということです。

しかし、残念ながらテストを機能不全にしてアプリを壊すことは簡単にできてしまいます。例えば、テストは一見正常に完了しているようにみえるものの、実際はアプリが壊れているという状況を作れるということです。これは、テストが全く機能しておらず我々が望むものにはなっていないということ意味しています。

もし、私がXcode上でtoggleSpinnerメソッドのIBActionでの接続を削除し、新しくdidTapSpinnerButton()メソッド(中身にはなにも実装していない)をOutletで接続した場合、新しいメソッドには何も実装されていなので、ボタンをタップした時の機能は失われた状況になっています。にもかかわらず、この状況下でテストを実行すると、テストは正常完了してしまいます。

テストが正常完了しているにも関わらず、アプリが壊れてしまっています。このような状況を改善するには一体どのようにすればいいのでしょうか?

        describe("toggleSpinner:") {
            beforeEach {
                Subject.toggleSpinner(subject.spinnerButton)
            }

to…

        describe("tapping the button") {
        before each {
            subject.spinnerButton.sendActionsForControlEvents(.TouchUpInside)
        }

まず第1に、 機能ではなく、振る舞いに従った名前をテスト名とする ということが挙げられます。例えば、メソッド名をテスト名にしないとい���ことです。もし、あなたがメソッド名をテスト名(ここではtoggleSpinner)としてテストを実行することは、脆弱性のあるテストになることが想定されます。ですので、今回のケースだと、振る舞いとしては、”tapping the button”というのが正しく、まさにユーザー視点から期待されるものをテスト名としなくてはいけません。

第2に、toggleSpinnerメソッドを呼ぶ代わりに “タップ”についてのヘルパーを書きます。今回ですと、sendActionForControlEvents(.TouchUpInside)を実装するということです。この書き方ですと、実際にそのボタンをタップした時の挙動を正確に制御することができます。ぜひ、このやり方を真似してほしいですし、ヘルパーを書くことで先ほどのようなミスを防ぐことができます。このようにすることで、テストの可読性が上がりますし、describeで書いたテスト名と内容が一致します。これはとても素敵なことです。

要約すると、ビューコントローラのインターフェースのテストをユーザーがアプリを操作する手順で実施しましょうということです。

原則2: すべての依存関係の注入(8:29)

ビューコントローラのテストが困難なことについて、よく言われることとして、”私は、ビューコントローラの実装をスリムに設計しているよ”とか、”そもそも制御があまり入っていないビューコントローラでテストすることは無意味だよ”という意見が挙げられます。ですが、それは真実とは言い切れません。本当に大切なのは、すべてのビューコントローラで実装されているロジックが正しく機能して、メッセージがオブジェクトに伝達されることです。もし、なにかのミスでメッセージの伝達がうまくいかなくなると、アプリは壊れてしまいます。ここで私が”すべての依存性の注入”と言っていることの意味は、オブジェクトへの伝達が機能しているかをテストするために、ロジックが実装されたものすべてを注入するということです。

self.presentViewController(alert, animated: true, completion: nil)

具体例を見ていきましょう。ここで、presentViewControllerと書かれている行があります。これは、テストが通ってほしいですよね?

it("presents an alert") {
    expect(subject.presentedViewController).to(beAnInstanceOf(UIAlertController))
}

あら。残念なことにこれはテストがとおりません。エラーメッセージを読むと、alert controllerがwindowの階層の中に入っていないために表示することができず、依存性が隠蔽されていることがわかります。

このケースでは、あなたはUIKit上の依存性についてテストが通らない状況になるまで気が付かなかったという点で、暗黙的な依存関係となっている一例ということができます。この場合ですと、DialogPresenterプロトコルを採用することで依存性を注入することができます。

public protocol DialogPresenter {
    func present(title: String, message: String?, onTryAgain: (Void -> Void), onTopOf presenter: UIViewController)
}

class RealDialogPresenter: DialogPresenter {
    func present(title: String, message: String?, onTryAgain: (Void -> Void), onTopOf presentingViewController: UIViewController) {
        let alert = UIAlertController(title: title, message: message, preferredStyle: .Alert)

        let cancelAction = UIAlertAction(title: "Cancel", style: .Cancel, handler: nil)
        alert.addAction(cancelAction)

        let tryAgainAction = UIAlertAction(title: "Try Again", style: .Default) { _ in onTryAgain() }
        alert.addAction(tryAgainAction)

        presentingViewController.presentViewController(alert, animated: true, completion: nil)
    }
}

このプロトコルは、presentメソッドを宣言しています。アプリでは、このプロトコルを使ってアラートを実装します。これの素晴らしい点としては、依存性がプロトコルに準拠しているということです。つまり、テストコードを書く時には、このプロトコルを使ってアラートを実装することができるのです。

プロトコルを使いましょう 。もし、ビューコントローラがアラートの実装を必要としてる場合は、プロトコルに準拠する必要があります。そうすることで、モックを簡単に書けるようになり、モックを使ってテストをすることができます。

class FakeDialogPresenter: DialogPresenter  {
    var present_wasCalled = false
    var present_wasCalled_withArgs: (title: String, message: String?, onTryAgain: (Void -> Void), presentingVC: UIViewController)? = nil

    func present(title: String, message: String?, onTryAgain: (Void -> Void), onTopOf presenter: UIViewController) {
        present_wasCalled = true
        present_wasCalled_withArgs = (title: title, message: message, onTryAgain: onTryAgain, presentingVC: presenter)
    }
}

ここに書いてあるのが今お話したようにモックでの実装です。クラス名は”FakeDialogPresenter”としています。

モックに投資する この実装に関しては少し時間がかかる上にメンテナンスコストも必要となってきます。しかし、それらのコストをかけるだけの価値はあります。モックを実装する際に、私は常に以下の2点の実装をすることにしてます。ひとつは、モックでどのメソッドが呼ばれているかをトラッキングするためのフラグとしてのBOOL型のプロパティです。これによって、実際のビューコントローラ上でモックが何をしようとしているかを把握することができるようになります。

もうひとつは、オブジェクトに渡されたすべての引数が入ったタプルを用意することです。こうすると特にObjective-Cのコードをテストする際にとても便利です。Objective-Cでは、Swiftのように静的な型安全が保証されていません。Swiftでは間違えるとコンパイル時に警告が発生するので必要は無いという反論もあると思います。ですが、今回のようにテストしたいオブジェクトがある場合は複数のプロパティをまとめることができるので便利だと思います。

モックを使って、テストを書く方法を以下に示しています。まるで、文章を書くようにテストコードをかけることを見ていただけると思います。

it("presents an alert") {
    expect(dialogPresenter.present_wasCalled).to(beTrue())
}

describe("the alert") {
    it("includes the correct text") {
        let expectedTitle = "Earthquakes near San Ramon"
        let actualTitle = dialogPresenter.present_wasCalled_withArgs?.actualTitle
        expect(actualTitle).to(equal(expectedTitle))

        let expectedMessage = "There have been 0 earthquakes near San Ramon since 10/1/2015"
        let actualMessage = dialogPresenter.present_wasCalled_withArgs?.actualMessage
        expect(actualMessage).to(equal)(expectedMessage))
    }
}

この箇所は以前のコードですとテストが通らなかったとおもいます。しかし、今回モックをつかうことでテストができるようになり、しかも普通の文章のようにコードを書くことができるようになりました。ここですと、dialogPresenterでダイアログが表示されて、期待したとおりの引数が返ってきているかを確認していることがわかります。

以上が、ビューコントローラをいかにテストしていくかということについての私の提案になります。

参考までにPivotalCoreKitは、私がPivotal社で働いていたときによく使っていて、テストをするときのヘルパーがたくさん用意されています。是非さわってみてください。

Q&A (13:39)

Q: このフレームワークの弱みは何ですか?

Rachel: 主な欠点は、Xcodeとの相性があまり良くないという点です。例えば、Xcode上でテストしたいリストが表示されたサイドバーがうまく機能しないことがたまにあります。ただ、それほど私は気にしていません。なぜなら、こういうことがきっかけでテストでより機能的な書き方を学ぶことができたからです。全体として見た場合、Xcodeのテストに役立つUIより実用的なテストコードの書き方の方が重要だと思います。

**Q: Quickはシミュレーター以外でのテストでも動作しますか?実機につなげてテストすることはできますか?確か1年ほど前Quickはシミュレーターしかサポートしていなかったと思うので **

Rachel: はい。実機でテストしてみましたが問題なかったです。

Q: 大変興味深いですが、1点質問です。あなたは今までアプリ開発でMVVMでの実装をしたことがありますか?MVVMでは多くのケースでビューコントローラからロジックが取り除かれています。そして、その結果、このようなテストはUIコンポーネントのバインディングロジックで役に立つものになってしまうと思うのですが。。この点はいかがでしょうか?

Rachel: MVVMで実装した経験はありませんが、ここで話をした私の見地は、役に立つと思います。

Q: テストをすることを重要視していない組織に所属している場合、なにかアドバイスいただけないでしょうか?このようなTDDをするような状況に変えていくにはどうすればいいでしょうか?

Rachel: 実際の問題として、あなたに賛同してくれるエンジニアがチームにいないなら実現は難しいでしょうね。私の場合ですと、今までの実績でうまくいったこともありますが、たとえば、あなたが普段からテストを書き、最終的に他の人がそれをみてくれるようになればいいですが。いずれにせよ、大変な話です。

Q: ビューコントローラのテストをする際に、loadViewviewDidLoadを何度も呼んで初期化の設定をしないといけないという認識を持っています。そのような煩雑さを感じられたことはありますか?もしくは、そうならないための工夫があれば教えていただければとおもいます 

Rachel: とてもいい指摘をいただきました。多くの場合、初期化の処理はviewDidLoadでなされることが多いです。ですので、このサイクルをテストの起点としなくてはいけません。私の個人的な好みとしてですが、テストの開始時に expect(subject.view).notTo(beNil())を書くということをしています。これはローディングを起点としていますが、テストの見通しを立てやすくしてくれてます。例えば、このタイミングでテストが通っていないのであれば、そもそも根本的に間違っていることになります。テストの初期化処理とテストのそもそものエラー判定は同じタイミングを起点とするのが良いと思います。

Rachel Bobbins

Rachel is a lead engineer at Stitch Fix, where she works primarily on their recently-released iOS app. Previously she worked on a variety of iOS and Rails project at Pivotal Labs. She’s passionate about writing well-tested code and keeping her view controllers under 200 lines.