iOSで振る舞いを見るテストを書いて安心しながら開発を続けよう

  • このエントリーをはてなブックマークに追加
Yahoo! JAPAN Tech Advent Calendar 2018の17日目の記事です。一覧はこちら

こんにちは。ヤフー株式会社でiOS Yahoo! JAPANアプリの開発をしている内村です。この記事では僕が普段のiOSの開発で意識・実践している振る舞いを見たテストによる開発(BDD/振る舞い駆動開発)を紹介します。

振る舞いを見るテストとは

振る舞い駆動開発では、TDDと同様にまずテストから書いていきます。その際のテストの観点が自然言語を併記しながらユーザから見てプログラムがどう動くべきか振る舞いを意識しているのが特徴です。

例えば「この関数はこの引数に対してこのreturnを返すべき」ではなく「このボタンが押されたら最終的にラベルにAAAが表示される」という観点でテストを作成します。

そのようなテストはUIテストでしか出来ないと思われるかもしれませんが、iOSのユニットテストでも実現することは可能です。

次に、書いたテストがパスするように実装を書いていき、必要に応じてリファクタリングをしていきます。

まとめると以下のようになります。

  1. 振る舞いを意識したテストを書く
  2. 書いたテストがパスするように実装を書く
  3. リファクタリングする

この1、2、3を繰り返しながら開発をしていきます。

作成するサンプルアプリについて

今回作成するサンプルアプリのgifです。

下にUIButtonが2つ、真ん中にUILabelが1つあります。UIButtonのタップに合わせてUILabelが変化し、数をカウントできる簡単なアプリになります。

実際のコードは以下を参照してみてください。

ir77/BDDSampleApp https://github.com/ir77/BDDSampleApp

動作確認環境

  • macOS Mojave(10.14.1)
  • Xcode(10.1)
  • Simulator iPhone XR iOS(12.1)

実際に振る舞いを意識しながら開発してみよう

それぞれ1つずつテスト追加、実装を行っていきましょう。プロジェクトの新規作成画面でSingle View Appを選択したらInclude Unit Testsにチェックを入れてプロジェクトを作成しましょう。

プロジェクトを作成したら ⌘ + U を押してシミュレータの起動、テストの動作を確認してください。ここでエラーが出るようであれば何か環境に問題があるはずです。

問題が無ければ次のステップに進みましょう。ユーザからの視点で見て、まず書かなければいけない振る舞いのテストは3つありそうです。

  • 0(UILabel)が表示されていること
  • +ボタン(UIButton)が表示されていること
  • -ボタン(UIButton)が表示されていること

(他にも「ボタンの文字の色は青になっているか」、「フォントサイズは何か」、「+と-ボタンは並んでいるか」などのユーザから見たときに観点はあるかもしれません。アプリの仕様と不安に応じてテストを追加していけばいいと考えています。例えば状態に応じてボタンの位置が変わったり、ラベルの色が変わる仕様があればそういったテストも追加すると思います)

まずは「0(UILabel)が表示されていること」ことを確認するテストから書きましょう。

SampleAppTests.swift

import XCTest
@testable import SampleApp

class SampleAppTests: XCTestCase {
    func test_画面が表示されたら_ラベルに0が表示される() {
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        let viewController = storyboard.instantiateViewController(withIdentifier: "ViewController") as? ViewController

        let label = viewController?.view.subviews
            .filter({ $0.accessibilityIdentifier == "label" })
            .compactMap({ $0 as? UILabel })
            .first

        XCTAssertEqual(label?.text, "0")
    }
}

最初にStoryboardからViewControllerを取得します。次に取得したViewControllerからaccessibilityIdentifier経由でUILabelを取得します。ViewControllerからテストを書きたいときはこの様な方法をよく使います。UILabelの場合、textの文字列でfilterする方法もあります。

最後にLabel0であることを評価しています。

このテストをv⌘ + Uで実行するとエラーが出てしまいます。解決していきましょう。

 2018-11-29 22.55.21.png

先程書いたコードでIdentifierを書いていないのが原因ですね。storyboard.instantiateViewController(withIdentifier: "ViewController") ← ここ

