テスト駆動開発(TDD)の概念から本質まで深掘り

こんにちは。ONDAでホテル運営管理ソリューションを開発しているバックエンド開発者のGunner(ガナー、チョン・ジェフン)です。
前回の第1編では、ソフトウェアテストの種類を様々な視点から見てきました。BDDやTDDのようなテストツールにも触れましたが、TDDとテストが混同され、その意味が不明確になるケースが時折見られます。今回は、TDDの概念と開発者間で起きた論争の争点を理解し、開発者にとって良いソフトウェアテストとは何かを一つずつ掘り下げていきます。
1. テスト駆動開発(Test-Driven Development, TDD)とは?
まず、TDDの概念から見ていきましょう。TDDはケント・ベック(Kent Beck)の**『Test-Driven Development By Example』(以下、TDDBE)**に紹介されたソフトウェア設計手法で、テストを通じて開発を主導していく方法です。
*TDDをテスト手法と捉える場合も多いですが、個人的には設計手法により近いと考えています。
TDDBEが提唱するTDDの方法はシンプルです。
1. 失敗するテストをまず書き、 2. できるだけ早くそれを通過させるコードを書き、 3. リファクタリングする
本書では下の画像のように、テスト失敗時に表示される赤色と成功時の緑色から、**「Red-Green-Refactorサイクル」**と呼んでいます。

TDDBE本ではMoneyクラスを開発しながら通貨単位などを実装していくのですが、例えばドル($)基準で開発してテストが成功しても、別の通貨単位を適用すると失敗し、それを継続するためのクラスを作っていく過程が示されています。
失敗するテストを先に書きながらRed-Green-Refactorサイクルをデモンストレーションしているのですが、Test-Firstで作られた自動化テストは、Moneyクラスの設計方向を駆動(drive)するだけでなく、新機能追加時に既存機能が正常に動作するかチェックするセーフティネットの役割も果たします。
もう一つ注目すべき点は、TDDBEでケントが非常に小さなステップを踏んでいくことです。後にケントはあるインタビューで、_「必ず小さなステップを踏めという意味ではなく、良い設計のアイデアが浮かばないときに小さなステップを踏み、確信が生まれたら大きなステップを踏めるよう感覚を養うのが目的」_と述べています。
結論として、TDDBEが語るTDDの核心はRed-Green-Refactorサイクルであり、これを通じて良い設計と自動化されたテストを作り上げていくことです。

2. TDD、開発者たちの間で論争を巻き起こす
TDDが登場した後、開発者たちの間では多くの議論が交わされました。主に「TDDを使って開発すべき」と「TDDは実務に合わない」という意見が大半でした。
「TDDを使って開発すべき」という立場の開発者たちは、TDDから生まれる利点、つまりTest-Firstで進められる良い設計、開発と共に作られる自動化テストなどを主張しましたが、**「TDDは実務に合わない」**という立場の開発者たちは、Test-Firstで設計する方法に共感できないと主張しました。
(1) ケントはなぜTDDを提唱したのか
詳しい話をする前に、ケントがTDDを提唱した背景について少し見てみましょう。
ケントが主に使用していた言語は**「Smalltalk」**で、実際にこの言語を使って有名なプロジェクトであるC3を進めながら、XP(eXtreme Programming)も適用しました。SmalltalkはVM(Virtual Machine、仮想マシン)上で実行される言語で、開発環境自体を一つのIDE(Integrated Development Environment、統合開発環境)と見なせ、ソースコードが一つの統合イメージとして保存されるのが特徴です。

