テスト駆動開発の第一部をSwiftで写経してみた

これはなに?

  • 社内でテスト駆動開発(Test Driven Development = TDD)の勉強会を行った
  • これはそのときの発表資料です
  • 自分はiOSエンジニアなのでSwiftでやってみた
  • 参加した同僚達は Go, Ruby, Dart, Elm などで写経している
  • 第一部を写経する前に「付録C 訳者解説:テスト駆動開発の現在」を先に読むのをオススメする
  • 社内勉強会では、付録Cの概要をみんなで共有してから第一部の写経に進んだ

Swiftで写経

  • Swiftで出来るだけ書籍通りに書いてみた
  • 出来るだけ同じように書かないと写経できなくなるので、Swiftでは非推奨な書き方もある
    • そこは後述で補足する
  • コミットは書籍でテストを走らせたタイミングや、ある程度意味のありそうな塊できった
    • 電子書籍だからページ番号がフォントサイズで変わるので、どの章のコミットかわかるようにメッセージの先頭に「第n章」とつけた
  • TDDの説明ではなく、JavaとSwiftの言語仕様の違いが際立ってしまった
    • 写経することがTDDを理解することの近道なので写経がまだの人は写経してください
    • TDDの背景や文脈は付録Cに書かれている

第1部の内容

  • 2つの仕様を満たすプログラムをTDDで開発していく
// ① 金額に数値を掛けて金額を算出する
$5 * 2 = $10
// ② 異なる2つの通貨を足し、為替レートに基づいて換算された金額を算出する
$5 + 10CHF = $10 (レートが USD:CHF = 2:1 の場合)  
  • 書籍では米ドル(USD)と瑞西フラン( CHF)を例にしている
  • 書籍ではJavaで書かれている
  • 第1部は全部で17章
  • 最後の17章はまとめなので、実際は16章まで

第1〜16章のコミットを追いながら説明

  • git log --oneline --reverse --pretty=format:"%s" でコミットメッセージを章ごとに表示
  • 書籍どおり、章の終了時点のコードがわかるようにGitHubのリンクを貼っておく
  • JavaとSwiftの言語仕様の違いとか、TDDの特徴で気になった点に注目していく

第1章

第1章 MoneyTestを作成
第1章 Dollarクラスを作成
第1章 Dollarクラスにイニシャライザ作成
第1章 Dollarクラスにtimesメソッド作成
第1章 Dollarクラスにamountプロパティを作成
第1章 テストを通すためにamountの初期値を10にする
第1章 重複の削除のためにamountの値を具体的にする
第1章 Dollarのイニシャライザでamountプロパティの初期値を設定する
第1章 timesの重複を削除するために5をamountに変更
第1章 timesの2のベタがきをmultiplierに変更
第1章 *=演算子をつかって重複を削除

第2章

第2章 Dollarの副作用でオブジェクトの状態が変更されてしまうが理想のテストを書く
第2章 もしもtimesメソッドから新しいオブジェクト返るなら
第2章 timesから新しいDollarオブジェクトを返せるような実装に変更する
第2章 timesからDollarオブジェクトを返す

第3章

第3章 equalsメソッドのテストを作成
第3章 equalsメソッドを作成してとりあえずtrueを返す
第3章 2つ以上の実例を作るために「$5 != $6」のテスト作成

第4章

第4章 Dollar同士を比較するようにしてテストの意図を明確にする
第4章 もう一方も同じようにDollar同士を比較する
第4章 一時変数のproductは不要なので削除してインライン化する
第4章 amountプロパティはprivateにして自分自身しか参照しなくて良くなった
第4章(番外編) Javaのequalsに相当する、Equatableの「==(lhs: Self, rhs: Self) -> Bool」を実装してテストを通す

第5章

第5章 ひとまずDollarのテストをコピーして、Francのテストを作成
第5章 DollarクラスをコピーしてFrancクラスを作成
  • 第5章終了時のソース
  • ①テストを書く→ ②コンパイラを通す→ ③テストを走らせ失敗を確認する → ④テストを通す → ⑤重複を削除する というサイクルで進めたい
  • ①~③を早く終わらせて④に出来るだけ早く着手したいから、DollarのテストとクラスをコピーしてFrancを作る
  • 要は、ただDollarコピペしてFrancにリネームしただけ

第6章

第6章 親クラスとしてMoneyクラスを作成
第6章 親クラスのMoneyにamountプロパティを引き上げる
第6章 Francの等価性のテストを忘れてたので追加する
第6章 FrancクラスもMoneyクラスを継承する
第6章 親のMoneyクラスにあるのでamountプロパティを削除
第6章 equalsメソッドのキャスト型をMoneyに変更する
第6章 Francクラスのequalsメソッドも親クラスのMoneyクラスに引き上げる

第7章

第7章 DollarとFrancは別の通貨であるというテストを追加
第7章 金額だけでなく、オブジェクトの型も同じなら等価とする
  • 第7章終了時のソース
  • 第6章の作業中に生じた疑問「DollarとFrancを比較するとどうなるのか?」をテストしてみる
  • 作業中に生じた疑問をテストにして確認するサイクルを紹介
    • 「あの条件・状態の時、どうなる?→テストコードで実験」という流れ
    • テストがあるとこういうの楽
  • ひとまずテストを通すようにコードを修正