Main.storyboardを選択しViewControllerを選び、Show the Identity InspectorStoryboard IDを写真のように設定しましょう。

Main.storyboard

もう一度⌘ + Uを押すとテストが最後まで実行されます。そして当たり前ですがUILabelが無いのでAssert部分が赤くなります。 2018-11-29 22.59.22.png次はここを直しましょう。

再び、Main.storyboardを選択しViewControllerを選び、UILabelを配置してください。

UILabelをダブルクリックして、テキストを「0」に変更したらShow the Identity InspectorAccessibility Identifierにテストコード上でも書いたlabelと書きます。

Main.storyboard

この状態でテストを実行すると、おめでとうございます。テストがパスするようになりました🎉🎉🎉

UILableと同様に「+ボタン(UIButton)が表示されていること」「-ボタン(UIButton)が表示されていること」のテストの追加、Storyboardの修正をしてみてください。

追加されるテストは以下のようになるかと思います。

SampleAppTests.swift

    func test_画面が表示されたら_マイナスボタンが表示される() {
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        let viewController = storyboard.instantiateViewController(withIdentifier: "ViewController") as? ViewController

        let button = viewController?.view
            .subviews
            .filter({ $0.accessibilityIdentifier == "minusButton" })
            .compactMap({ $0 as? UIButton })
            .first

        XCTAssertEqual(button?.titleLabel?.text, "?")
    }

    func test_画面が表示されたら_プラスボタンが表示される() {
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        let viewController = storyboard.instantiateViewController(withIdentifier: "ViewController") as? ViewController

        let button = viewController?.view
            .subviews
            .filter({ $0.accessibilityIdentifier == "plusButton" })
            .compactMap({ $0 as? UIButton })
            .first

        XCTAssertEqual(button?.titleLabel?.text, "+")
    }

テストはパスするようになったでしょうか?次のボタンタップ時の振る舞いをテストに書き起こしていきたいですが、その前にテストコードのリファクタリングを行いましょう。viewControllerを取得する部分が重複しているのと、buttonとlabelを取得する部分のコードは次のテストでも使われそうなのでまとめておきたいです。

SampleAppTests.swift リファクタ後

class SampleAppTests: XCTestCase {

    var viewController: ViewController!

    override func setUp() {
        super.setUp()

        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        viewController = storyboard.instantiateViewController(withIdentifier: "ViewController") as? ViewController
    }

    func test_画面が表示されたら_プラスボタンが表示される() {
        let button = viewController.plusButton

        XCTAssertEqual(button?.titleLabel?.text, "+")
    }

    func test_画面が表示されたら_マイナスボタンが表示される() {
        let button = viewController.minusButton

        XCTAssertEqual(button?.titleLabel?.text, "?")
    }

    func test_画面が表示されたら_ラベルに0が表示される() {
        let label = viewController.label

        XCTAssertEqual(label?.text, "0")
    }
}

private extension ViewController {
    var plusButton: UIButton? {
        return view
            .subviews
            .filter({ $0.accessibilityIdentifier == "plusButton" })
            .compactMap({ $0 as? UIButton })
            .first
    }

    var minusButton: UIButton? {
        return view
            .subviews
            .filter({ $0.accessibilityIdentifier == "minusButton" })
            .compactMap({ $0 as? UIButton })
            .first
    }

    var label: UILabel? {
        return view
            .subviews
            .filter({ $0.accessibilityIdentifier == "label" })
            .compactMap({ $0 as? UILabel })
            .first
    }
}

テストコードもすっきりしたので、次にボタンタップ時の振る舞いを書いていきましょう。書かなければいけない残りの振る舞いは2つですね?

  • +ボタン(UIButton)がタップされたらラベルの数字を1つ増やす
  • -ボタン(UIButton)がタップされたらラベルの数字を1つ減らす

まずは+ボタンから。ボタンがタップされたらラベルが0から1に変わることを確認しましょう。

