【Swift】サンプルアプリを作りながらBDDによるアプリ開発を学んでみた

2021.08.19

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

最近、TDDの本やiOSのテストに関する本を読んで、テストに興味を持ち出した者です。 iOSテスト全書のBDDの章を読んでとても面白かったので、BDDによるアプリ開発手法への理解をさらに深める為に自分なりにサンプリアプリを作りながら学んでいくことにしました。

対象

  • 「BDD?何それ?ベーコン・レタス・トマトバーガー?」という方
  • BDDに興味があるけど、まだ触れたことがないテスト初心者の方

私自身もテストビギナーなのでサンプルアプリを作りながら、理解を深めていこうと思っております。

BDD

Behavior Driven Developmentの略で振る舞い駆動開発と言われています。 TDD(テスト駆動開発)から派生したもので、基本的な開発手法はTDDと同じですが、それに加えてプログラムに期待される振る舞い、いわば要件に近い形で自然に使っている言葉を併記しながらテストコードを記述していきます。

「期待通り、要件通りにアプリが振る舞っているか? 」そのような観点で進めていくのがBDDです。

TDD

TDDの詳細は今回は割愛させていただきますが、BDDもTDDプラクティスを用いて実装を進めていきます。

TDDプラクティスとは、「レッド → グリーン → リファクター」のサイクルを用いて実装のサイクルを回します。

  • 失敗するテストを書く  (レッド)
  • テストが成功するように実装する (グリーン)
    • ここで重要なポイントは、先に設定したテスト条件をクリアさせるだけの「最低限」のコードを書くことです。
    • レッドとグリーンの工程はリファクタリングを行う前に数回繰り返す場合もあります。
  • リファクタリングを行う (リファクター)

このサイクルを繰り返しながら実装を進めていきます。

作成したサンプルアプリ

BDD勉強用に作成するアプリは、テキストフィールドに入力された文字によって画像を切り替えるシンプルなものになります。

デモ

サンプルアプリのコードはGitHubにあげております。

サンプルアプリの要件

  • 0〜10までの文字を入力すると、その入力文字に紐づく画像に切り替わる
  • 入力文字の初期値は0
  • 上記以外の数字の場合はエラー画像を表示する
  • 数字以外の入力の場合もエラー画像を表示する
  • 数字キーボードは常時表示されている

画像リソース

今回のサンプリアプリで使用する画像リソースです。

hair0 hair1 hair2 hair3 hair4 hair5
画像
hair6 hair7 hair8 hair9 hair10 error
画像

それでは開発をはじめましょう

振る舞いを意識しながら開発を進めていきましょう!

開発環境

  • Xcode 12.5
  • Swift 5.4

プロジェクトの作成

プロジェクト名は、BDD_HairCounterに設定しました。

XCode > Create a New Xcode project > Choose options for your new project まで進んでいただき、こちらの Include Tests にチェックを入れることで初期設定からテストを追加してくれます。もちろん、後からテストファイルを追加することは可能ですが今回は先に追加する方法を選びました。

設定と名前の変更

テストを始める前にstoryboardViewControllerBDD_HairCounterTestsが初期設定の名前のものなので、任意の今回作成するプロジェクトに適した名前に変更します。それに伴うMain userInterfaceInfo.plistの変更も行います。

(補足: 最初にInclude TestをするとUITest用のファイルも作成されますが、今回はUITestは使用しません。また、今回指すテストとはUITestではなく、ユニットテストのことを指します。)

Storyboard 名 ViewController 名 Tests 名
変更後 HairCounter.storyboard HairCounterViewController.swift HairCounterViewControllerTests.swift

画像リソースの追加

用意した画像リソースをAssetsフォルダに追加します。

テストが成功するか確認

ここまで設定したら、BDDで進めて行く前にcommand + U でテストを実行して成功するか確認しましょう。この時点でエラーが発生しましたら、環境で何らかの問題が発生していますのでエラーを解決してから振る舞いのテストに取り掛かりましょう!成功すると、Test Succeededという画像が表示されます。

振る舞いのテストを作成する