第8章

第8章 timesメソッドの返値をDollarとFrancで同じにする
第8章 DollarのFactory MethodをMoneyに作成する
第8章 Dollarクラスへの参照を減らすために型宣言を親クラスのMoneyに変更する
第8章 DollarのFactory Methodの返り値の型をMoneyに変更する
第8章 Dollarを参照している箇所をFactory Methodに置き換える
第8章 Dollarと同じくFrancもFactory Methodを作成してFrancの参照をなくす

第9章

第9章 通貨(Currency)のテストを追加する
第9章 通貨名をサブクラスに格納する
 - Swiftではクラス内に同名で同型のプロパティとメソッドは定義できないので_を接頭辞につける
第9章 currencyを親クラスのMoneyに引き上げる
第9章 Francのイニシャライザで通貨名を渡せるようにする
第9章 Francのイニシャライザを呼び出している箇所のエラーを解消する
第9章 自身のイニシャライザを呼ばずに、親クラスのFactory Methodを呼ぶ
第9章 FrancのFactory Methodから通貨名を渡すように変更する
第9章 イニシャライザのcurrencyをプロパティに代入する
第9章 Francと同じ変更をDollarにも行う
第9章 サブクラスのイニシャライザを親クラスに引き上げる
  • 第9章終了時のソース
  • DollarとFrancのクラス自体を消し去りたいので、通貨(Currency)の概念を導入したい
    • じゃあ通貨(Currency)をMoneyに持たせて...とするのではなく、Moneyが通貨を持った状態のテストを先に書く
    • 書いたテストが成功するような実装を書いていく
    • 同じサイクルの繰り返し

第10章

第10章 timesメソッドで返すクラスを自身のイニシャライザを使って行う
第10章 ベタ打ちではなく、currency変数に置き換える
第10章 Franc型とMoney型の区別が必要なのか判断するためにtimesでMoneyを生成して返すように変更してみる
第10章 Moneyのtimesを抽象メソッドではなく具象メソッドに変更する
 - Swiftの場合、XCTAssertEqual failed: ("Optional(TDDForSwift.Franc)") is not equal to ("Optional(TDDForSwift.Money)") のエラーになる
第10章 エラーメッセージを少しマシなものにするためにデバック出力を編集する
 - SwiftはCustomStringConvertibleのdescriptionで、JavaのtoStringと同じ処理を実現できる
第10章 エラーになる前の状態に戻す
第10章 FrancとMoneyが等価であることをテストする
第10章 equalsメソッドでクラスではなく、通貨名を判定するように変更する
第10章 再びFrancのtimesメソッドでMoneyを返すように変更してテストを通す
第10章 Francと同じようにDollarもtimesでMoneyを生成して返すように変更する
第10章 timesメソッドを親クラスのMoneyに引き上げる

第11章

第11章 Factory Methodでサブクラスを初期化する必要はなくなったのでMoneyで初期化する
第11章 Dollarクラスを参照するコードはないので削除できる
第11章 等価性比較の過剰なテストは削除する
第11章 Francクラスの動作を確認するだけのテストとなったため、Francクラスと同時に削除する
第11章 掛け算の振る舞いをクラスごとにテストする必要はないので、不要な方の掛け算の振る舞いテストを削除する
  • 第11章終了時のソース
  • 子クラスを削除する準備が整った
  • 子クラスを削除して、過剰なテストを削除する(重複の削除)

第12章

第12章 足し算のテストを追加
第12章 plusメソッドを実装する
第12章 為替レートをもとに銀行が通過を換算(reduced)するようなテストを作成する
第12章 銀行を初期化する
第12章 2つの和を表現するExpression(式)を作成する
第12章 5ドルを作成して、$5 + $5 のテストを完成させる
第12章 Expressionを作成する
 - SwiftはprotocolでJavaのinterfaceを表現する
第12章 plusメソッドはExpressionを返すように変更する
第12章 MoneyをExpressionに準拠させる
第12章 Bankクラスを作成する
第12章 空のreduceメソッドをBankに実装する
第12章 ひとまずテストを通すためにreduceで10ドルを返す
  • 第12章終了時のソース
  • 掛け算はできたので、次は足し算の実装をスタート
  • やることは掛け算のときと同じサイクル
  • 為替レートの概念が複雑で熟考を要する
  • 12章から複雑になる
    • コミットの数が細かく章ごとのコミット数が多くなっていく
    • Expression(式)を思いつく人と、そうでない人に大きな差があるような気がしてならない(Kent Beck(筆者)も17章で式のメタファーを思いつくまでに多くの時間とプロセスを要したと述べている)
    • つまり、Expression(式)を思いつくまでテスト書いて実験するサイクルを繰り返せてことのようだ
    • 先にExpression(式)が出たけど、DollarができてMoneyができたのなら、SumができてExpressionじゃない?と思った
    • ドルとフランしか表現できなかったときがあったように、足し算しかできないBankができてもこの時点ではよかったのでは?