SampleAppTests.swift

    func test_プラスボタンがタップされる_ラベルの数字が1つ増える() {
        let button = viewController.plusButton
        let label = viewController.label

        XCTAssertEqual(label?.text, "0")

        button?.sendActions(for: .touchUpInside)

        XCTAssertEqual(label?.text, "1")
    }

テストを実行するとボタンのアクションを定義していないので2つ目のassertが失敗するはずです。

StoryboardのUIアイテムとViewController.swiftを紐付けて実装を行いましょう。僕はとりあえず動かすために以下のように実装してみました。

ViewController.swift

import UIKit
class ViewController: UIViewController {
    @IBOutlet private weak var label: UILabel!

    @IBAction func plusButtonAction(_ sender: UIButton) {
        let number = Int(label.text!)!
        label.text = String(number + 1)
    }
}

もう一度テストを実行するとパスするようになったでしょうか?

パスしたのであれば、-ボタンのテスト追加実装も行いましょう。

SampleAppTests.swift

    func test_マイナスボタンがタップされる_ラベルの数字が1つ減る() {
        let button = viewController.minusButton
        let label = viewController.label

        XCTAssertEqual(label?.text, "0")

        button?.sendActions(for: .touchUpInside)

        XCTAssertEqual(label?.text, "-1")
    }

テストが失敗することを確認したら実装を行いましょう。

ViewController.swift

class ViewController: UIViewController {
    ...
    ...
    @IBAction func minusButtonAction(_ sender: UIButton) {
        let number = Int(label.text!)!
        label.text = String(number - 1)
    }
}

この実装でサンプルアプリは完成となります🎉🎉🎉では、ここまで書いてきた振る舞いのテストは一体何が嬉しいのでしょうか?

BDD/振る舞い駆動開発のメリット・デメリット

一番のメリットはリファクタリングをするときに不安が少ないことです。

例えば今回書いた+や-の演算の実装を関数にまとめてみたり、別のクラスに剥がしてみても追加した振る舞いのテストは壊れずに動作し続けてくれます。リファクタリングをしても実装が壊れているかどうかアプリを実行しなくても気づくことができます。

ViewController.swift

class ViewController: UIViewController {
    @IBOutlet private weak var label: UILabel!
    @IBAction func plusButtonAction(_ sender: UIButton) {
        label.text = calc(label, sign: +)
    }

    @IBAction func minusButtonAction(_ sender: UIButton) {
        label.text = calc(label, sign: -)
    }

    private func calc(_ label: UILabel, sign: (Int, Int) -> Int) -> String {
        return String(sign(Int(label.text!)!, 1))
    }
}

例えば先程のコードをこのように変えてみました。テストを実行するパスしています。リファクタリングをしても実装が壊れてないことがテストによって担保されているので安心して次の作業に移ることができます。

この安心感に大きな価値があると思います。

特に近年ではMVPやClean Architectureなど設計の改善に注目が集まっていますが、そうした改修を行っても、Viewから見たテストが無ければ既存の機能が問題なく動き続けているかどうかは分かりません。

「もしも壊れてしまったらどうしよう」という想いを抱えながら行うリファクタリングは辛さしかありません。一方でリファクタリングをしないアプリもよろしくありません。

kent Beckもこんなことを言っていました。

リファクタリングが無ければ設計は次第に腐り、あなたは職や家族を失い、健康に気をつかわなくなり、虫歯になる「テスト駆動開発」より

継続して安心のできるリファクタリングを続けるために、振る舞いを見るテストは必要だと僕は考えています。

もちろん、振る舞いを見るテストにもデメリットはあります。コードを書く量は2倍くらいになるのでテストコード自体のメンテナンスコストを払う必要がありますし、テストの実行時間も場合によっては長くなることがあり注意が必要になります。

コストとメリットのバランスを比較しつつ上手く使っていければと思います。

以上です。TDDやBDD自体ここ半年ぐらいで勉強したてホヤホヤなので何か意見やアドバイスなどあれば是非よろしくお願いします!

参考文献

関連リンク

Yahoo! JAPANでは情報技術を駆使して人々や社会の課題を一緒に解決していける方を募集しています。詳しくは採用情報をご覧ください。

  • このエントリーをはてなブックマークに追加