今回のサンプルアプリですが、使用するユーザーの視点から考えて、アプリ起動時に下記のように振る舞う必要があると考えました。

  • 初期表示時の画像はhair0の画像が表示されていること
  • 初期表示時に文字入力のルール説明の文字が表示されていること
  • 初期表示時に0という文字が入力されていること
  • 初期表示時に数字のキーボードが表示されていること

(本来ならば、UIFontや各サイズ、色などの振る舞いの条件が複数あるかと思いますが、今回はサンプリアプリということで割愛させていただきました)

失敗するテストを書く  レッド

まずは、「初期表示時の画像はhair0の画像が表示されていること」の振る舞いテストを書いていきます。

import XCTest
@testable import BDD_HairCounter

class HairCounterViewControllerTests: XCTestCase {

    override func setUpWithError() throws {
    }

    override func tearDownWithError() throws {
    }

    func test_初期起動時_画像にhair0が表示されていること() {
        let stroyboard = UIStoryboard(name: "HairCounter", bundle: nil)
        let viewController = stroyboard.instantiateInitialViewController() as! HairCounterViewController
        let window = UIWindow(frame: UIScreen.main.bounds)
        window.rootViewController = viewController
        // 画面表示を行う
        window.makeKeyAndVisible()
        XCTAssertEqual(viewController.hairImageView.image, UIImage(named: "hair0"))
    }
}

このようなテストになりました。最後のXCTAssertEqualメソッドで左右の値がイコールかどうかを判定しています。そして、当たり前ですが、現時点ではhairImageViewというものは存在しないのでXcodeから警告を受けます。

警告上等!まずはこのエラーを解決していきましょう。

HairCounter.storyboardUIImageViewを追加し、HairCounterViewControllerIBOutletで紐付けを行いました。

これで先程のエラーを解決したはずなのでテストを実行して結果を見てみましょう。

やったー!失敗するテストの成功です!こんなに失敗することが嬉しいことなんて滅多にないですね。笑

ここで大事なのは期待通りに失敗しているかを確認することです。意図しない原因で失敗していないことを確認してください。今回は「hairImageViewの値はnilなので、hair0と一致しません」と言われているのでこの結果は期待通りとなります。

テストを成功させる為の実装を行う グリーン

それでは失敗したテストを成功させる為の実装を行いましょう。storyboardで画像を設定してもいいですが、今回はviewDidLoad内で値を代入をします。

import UIKit

class HairCounterViewController: UIViewController {

    @IBOutlet private(set) weak var hairImageView: UIImageView!

    override func viewDidLoad() {
        super.viewDidLoad()
        hairImageView.image = UIImage(named: "hair0")
    }
}

実装後、テストを実行してみましょう!テスト成功の画像が出たらテストが成功です!

テストが成功すると、テストメソッドの左側にグリーンのチェックがつきます。

今回はリファクタリングを行う箇所は無さそうなので、レッドの作業に戻りましょう。

同じようにサイクルを回す

次は、「初期表示時に文字入力のルール説明の文字が表示されていること」の振る舞いテストです。

まずは失敗するテストを書きましょう。

func test_初期表示時_文字入力ルール説明の文字が表示されていること() {
    let stroyboard = UIStoryboard(name: "HairCounter", bundle: nil)
    let viewController = stroyboard.instantiateInitialViewController() as! HairCounterViewController
    let window = UIWindow(frame: UIScreen.main.bounds)
    window.rootViewController = viewController
    window.makeKeyAndVisible()
    XCTAssertEqual(viewController.ruleDescriptionLabel.text, "0~10までの数字を入力してください")
}

毎度ながらruleDescriptionLabelが存在しないと言われましたね。

  1. hairImageViewの時と同じようにstoryboardにUILabelを追加して、hairCounterViewControllerに紐付けを行い、ruleDescriptionLabelを作成する。
  2. ruleDescriptionLabelが存在しないという最低限のエラーを解決したのでテストを実行し、テストが失敗するのを確認する

ここまできたらテストを成功させる実装を行います。

こちらがテストを成功させるコードです。

override func viewDidLoad() {
    super.viewDidLoad()
    hairImageView.image = UIImage(named: "hair0")
    ruleDescriptionLabel.text = "0~10までの数字を入力してください"
}

リファクタリング

HairCounterViewControllerのコードを見る限り、まだリファクタリングを行う箇所は無いように思いますが、テストのコードを見るとどうでしょうか?