Smalltalkはオブジェクト指向的に作られた言語で、以下のようにTDDを適用するのに良い環境と要件を備えていました。
- コードブラウザからコードを直接実行できる。
- 無数の小さなオブジェクトが相互にメッセージをやり取りして作業するように設計されている。
- SmalltalkはMVCパターンの元祖だったが、MVCを設計する際に今のJavaプロジェクトのようにレイヤー概念でファイルを分けず、ソースコード全体を一つのイメージ形式で保存する。
- xUnitの元祖はSUnitである。
しかし、通常のJava Springプロジェクトはそうではありません。
- コードを実行するにはSpringコンテキストを起動する必要がある。
- Smalltalkと比較すると相対的にクラスをあまり分けない。
- DDD(Domain-Driven Design)、ヘキサゴナルアーキテクチャなどの影響でレイヤーを明確に設け、レイヤーごとにファイルを分離して別々に置く。これはテスト範囲が拡大することを意味する。
- JUnitはSUnitの移植版である。
SmalltalkはJavaのように「ファイル→クラス」の順で探せず、オブジェクト間の相互作用が多いため、これを自動化テストで確認するプロセスが必要だったと言えます。また別の言い方をすれば、Smalltalkはユニットテストを素早く実行し、即座のフィードバックを通じて開発するのに非常に良い環境でもあったのです。
(2) 「TDDは死んだ。テストよ永遠なれ」
2000年代初頭、TDD概念の登場とともに開発者間で多くの議論があり、先述のようにこれを積極的に受け入れるグループとそうでないグループに分かれました。そんな2014年、Ruby on Rails(*フルスタックWebフレームワーク、以降登場したほぼすべてのフルスタックWebフレームワークに影響を与えた)を作ったDHH(David Heinemeier Hansson)が自身のブログに**「TDDは死んだ。テストよ永遠なれ」**という挑発的なタイトルの記事を投稿し、TDD論争が始まりました。
DHHが主張した主な内容は以下の通りです。
-
TDDにはそれなりの有用性があるが、教条主義的な宗教に変わった。
-
TDDは単体テストに集中するあまり、不必要な複雑性(例:モック)をもたらすこともある。
◦ レイヤー間の中継オブジェクトが絡み合った複雑な構造
◦ テストでDBやI/Oを直接使うのを避ける → システムテストは悪いものとして扱われる
◦ サービスオブジェクトとコマンドパターン、そしてさらに悪いものが絡み合ったジャングルだ。→ テストのためだけのパターン乱用とオブジェクト設計
-
TDDが設計を主導することもあるだろうが、私の設計方法とはあまり合わなかった。
-
すべての依存関係をモック化し、数千のテストが数秒で終わるなど、伝統的な意味での単体テストを私はほとんどしない。
◦ Railsでは良いテストとは見なさない。
◦ 私はActiveRecordモデルを直接テストする。fixtureを作りDBに直接アクセスする。
◦ トップレイヤーにはコントローラーテストがあるが、私の場合Capybaraのようなツールを使って高レベルのシステムテストで置き換えることを好む。
-
全員が私のようにやるべきだという意味ではないが、TDDをしなければ良い開発ではないという雰囲気はやめよう。
(3) ケント・ベック vs. DHH、TDDに対する彼らの考え
この記事は当時TDDを支持するグループの反感を買い、オンライン上で大きな論争に発展しました。
しばらく論争が続いた後、最終的にマーティン・ファウラー(*『Refactoring』で有名なソフトウェア開発者)の主導でケント・ベックとDHHのオンライン討論が開かれ、当時の討論で彼らが交わした内容を要約すると以下の通りです。
TDDが言う単体テストの定義
-
DHH: TDDにおける単体テストの定義は何か? 必ずモックを使って単体テストをしなければならないのか? またRed-Green-Refactorサイクルは私に合う部分もあったが、全体的には合わなかった。
-
ケント: 昔からプログラマーが(何らかの方法で)テストするのは当然だった。Smalltalkでプログラミングしながら、私はこれを自動化し、Test-Firstを試みたが、私にはとても合っていた。
-
共通: もちろんTDDを適用することにはそれなりの有用性があるが、好みやプロジェクトの特性によって異なる可能性がある。
-
DHH: 多くの人が重いモックを使いながら悪いトレードオフをしている。なぜここまでモックを使って損をするのか?
-
ケント: モックを使うのはトレードオフであり、その点には同意する。もし実オブジェクトで速いフィードバックループを作れるなら実オブジェクトを使う。私もTDDをする際にモックをほとんど使わなかった(Smalltalkは Javaと違ってレイヤーを分けないため、モックを使わず直接テストできる)。
またテストを作り実コードを修正する際にテストコードも一緒に直す必要があるが、実コードが大きく変わるとそれに対応するモックもすべて変えなければならない。テストはリファクタリングをより簡単にする一方、モックはリファクタリングをより難しくする点が少し懸念される。
-
DHH: 無数のモックを使うレイヤードアーキテクチャは間接参照が多く、複雑度が過度だ。
-
ケント: これはTDDから生じる問題ではない。
-
マーティン: その通り。これはヘキサゴナル(Hexagonal)アーキテクチャの核心である「環境からの徹底的な分離」に起因する。
-
DHH: 同意する。ただし分離自体を目的に強調する人々をしばしば見かける。TDDで単体テストをするからだ。
ヘキサゴナルアーキテクチャを適用する人たちは、これ(ドメインモデル)をターミナルアプリケーションまたはコマンドラインで使えると言う。彼らに聞きたい。果たしてそんなことがあるだろうか?
リポジトリ(repository)も同様だ。DBにアクセスしていた実装をインメモリDBまたはWebサービスにアクセスするものに変えられると言うが、果たしてそんなことがあるだろうか。
-
ケント: これもTDDから生じた問題ではなく、設計やフィードバックをどれだけ早く受け取れるかの問題だ。設計上の問題は文字通り設計をよく考慮することが重要だ。
-
共通: 速いフィードバックは重要で、TDDがQAを代替することはできない。
すべてのコードにTDDをするのが正しいか? テストが多すぎたり不要なテストではないか?
-
ケント: 私も常にTDDをするわけではない。人々はテストコードを書くためのコストを払うのではなく、十分な確信を得るために書くのだ。
-
マーティン: 過剰なテストは明らかに存在する。もし私がこのコード行をコメントアウトしたらテストが壊れるだろうか? 重要なのは壊れそうなケースをテストにすることだ。getterのような箇所は信頼できるため、テストを付ける必要はない。
-
コードを自信を持って変更できない → テストが不十分または良いテストではないというシグナル
-
コード変更時にテスト変更がより大変そうだ → テストが多すぎるというシグナル
TDDはどの点で有用なのか?
- ケント: TDDは問題を小さな単位に分割し、自信を与える。
- マーティン: TDDを使うのに適した分野とそうでない分野がある。
- DHH: TDDに適した分野があるという言葉に同意する。個人的な経験上、MVC Webアプリケーションには合わない場合が多かった。しかしTDDが適さない分野でも、自動化されたセルフテストは必ず必要だ。それがTDDにおいて重要な価値だと思う。
結果的にTDDは良いツールだが、ある程度好みの問題であり、すべての分野に適したツールではないと結論づけられました。
💡TDDが好みの問題だという点について、Clojureを作ったリッチ・ヒッキー(Rich Hickey)の発言も見てみましょう。
リッチはこれを**「ガードレールプログラミング」**と呼び、_「テストがあるから修正できると言うが、誰がそうする? 誰が車をガードレールにぶつけながら運転する?」_とTDDのRed-Green-Refactorサイクルを批判しました。彼はTDD自体を批判しているのではなく、プログラムを論理的に分析できる能力が重要だ(熟考による分析がプログラム設計に適用されるべきだ)という主張を展開したのです。
つまり、TDDで開発したり、テストがすべて通過したからといって良いプログラムではないという意味です。これはTDDより先に述べた型チェッカーを皮肉る部分からも分かります。
リッチもまた自動化されたセルフテストの必要性は認めつつも、(その発言後にTDD支持者たちの攻撃が続くと)「もし自分が使うツールや方法論が特定の目的に合わないと批判されることが攻撃のように感じられるなら、リラックスする必要がある」と、方法論は単なるツールだと述べました。
(4) いまだ続くTDD論争から分かる真実
以前ほど活発ではないものの、いまだにTDD論争が続く理由は何でしょうか?
論争を詳しく見ると、TDDから生じた利益ではないにもかかわらず、TDDをすることで発生する利益と見なすケースが多くあります。例えば以下のようにです。
- TDDをしなければ自動化されたセルフテストを得られないのか? いいえ。
- TDDをしなければ回帰テストを得られないのか? いいえ。
- TDDをしなければ良い設計(*良い設計が何かはまた別の話だが)が生まれないのか? いいえ。
- TDDは仕様をコードで明確に表現するが、TDDをしなければ可能でないのか? いいえ。
TDDはRed-Green-Refactorサイクルで進められる設計方法で、明らかに有用性はあるが、誰にでも、あるいはすべてのプロジェクトに合うとは限りません。また論争でケントが述べたように、モックはTDDとは関係ありません。したがって、本質的に設計技法である「TDD」の有用性と「テスト」の有用性を明確に分離する必要があります。
3. 開発者にとって良いテストとは?
良いテストとは何かを考える前に、テストもトレードオフであるという事実を前提にしなければなりません。テストを作り保守する作業に開発者のリソースが投入されるからです。
またテストは明らかに開発の助けになりますが、マーティン・ファウラーが対談で述べたように、不足と過剰が存在します。では、開発者にとって良いテストとは何でしょうか?
(1) テストするコード vs. テストしないコード
良いテストは壊れやすい、つまり間違いやすい部分をテストするコードです。
壊れやすいコード
- 複数箇所から参照されるコード
- 核心ロジックコード
- 今後変更される可能性が高いコード
- QAまたは実サービス時に問題が発生したコード
このようなコードを判断するのは、単体テストを書くだけでは分かりにくい側面があります。このときBDDを活用すると良いです。Given/When/Thenコンテキストでテストケースを考えると、自然にそのオブジェクトが他のオブジェクトとどう関連するかが分かります。
逆に、ある領域は信頼できる部分だと判断し、テストしないと決定することも重要です。
壊れにくいコード
- 使われる箇所が多くないコード
- 明確で単純なコード
- ライブラリにある機能
ただし、テストしないコードを選択する絶対的な基準はありません。例えばライブラリコードは一般的にテストしない開発チームがあると仮定しましょう。特定のライブラリが頻繁に更新され、インターフェースや動作がよく変わるなら、そのライブラリを使用するコードにはテストを付けるのが良いでしょう。
つまり、テストしないコードと判断するのは、開発者が置かれた状況に応じて十分に考慮して判断すべきなのです。
(2) モック(mock)、必ず使うべきか?
テスト関連の論争を見ると、やたらとモックについての言及が多いです。
TDDのような方法論では基本的に単体テストを語り、単体テストは独立した環境でテスト対象単位の振る舞いを素早く検証できる必要があります。単体テストの定義によると、単位をテストするために必要なレイヤーはモックを通じて使用することが推奨されます。
もちろん妥当な話ですが、TDD論争でDHHはモックの弊害を強調し、ケントもTDDをしながらほとんどモックを使わなかったと述べました。さらにケントは、モックによってリファクタリングが難しくなる可能性があるという懸念を示しました。
根本的にモックを使う理由は**「独立した環境での速いテスト試行」**にあります。
しかし必ずモックを使わなければ正解という規則はありません。単にテストを構成する一つのツールです。
- 独立した環境: 複数のオブジェクト間の相互作用で働くオブジェクト指向システムで、モックを通じた独立したユニットテストは果たしてテスト対象を適切にテストする良いテストなのか?
- 速度: モックを使わず実レイヤーオブジェクトを使っても速度が遅くないなら、実オブジェクト使用が問題になるのか?
(3) 自動化されたセルフ回帰テスト
もう一つの「良いテスト」の特徴として、自動化されたセルフ回帰テストを挙げられます。各単語の意味を一つずつ見ていきましょう。
- 自動化(automated): あまりに当然で言及しませんでしたが、非常に重要な要素です。もしテストを実行するために別途作業が必要なら、テストを実行することが負担になるしかありません。
- セルフ(self-checking): 機能が自ら正常に動作するかテストできなければなりません。テスト結果を人が判断しなければならないなら、(当然ですが)それもまた仕事になりますから。
- 回帰(regression): 既存に正常に動作していた機能が今も正常に動作するか確認できなければなりません。
自動化されたセルフ回帰テストがすべてのコードの安全を保証することはできません。しかし熟考の末にテストが必要だと判断した部分をテストがカバーするなら、その部分については自信を持つことができます。
もう一つ付け加えると、TDDをしながら自然に自動化されたセルフ回帰テストが作られることはありますが、それがTDDの追求する目標ではありません。自動化されたセルフ回帰テストはTDDと関係なく作ることができます。
つまりTDDは有用な設計方法の一つであり、何よりも重要なのは壊れやすい部分を判断し、自動化されたセルフテストを精巧に作成する開発者の能力です。
実は開発者たちは、こうした議論があるずっと前からテストをしてきました。ログや画面、プリンターに出力された値を目で見て期待値と合うか確認し、時にはプログラムを実行するシェルスクリプトを作ってそのプログラムが望む値を出すか目でまたはシェルスクリプトコマンドで確認しました。ケントはTDDとSUnitを通じてこれを定型化された領域に持ち込み、それに伴う多くの論争も招きました。しかし論争の中でも、自動化されたセルフテストの有用性には誰も異論がありませんでした。優れた開発者なら、既にずっと前から何らかの方法でテストをしてきたからです。
参考
1. 翻訳書: http://www.yes24.com/Product/Goods/12246033, 原書: Test Driven Development: By Example: Beck, Kent: 8601400403228: Amazon.com: Books
2. Chrysler Comprehensive Compensation System - Wikipedia
3. JUnit Was Born on a Plane! – Tesla Tales (wordpress.com)
4. TDD is dead. Long live testing. (DHH)
5. "TDDは死んだ" - Railsを作ったDHHの記事 (sangwook.github.io)
6. TDD勉強中。Is TDD dead? (junho85.pe.kr)