こんにちは。ヤフー株式会社でiOS Yahoo! JAPANアプリの開発をしている内村です。この記事では僕が普段のiOSの開発で意識・実践している振る舞いを見たテストによる開発(BDD/振る舞い駆動開発)を紹介します。
振る舞いを見るテストとは
振る舞い駆動開発では、TDDと同様にまずテストから書いていきます。その際のテストの観点が自然言語を併記しながらユーザから見てプログラムがどう動くべきか振る舞いを意識しているのが特徴です。
例えば「この関数はこの引数に対してこのreturnを返すべき」ではなく「このボタンが押されたら最終的にラベルにAAAが表示される」という観点でテストを作成します。
そのようなテストはUIテストでしか出来ないと思われるかもしれませんが、iOSのユニットテストでも実現することは可能です。
次に、書いたテストがパスするように実装を書いていき、必要に応じてリファクタリングをしていきます。
まとめると以下のようになります。
- 振る舞いを意識したテストを書く
- 書いたテストがパスするように実装を書く
- リファクタリングする
この1、2、3を繰り返しながら開発をしていきます。
作成するサンプルアプリについて
今回作成するサンプルアプリの動画です。
下に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する方法もあります。
最後にLabel
が0
であることを評価しています。
このテストをv⌘ + U
で実行するとエラーが出てしまいます。解決していきましょう。
先程書いたコードでIdentifier
を書いていないのが原因ですね。storyboard.instantiateViewController(withIdentifier: "ViewController")
← ここ
Main.storyboard
を選択しViewController
を選び、Show the Identity Inspector
でStoryboard ID
を写真のように設定しましょう。
Main.storyboard
もう一度⌘ + U
を押すとテストが最後まで実行されます。そして当たり前ですがUILabel
が無いのでAssert部分が赤くなります。次はここを直しましょう。
再び、Main.storyboard
を選択しViewController
を選び、UILabel
を配置してください。
UILabel
をダブルクリックして、テキストを「0」に変更したらShow the Identity Inspector
でAccessibility 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自体ここ半年ぐらいで勉強したてホヤホヤなので何か意見やアドバイスなどあれば是非よろしくお願いします!
参考文献
- テスト駆動開発
- BDDをやる前に必ず1度は読んだほうがいいです。
- 付録も充実しているのでおすすめです。「TDDのTは「テスト」の一部に過ぎない」の章がタメになりました
- Swiftで書いておぼえるTDD (技術書典シリーズ(NextPublishing)) 田中 賢治
- TDDをしたことがない人向けに丁寧に解説されています
関連リンク
- Quickを使ってビューコントローラをテストする
- QuickフレームワークやDIの仕方についても紹介されていて参考になります。
- ビヘイビア駆動開発
こちらの記事のご感想を聞かせください。
- 学びがある
- わかりやすい
- 新しい視点
ご感想ありがとうございました