func test_初期起動時_画像にhair0が表示されていること() {
    let stroyboard = UIStoryboard(name: "HairCounter", bundle: nil)
    let viewController = stroyboard.instantiateInitialViewController() as! HairCounterViewController
    let window = UIWindow(frame: UIScreen.main.bounds)
    window.rootViewController = viewController
    window.makeKeyAndVisible()
    XCTAssertEqual(viewController.hairImageView.image, UIImage(named: "hair0"))
}

func test_初期表示時_文字入力ルール説明の文字が表示されていること() {
    let stroyboard = UIStoryboard(name: "HairCounter", bundle: nil)
    let viewController = stroyboard.instantiateInitialViewController() as! HairCounterViewController
    let window = UIWindow(frame: UIScreen.main.bounds)
    window.rootViewController = viewController
    window.makeKeyAndVisible()
    XCTAssertEqual(viewController.ruleDescriptionLabel.text, "0~10までの数字を入力してください")
}

viewControllerの初期起動時を処理を実行するコードまでが繰り返し書かれています。このリファクリングのサイクルはプロダクションコードだけではなくテストコードにも該当します。可読性の高いコードは保守しやすいプログラムにつながるのでこのリファクタリングも忘れないようにしましょう。

ここで活躍するのが、Testsクラスの上部で記述されているsetUpWithError()メソッドです。このメソッドはクラス内の各テストメソッドが実行される前に呼び出されるメソッドです。

それではリファクタリングしてみましょう。

class HairCounterViewControllerTests: XCTestCase {

    private var viewController: HairCounterViewController!

    override func setUpWithError() throws {
        let stroyboard = UIStoryboard(name: "HairCounter", bundle: nil)
        viewController = stroyboard.instantiateInitialViewController() as? HairCounterViewController
        let window = UIWindow(frame: UIScreen.main.bounds)
        window.rootViewController = viewController
        window.makeKeyAndVisible()
    }

    func test_初期起動時_画像にhair0が表示されていること() {
        XCTAssertEqual(viewController.hairImageView.image, UIImage(named: "hair0"))
    }

    func test_初期表示時_文字入力ルール説明の文字が表示されていること() {
        XCTAssertEqual(viewController.ruleDescriptionLabel.text, "0~10までの数字を入力してください")
    }
}

初期起動時の処理までをsetUpWithErrorメソッドに渡したことで各テストメソッドがスッキリしました。またsetUpWithError()は今回使わなそうだったので削除しました。

ひたすらこのサイクルを回す

サイクルの流れを掴んできたのではないでしょうか?あとはひたすらこのサイクルを回しながら開発を進めていきます。

引き続き、残りの振る舞いについてもテストをしていきます。 それぞれ一つずつサイクルを回していきますが、記事の都合上まとめてコードを記載させていただきます。

残りはこちらの二つですが、初期表示時に数字のキーボードが表示されていることの中には二つの要素がありそうなのでそちらも分けて実装いたしました。

  • 初期表示時に0という文字が入力されていること
  • 初期表示時に数字のキーボードが表示されていること
    • キーボードが数字入力になっていること
    • キーボードが表示されていること

レッド

// 1
func test_初期起動時_テキストフィールドに0という文字が入力されていること() {
    XCTAssertEqual(viewController.hairCountTextField.text, "0")
}

// 2
func test_初期起動時_キーボードのタイプが数字入力になっていること() {
    XCTAssertEqual(viewController.hairCountTextField.keyboardType, .numberPad)
}

// 3 
func test_初期起動時_キーボードが表示されていること() {
    XCTAssertTrue(viewController.hairCountTextField.isFirstResponder)
}

グリーン

override func viewDidLoad() {
    super.viewDidLoad()
    hairImageView.image = UIImage(named: "hair0")
    ruleDescriptionLabel.text = "0~10までの数字を入力してください"
    // 1
    hairCountTextField.text = "0"
    // 2
    hairCountTextField.keyboardType = .numberPad
    // 3
    hairCountTextField.becomeFirstResponder()
}

文字入力機能の実装をする

初期起動時の振る舞いのテストと実装は完了したので、ついに文字入力機能の実装に取り掛かります。