第13章

第13章 足し算のaugend(被加算数)とaddend(加数)のテストを書く
第13章 Sumクラスを作成する
第13章 plusメソッドでSumを返す
第13章 Sumクラスにイニシャライザを定義する
第13章 SumクラスにExpressionを準拠させる
第13章 Sumのイニシャライザでプロパティに値を代入してテストを通す
第13章 Bankのreduceのメソッドで足し算の結果をテストする
第13章 BankのreduceメソッドでSumのプロパティを足し合わせた値を使ってMoneyを生成して返す
第13章 BankのreduceをSumクラスへ移動する
第13章 BankのreduceメソッドにMoneyを渡したときのテストと、Moneyにキャストする処理をreduceメソッドに追加する
第13章 Moneyにもreduceメソッドを定義する
第13章 MoneyにもSumにもreduceメソッドがあるので、Expressionにreduceを定義する
 - Javaはinterfaceのメソッドはpublicになるが、Swiftはデフォルトのinternal(同モジュール内まで公開)で良い
第13章 Expressionにreduceを引き上げたのでキャストや型チャックが不要になったので削除する

第14章

第14章 2フランを1ドルに換算するテストを書く
第14章 ビルド通すためにBankにaddRateメソッドを空で実装する
第14章 フランをドルに換算するテストを通すために、Moneyのreduceメソッドでレートを計算する
第14章 為替レートをBankで管理できるようにreduceの引数にBankを追加する
第14章 Bankで為替レートの計算を行うrateメソッドを定義する
第14章 為替レートの計算をBankに任せる
第14章 配列の中身を等価だと扱ってくれるのかテストする
 - Swiftは等価だと判断するが、Javaは等価だと判断しない
第14章 先ほどのテストは削除して、Pairクラスを作成する
第14章 Pairクラスをハッシュのキーとして使うために必要なメソッド定義する
 - SwiftはHashableに準拠すれば辞書型(ハッシュ)のキーに使用できるクラスになる
第14章 Bankで為替レートを格納するローカル変数を定義する
第14章 為替レート設定時にレートを格納する
第14章 為替レートを検索されたときに対象のレートを返す
 - Swiftの辞書型(ハッシュ)から値を取り出すとき、値は必ずオプショナル(?)なので暗黙的アンラップ(!)する
第14章 同じ通貨を換算した場合にエラーになるので、同じ通貨を為替レートから検索する場合のテストを追加する
第14章 同じ通貨の場合は為替レートは等倍なので1を返す

第15章

第15章 異なる通貨を足し算して、どちらかの通貨に換算して結果を出すテストを書く
第15章 ひとまずビルドを通すために、Money型で宣言するがテストは通らない
第15章 Sumクラスで被加算数(augend)と加数(addend)を換算してテストを通す
第15章 被加算数と加数の型をExpressionに変更する
第15章 さらにイニシャライザの引数の型もExpressionに変更する
第15章 Moneyのplusメソッドの引数もExpressionに変更できる
第15章 Moneyのtimesメソッドの返り値の型もExpressionに変更できる
第15章 テストケースもMoneyのplusメソッドに渡す型をExpressionに変更する
第15章 fiveBucksの型もExpressionに変更する
第15章 Expressionにplusメソッド引き上げる
第15章 Expressionに準拠しているSumにもplusメソッドを定義する
 - 書籍どおりnilを返したいが、オプショナル型になると別メソッドと認識されるので、addedをとりあえず返す
第15章(番外編) 強制キャストしてMoneyにしないとテストが通らない
 - Swiftの==演算子はEquatableに準拠する型しか比較できず、Expressionはプロトコルなためできない

第16章

第16章 Sumクラスのplusメソッドのテストを書く
第16章 SumクラスのplusメソッドからSumを生成して返す
第16章 Sumクラスのtimesメソッドのテストを書く
第16章 SumクラスのtimesメソッドもSumを生成して返す
第16章 被加算数と加数はExpression型にしたので、Expressionにもtimesメソッドを引き上げる

第17章

  • 1〜16章までTDDのサイクルを以下のように表現してきた
    • ① 小さいテストを追加する
    • ② すべてのテストを動かし、失敗があることを確認する
    • ③ 変更を行う
    • ④ 再びすべてのテストを動かし、すべて成功することを確認する
    • リファクタリングを行い重複を除去する
  • TDDを教えるたびに著者は3つのことに驚く
    • テストを綺麗に機能させる3つのアプローチ。仮実装、三角測量、明白な実装
    • テストとコードの間の重複除去が設計を駆動する
    • テストの間のギャップを制御する能力。路面が滑りやすいならグリップを増し、路面が良いならより速く

まとめ

  • 第1部の総コミット数は122でした
  • 写経するのが一番理解が早いと実感した
  • Xcodeで全てが完結するのでTDDの写経は楽だった
  • TDDの問題ではないがテストが欲しい大きなプロジェクトほどiOSではビルドに長い時間がかかるのでスムーズにTDDができず歯がゆい