最近、謎の集団zeneloでは、テスト駆動開発(TDD)を徹底しようという風潮が出てきました。
さらには受け入れテスト駆動開発(ATDD)も開発フローに取り入れていきたいという話もしています。
なので、今回は慣れと復習のためATDDでTODOアプリを作る過程をまとめていきます。
(あえて最近全然触っていないiOSで)
環境
- Xcode 10.1
- Swift 4.2.1
- XCTest
受け入れテスト駆動開発(ATDD)とは?
ATDDでは、先に振る舞い(仕様、要件)をテストコードで定義してからそれを満たす実装を行います。
受け入れテストコード自体が仕様なので、テストがPassしている状態は仕様を満たしていることを意味します。
また、受け入れテストは基本的にユーザーを含め誰もが理解できる自然言語で具体的に書きます。
これには下記のようなメリットがあります。
- 最初に要件を明確化する議論を行うので、ユーザーが求めていることの理解が進む
- 実装完了後に仕様の認識が間違っていたことに気付いて修正をするといった事態が起きにくい
- 受け入れテストが自然言語で具体的に表現されるので、仕様の考慮漏れを減らすことができる
ATDDのやり方
Cucumberなどの受け入れテストツールで採用されているGherkinというテスト記述言語フォーマットが分かりやすいのですが、iOSではGherkinのフォーマットで記述できるツールが見つからなかったので、今回は標準のXCTest
を使ってGherkin形式っぽく書いていきます。
1, 要件を明確化する議論を行い、仕様を自然言語で具体的に表現する
まずはユーザーやプロダクトオーナーと議論して、要件を明確化にします。
その上で具体例で実現したいこと(仕様)をGiven(条件)、When(操作)、Then(期待結果)で表現し、受け入れテスト仕様
とする
2, 受け入れテスト仕様をテストコードに変換してから実装を行う
テスト駆動開発のサイクルと同様に下記のようなサイクルを回していきます。
- Given-When-Thenで表現した仕様をテストコードに変換してテストを失敗させる(
RED
) - 実装を書いてテストを成功させる(
GREEN
) -
Code Smellを見つけたらリファクタリング(
REFACTOR
)
実際にATDDでTODOアプリをつくってみる
前提
今回はATDDの流れを大まかに体験することに主眼を置くので
- 機能はTODOの一覧表示機能、TODOの登録機能に絞る
- 実装方法の詳しい手順は書かない
- ユニットテストは省略する(本来は絶対書くべき)
完成形のアプリはこちら
1, 仕様を自然言語で具体的に表現する
本来であれば最初にユーザー、プロダクトオーナー、チームメンバーと要件に関して議論をするのですが、今回は僕1人なので自分が考えた仕様をGiven-When-Then
形式でまとめました。
Scenario: Top画面はTODO一覧画面
When TODOアプリを起動すると
Then TODO一覧画面が表示される
Scenario: TODO一覧画面からTODO登録画面に遷移できる
Given TODO一覧画面にいる状態で
When +ボタンを押すと
Then TODO登録画面が表示される
Scenario: TODO登録画面
Given TODO一覧画面にいる状態で
When TODO登録画面に遷移したとき
Then TODO名の入力欄が表示される
And 登録ボタンが表示される
Then TODO一覧ボタン(戻るボタン)が表示される
Scenario: TODO登録画面からTODO一覧画面に戻ることができる
Given TODO登録画面に遷移した状態で
When TODO一覧ボタンを押すと
Then TODO一覧画面に遷移する
Scenario: TODO登録画面でTODOを登録できる
Given TODO一覧画面に掃除という項目がなく
And TODO登録画面に遷移し
When TODO名の欄に掃除と入力して登録ボタンを押すと
Then TODO一覧画面に遷移し
And TODO一覧に掃除という項目が表示される
これで、それぞれの機能で満たすべきであろう仕様を定義できました。
この時点で上記をステークホルダー(ユーザーやプロダクトオーナー等)に見せて求められていることが本当に満たされているかを確認しておきましょう。
2, ATDDで実装
(1/5)Top画面はTODO一覧画面
まずは下記シナリオを実装します。
Scenario: Top画面はTODO一覧画面
When TODOアプリを起動すると
Then TODO一覧画面が表示される
XCTestのテストコードに変換します。
(※Cucumberなどのツールを使えばそのままテストコードとして利用できるが、ないので我慢)
import XCTest
class TodoFeatures: XCTestCase, TodoSteps {
func test_Top画面はTODO一覧画面() {
when_アプリが起動すると()
then_TODO一覧画面が表示される()
}
}
仕様の見やすさを担保する関係で各Stepの実装を別で管理したいので
TodoSteps.swiftというファイルを作り、上記で書いたメソッドの実態を書きます。
import XCTest
protocol TodoSteps {
// when
func when_アプリが起動すると()
// then
func then_TODO一覧が表示される()
}
extension TodoSteps {
// when
func when_アプリが起動すると() {
XCUIApplication().launch()
}
// then
func then_TODO一覧が表示される() {
// タイトルがTODO一覧であるNavigationBarが存在することでTODO一覧が表示されていることを確認
XCTAssertTrue(XCUIApplication().navigationBars["TODO一覧"].exists)
}
}
test_Top画面はTODO一覧画面()
のテストを実行します。
テストが失敗したのでこれでRED
の状態に。
何も実装していないので当然失敗するはず。期待通りの結果です。
次にテストをPassさせるための実装をします。
アプリ起動時に表示されるViewに、タイトルがTODO一覧
であるnavigationBarがあればいいので、今回はMain.storyBoardでNavigationControllerを追加して、RootViewControllerが持つNavigationBarのタイトルをTODO一覧
にします。
再びtest_Top画面はTODO一覧画面()
のテストを実行します。
テストがPassしたのでGreen
の状態に。
この段階では特にリファクタリングの必要がなさそうなのでこのシナリオの実装は完了!
(2/5)TODO一覧画面からTODO登録画面に遷移できる
続いて、次のシナリオを実装します。
Scenario: TODO一覧画面からTODO登録画面に遷移できる
Given TODO一覧画面にいる状態で
When +ボタンを押すと
Then TODO登録画面が表示される
まずはシナリオをテストコード化する。
func test_TODO一覧画面からTODO登録画面に遷移できる() {
given_TODO一覧画面にいる状態で()
when_+ボタンを押すと()
then_TODO登録画面が表示される()
}
続いてgivenのテストコードを書きます。
func given_TODO一覧画面にいる状態で() {
XCUIApplication().launch()
XCTAssertTrue(XCUIApplication().navigationBars["TODO一覧"].exists)
}
// 未実装なのでtestが失敗するようにしておく
func when_+ボタンを押すと() { XCTFail("Unimplemented") }
func then_TODO登録画面が表示される() { XCTFail("Unimplemented") }
test_TODO一覧画面からTODO登録画面に遷移できる()
のテストを実行します。
RED
のままですが、未実装のwhen_+ボタンを押すと()
とthen_TODO登録画面が表示される()
で失敗しているだけで、given_TODO一覧画面にいる状態で()
はPasssしているのでOKです。
続いてwhenのテストコードを書きます。
func when_+ボタンを押すと() {
XCUIApplication().buttons["Add"].tap()
}
テストを実行します。
+
というボタンが存在しないという理由でRED
の状態になります。
なので次は+ボタンを作ります。
テストを実行します。
RED
のままだが、when_+ボタンを押すと()
でのエラーが解消されたのでOK。
続いてthen_TODO登録画面が表示される()
のテストを書きます。
func then_TODO登録画面が表示される() {
XCTAssertTrue(XCUIApplication().navigationBars["TODO登録"].exists)
}
テストを実行します。
テストが失敗したのでRED
の状態になります。
テストがPassするように、
+を押したらTODO登録画面(「TODO登録」というnavigationBarTitleを持つView)に遷移するような実装をします。
storyBoardでViewControllerを追加し、
「+」ボタンのactionを追加したViewControllerに向けて遷移方法をpushにします。
テストを実行します。
PassしたのでGreen
の状態に。
これでTODO一覧画面からTODO登録画面に遷移できる
のScenarioが完成!
続いて次のシナリオを実装します。
(3/5)TODO登録画面のフォーム
Scenario: TODO登録画面のフォーム
Given TODO一覧画面にいる状態で
When TODO登録画面に遷移したとき
Then TODO名の入力欄が表示される
And 登録ボタンが表示される
And TODO一覧ボタンが表示される
まずはシナリオに対応するテストコードを書きます。
func test_TODO登録画面のフォーム() {
given_TODO一覧画面にいる状態で()
when_TODO登録画面に遷移したとき()
then_TODO名の入力欄が表示される()
then_登録ボタンが表示される()
then_TODO一覧ボタン(戻るボタン)が表示される()
}
続いて各ステップの操作を書きます。
func when_TODO登録画面に遷移したとき() {
XCUIApplication().buttons["Add"].tap()
XCTAssertTrue(XCUIApplication().navigationBars["TODO登録"].exists)
}
func then_TODO名の入力欄が表示される() {
XCTAssertTrue(XCUIApplication().textFields["TODO名"].exists)
}
func then_登録ボタンが表示される() {
XCTAssertTrue(XCUIApplication().buttons["登録"].exists)
}
func then_TODO一覧ボタン(戻るボタン)が表示される() {
XCTAssertTrue(XCUIApplication().buttons["TODO一覧"].exists)
}
テストを実行します。
TODO名という入力欄と、登録というボタンが存在しないことが理由でRED
になります。
なのでTODO名という入力欄と、登録というボタンを配置します。
テストを実行します。
PassしたのでGreen
の状態に。
これでTODO登録画面のフォーム
のシナリオが完成!
続いて次のシナリオを実装します。
(4/5)TODO登録画面からTODO一覧画面に戻ることができる
Scenario: TODO登録画面からTODO一覧画面に戻ることができる
Given TODO登録画面に遷移した状態で
When TODO一覧ボタンを押すと
Then TODO一覧画面に遷移する
まずはシナリオに対応するテストコードを書きます。
func test_TODO登録画面からTODO一覧画面に戻ることができる() {
given_TODO登録画面に遷移した状態で()
when_TODO一覧ボタンを押すと()
then_TODO一覧画面に遷移する()
}
続いて各ステップの操作を書きます。
func given_TODO登録画面に遷移した状態で() {
XCUIApplication().launch()
XCUIApplication().buttons["Add"].tap()
XCTAssertTrue(XCUIApplication().navigationBars["TODO登録"].exists)
}
func when_TODO一覧ボタンを押すと() {
XCUIApplication().buttons["TODO一覧"].tap()
}
func then_TODO一覧画面に遷移する() {
XCTAssertTrue(XCUIApplication().navigationBars["TODO一覧"].exists)
}
テストを実行します。
テストがPassしました。
これはたまたま(2/5)TODO一覧画面からTODO登録画面に遷移できる
の画面遷移を実装した際に、戻るボタンも実装されていたのでRedにならずにGreenになりました。
ここで一旦今まで書いたテストコードを見てみます。
extension TodoSteps {
func given_TODO一覧画面にいる状態で() {
XCUIApplication().launch()
XCTAssertTrue(XCUIApplication().navigationBars["TODO一覧"].exists)
}
func given_TODO登録画面に遷移した状態で() {
XCUIApplication().launch()
XCUIApplication().buttons["Add"].tap()
XCTAssertTrue(XCUIApplication().navigationBars["TODO登録"].exists)
}
........
func when_TODO登録画面に遷移したとき() {
XCUIApplication().buttons["Add"].tap()
XCTAssertTrue(XCUIApplication().navigationBars["TODO登録"].exists)
}
func then_TODO一覧画面が表示される() {
XCTAssertTrue(XCUIApplication().navigationBars["TODO一覧"].exists)
}
func then_TODO登録画面が表示される() {
XCTAssertTrue(XCUIApplication().navigationBars["TODO登録"].exists)
}
........
func then_TODO一覧画面に遷移する() {
XCTAssertTrue(XCUIApplication().navigationBars["TODO一覧"].exists)
}
}
重複が多いので下記のようにメソッドに切り出してテストコードをリファクタリングします。
func isInTodoListView() -> Bool {
return XCUIApplication().navigationBars["TODO一覧"].exists
}
func isInTodoAddView() -> Bool {
return XCUIApplication().navigationBars["TODO登録"].exists
}
func tapAddButton() {
XCUIApplication().buttons["Add"].tap()
}
テストを実行します。
PassしたのでOKです。
続いて最後のシナリオを実装します。
(5/5)TODO登録画面からTODO一覧画面に戻ることができる
Scenario: TODO登録画面でTODOを登録できる
Given TODO一覧画面に掃除という項目がなく
And TODO登録画面に遷移し
When TODO名の欄に掃除と入力して登録ボタンを押すと
Then TODO一覧画面に遷移し
And TODO一覧に掃除という項目が表示される
まずはシナリオに対応するテストコードを書きます。
func test_TODO登録画面でTODOを登録できる() {
given_TODO一覧画面に掃除という項目がなく()
given_TODO登録画面に遷移した状態で()
when_TODO名の欄に掃除と入力して登録ボタンを押すと()
then_TODO一覧画面に遷移する()
then_TODO一覧に掃除という項目が表示される()
}
func exists掃除cell() -> Bool {
return XCUIApplication().staticTexts["掃除"].exists
}
func given_TODO一覧画面に掃除という項目がなく() {
XCUIApplication().launch()
XCTAssertTrue(isInTodoListView())
XCTAssertFalse(exists掃除cell())
}
func when_TODO名の欄に掃除と入力して登録ボタンを押すと() {
XCUIApplication().textFields["TODO名"].tap()
XCUIApplication().textFields["TODO名"].typeText("掃除")
XCUIApplication().buttons["登録"].tap()
}
func then_TODO一覧画面に遷移する() {
XCTAssertTrue(isInTodoListView())
}
func then_TODO一覧に掃除という項目が表示される() {
XCTAssertTrue(exists掃除cell())
}
テストを実行します。
失敗したのでRed
の状態に。
登録ボタンを押した際にTODO一覧画面へ遷移させ、入力された文字がTODO一覧に表示されるようにする実装を行います。
※実装手順は割愛
コミットを参照
テストを実行します。
Passしました。
これで全ての受け入れテストがPassしたので仕様が満たされたことになります。
実装や解説がだいぶざっくりでしたが、なんとなくATDDの流れが伝われば嬉しいです。
※現状CoreDataのデータ初期化をUITestの前処理で行う事ができなかったので2回目以降のテストが失敗します。
そもそもアプリとテストで別のデータソースを利用し、テストでは毎回初期化するべきだと思うのですが、iOS力が足りないので今回は解決できませんでした。
最後に
最後のデータベース周りの扱いに不安が残るのですが、iOSアプリでもATDDのサイクルで開発できそうだなと実感することができました。
ただ今回は単純で機能が少ないアプリの実装だったので良かったのですが、Cucumberのようなツールがないと自動受け入れテスト部分のコードの管理が大変になりそうだなと思いました。iOSアプリ開発でATDDやるのに便利なツール、ライブラリ等があれば教えてください。
久しぶりにiOS触れて楽しかった!