要件を元に実装すべきものをリスト化しました。

  • 0~10の文字が入力されたらその値に紐ずく画像に切り替わる
  • 11以上の文字が入力されたらエラー画像に切り替わる
  • 数字以外の文字が入力された場合はエラー画像に切り替わる

失敗するテストを書く  レッド

またやってきました失敗タイムです。張り切って失敗するテストを書いていきましょう。

今回はTextFieldの値の変更はデリゲートメソッドのtextFieldDidChangeSelectionを利用して受け取ることにしました。

func test_TextFieldの文字の値が変わった時_値が1_画像にhair1が表示されていること() {
    viewController.hairCountTextField.text = "1"
    viewController.textFieldDidChangeSelection(viewController.hairCountTextField)
    XCTAssertEqual(viewController.hairImageView.image, UIImage(named: "hair1"))
}

いつものごとくメソッドが無いと怒られるので広い心で受け止めましょう。

テストを成功させる為の最低限の実装する グリーン

ここで重要なのは失敗したテストが成功する為の最低限の実装を行うということです。

class HairCounterViewController: UIViewController {

    (省略)

    override func viewDidLoad() {
        super.viewDidLoad()
        hairImageView.image = UIImage(named: "hair0")
        ruleDescriptionLabel.text = "0~10までの数字を入力してください"
        hairCountTextField.text = "0"
        hairCountTextField.keyboardType = .numberPad
        hairCountTextField.becomeFirstResponder()
        hairCountTextField.delegate = self
    }
}

extension HairCounterViewController: UITextFieldDelegate {

    func textFieldDidChangeSelection(_ textField: UITextField) {
        hairImageView.image = UIImage(named: "hair1")
    }
}

この実装でテストは成功しましたが、

「おいおい、これだと他の数字が入った時もhair1が表示されるじゃねぇか」

と思われたと思いますが、まさにその通りです。これが最低限の実装になります。 その都度テストのサイクルを繰り返し、エラーのない実装にしていきましょう!

入力された文字が2の場合の失敗するテストを書く  レッド

そこで先程の疑問に抱いた入力値を2にしたテストを追加します。 hair1が渡されるのは分かっているのでもちろんこのテストは失敗します。笑

func test_TextFieldの文字の値が変わった時_値が2_画像にhair2が表示されていること() {
    viewController.hairCountTextField.text = "2"
    viewController.textFieldDidChangeSelection(viewController.hairCountTextField)
    XCTAssertEqual(viewController.hairImageView.image, UIImage(named: "hair2"))
}

ここでまたまたグリーンの出番になります。

入力した文字が2の場合でも成功する実装を行う グリーン

textFieldから受け取った入力文字をhairの文字列と合わせることで成功を導き出すことが出来ました。

extension HairCounterViewController: UITextFieldDelegate {

    func textFieldDidChangeSelection(_ textField: UITextField) {
        hairImageView.image = UIImage(named: "hair\(textField.text!)")
    }
}

リファクタリング

書いたテストを見ると、同じような処理が繰り返されているのが見えてきました。これからさらに値が3の時、値が4の時、、、と追加していくと考えると少し恐ろしいですね。

func test_TextFieldの文字の値が変わった時_値が1_画像にhair1が表示されていること() {
    viewController.hairCountTextField.text = "1"
    viewController.textFieldDidChangeSelection(viewController.hairCountTextField)
    XCTAssertEqual(viewController.hairImageView.image, UIImage(named: "hair1"))
}

func test_TextFieldの文字の値が変わった時_値が2_画像にhair2が表示されていること() {
    viewController.hairCountTextField.text = "2"
    viewController.textFieldDidChangeSelection(viewController.hairCountTextField)
    XCTAssertEqual(viewController.hairImageView.image, UIImage(named: "hair2"))
}

0〜10の時はその値に紐づく画像を表示するというのは決まっているので、それも踏まえた上でリファクタリングをしていきたいと思います。

func test_TextFieldの文字の値が変わった時_値が0から10_画像にその値に紐づく画像が表示されていること() {
    for i in 0...10 {
        viewController.hairCountTextField.text = "\(i)"
        viewController.textFieldDidChangeSelection(viewController.hairCountTextField)
        XCTAssertEqual(viewController.hairImageView.image, UIImage(named: "hair\(i)"))
    }
}

入力された文字が11の時の失敗するテストを書く レッド

ここで求められるのは、入力された文字が11の場合はerror画像が表示されているということです。

func test_TextFieldの文字の値が変わった時_値が11_画像にerrorが表示されていること() {
    viewController.hairCountTextField.text = "11"
    viewController.textFieldDidChangeSelection(viewController.hairCountTextField)
    XCTAssertEqual(viewController.hairImageView.image, UIImage(named: "error"))
}

案の定、テスト失敗しました!嬉しい!

入力された文字が11の時の成功する最低限の実装を行う グリーン

func textFieldDidChangeSelection(_ textField: UITextField) {

    if textField.text == "11" {
        hairImageView.image = UIImage(named: "error")
        return
    }
    hairImageView.image = UIImage(named: "hair\(textField.text!)")
}

最低限の実装を行い、テストを成功させました!

さて、お察しの通り、値が12だった場合のテストを書きましょう。

もうリズムは掴めてきたと思うのでサクサクいきます。

入力された文字が12の時も失敗するテストを書く レッド

func test_TextFieldの文字の値が変わった時_値が12_画像にerrorが表示されていること() {
    viewController.hairCountTextField.text = "12"
    viewController.textFieldDidChangeSelection(viewController.hairCountTextField)
    XCTAssertEqual(viewController.hairImageView.image, UIImage(named: "error"))
}

XCTAsserthairImageView.imageはnilなのでイコールの式が成り立たないよ!と言われ、堂々と失敗いたしました。

失敗なんて怖くない!グリーンの実装に移りたいと思います。

入力された文字が12の時も成功する実装を行う グリーン

ここからは12の時、13の時、14の時、、、と実装を繰り返すのは見えているので入力文字が11以上の場合の実装を行ないます。

func textFieldDidChangeSelection(_ textField: UITextField) {

    guard let hairCount = Int(textField.text!) else {
        return
    }

    if hairCount >= 11 {
        hairImageView.image = UIImage(named: "error")
        return
    }
    hairImageView.image = UIImage(named: "hair\(hairCount)")
}

これでテストが成功しました!

テストクラスのリファクタリング

テストクラスを見ると、textFieldDidChangeSelectionの処理が繰り返し書かれているのが見えてきました。また、「TextFieldの文字の値が変わった時」という振る舞いの意図が明確に表現されておらず、長く読みづらいコードになっているかと思います。

func test_TextFieldの文字の値が変わった時_値が0から10_画像にその値に紐づく画像が表示されていること() {
    for i in 0...10 {
        viewController.hairCountTextField.text = "\(i)"
        viewController.textFieldDidChangeSelection(viewController.hairCountTextField)
        XCTAssertEqual(viewController.hairImageView.image, UIImage(named: "hair\(i)"))
    }
}

func test_TextFieldの文字の値が変わった時_値が11_画像にerrorが表示されていること() {
    viewController.hairCountTextField.text = "11"
    viewController.textFieldDidChangeSelection(viewController.hairCountTextField)
    XCTAssertEqual(viewController.hairImageView.image, UIImage(named: "error"))
}

func test_TextFieldの文字の値が変わった時_値が12_画像にerrorが表示されていること() {
    viewController.hairCountTextField.text = "12"
    viewController.textFieldDidChangeSelection(viewController.hairCountTextField)
    XCTAssertEqual(viewController.hairImageView.image, UIImage(named: "error"))
}

UITextFieldextensionとしてvalueChangeメソッドを実装しました。呼び出し側ではvalueChangeに入力文字を渡すとtextFieldの文字変更時の処理が行われます。

private extension UITextField {

    func valueChange(into value: String, on viewController: HairCounterViewController) {
        self.text = value
        viewController.textFieldDidChangeSelection(self)
    }
}

コードの見た目もTextFieldの値が変わった時というテストの意図が明白になりました。

// hairCountTextFieldの値が変わる_値は11
viewController.hairCountTextField.valueChange(into: "11", on: viewController)

またviewControllerのプロパティとして呼ばれている箇所もやや度々繰り返されており、ここのテストはHairCounterViewControllerのテストというのは明らかなので、hairCounterTextFieldhairImageViewHairCounterViewControllerTestsのメンバー変数として追加しました。

private var hairCountTextField: UITextField {
    return viewController.hairCountTextField
}

private var hairImageView: UIImageView {
    return viewController.hairImageView
}

リファクタリングを行ったコードを見てみましょう。

func test_TextFieldの文字の値が変わった時_値が0から10_画像にその値に紐づく画像が表示されていること() {
    for i in 0...10 {
        hairCountTextField.valueChange(into: "\(i)", on: viewController)
        XCTAssertEqual(hairImageView.image, UIImage(named: "hair\(i)"))
    }
}

func test_TextFieldの文字の値が変わった時_値が11_画像にerrorが表示されていること() {
    hairCountTextField.valueChange(into: "11", on: viewController)
    XCTAssertEqual(hairImageView.image, UIImage(named: "error"))
}

func test_TextFieldの文字の値が変わった時_値が12_画像にerrorが表示されていること() {
    hairCountTextField.valueChange(into: "12", on: viewController)
    XCTAssertEqual(hairImageView.image, UIImage(named: "error"))
}

これでよりテストコードの意図が明白になりました。スッキリしましたね!いよいよ、ラストスパート!

値が数字以外の時の失敗するテストを書く レッド

「数字以外の文字が入力された場合はエラー画像に切り替わる」の振る舞いテストを書いていきます。

今回はすでにキーボードタイプがnumberpadであることは保証されているので、数字以外の時は""の文字列の時のみになります。

func test_TextFieldの文字の値が変わった時_値が何もない_画像にerrorが表示されていること() {
    hairCountTextField.valueChange(into: "", on: viewController)
    XCTAssertEqual(hairImageView.image, UIImage(named: "error"))
}

上記のテストコードを追加し、実行。無事にテストが失敗しました!

値が数字以外の時の成功する最低限の実装を行う グリーン

値が""の時はIntに型変換出来ないのでguard文の中に処理を追加します。

func textFieldDidChangeSelection(_ textField: UITextField) {

    guard let hairCount = Int(textField.text!) else {
        hairImageView.image = UIImage(named: "error")
        return
    }

    if hairCount >= 11 {
        hairImageView.image = UIImage(named: "error")
        return
    }
    hairImageView.image = UIImage(named: "hair\(hairCount)")
}

無事テストが通りました!

値が数字以外の時の実装のリファクタリング

textFieldデリゲートのメソッドの中のコードを見ると、hairImageView.imageへのUIImageの代入とreturnが度々記述されているように見えます。またネストのせいでやや見にくくなっているようにも思えます。

リファクタリングをすると、

func textFieldDidChangeSelection(_ textField: UITextField) {
    guard let hairCount = Int(textField.text!),
          hairCount <= 10
    else {
        hairImageView.image = UIImage(named: "error")
        return
    }
    hairImageView.image = UIImage(named: "hair\(hairCount)")
}

スッキリしてみやすくなりました。

リファクタリングを行った後も、問題なくテストが成功するか忘れずテストを実行しましょう。

この文字入力の機能は本来ならば、責務の量が多くなると別クラスに責務分けをしたり、設計パターンによっては処理を別クラスに渡したい場合があると思います。そういう場合でも同じようにTDDプラクティスのサイクルを回しながら進めて行きます。

これでBDDを進めながらサンプリアプリが完成しました!

テストは精神安定剤

今回テストを学びながら感じたのは、テストは精神安定剤ということ。サンプルアプリではあったのだけれども、既存のコードに変更を加えた際にテストをポチッと実行するだけで、精神的安心を手に入れることが出来ました。 テストを書くだけで心が救われるなら今後は意識して書いて行きたいと感じました。   

おわりに

テストを先に書くことで、後からテストが書けないという状況を回避するということを学びました。 今回の振る舞いテストでは、画面の描画崩れなどは検知することが出来ません。なので、SnapshotTestingなども今後勉強して行きたいと思っております。

テストが通るとなんだかXcode様に認められた気がして心地よいです。笑

まだテストに関して学び始めたばかりなので、もし間違いや改善点等ありましたら優しく教えていただければ幸いです。

Have a nice test!

参考