どうも、よこけんです。
今日は、「みんなテスト駆動開発やろうよ」と宣ってみます。
TDD (テスト駆動開発) とは
TDD とは、ユニットテストを基点に開発を行なっていく設計手法です。
よく、TDD は品質保証のためのテスト手法だ、と誤解されがちですがこれは間違いです。
品質も結果的に高まりますが、決して主目的ではありません。
- テストは短期的なゴールを設定するために書かれます。
- ゴールに辿り着くことだけに集中してシンプルに実装を行います。
- ゴールに辿り着き命綱 (正解) を確保したら、安全にコードを、設計を、改善します。
シンプルさこそが TDD において最も重視される価値です。
What (目的) と How (手段) を分離して短いサイクルで繰り返すことで、物事をシンプルに考えられるようになります。
リファクタリングの正当性がテストによって保証されることで、継続的な設計、つまり進化的設計が可能となり、常にシンプルさを保つことができるようになります。
TDD の進め方
TDD は次の流れで進めていきます。
- TODO リストの作成
- TODO がなくなるまで以下を繰り返す
- 未達成の TODO を一つ選択する
- 選択した TODO を達成するまで以下を繰り返す
TODO リスト
TODO リストは地図のような役割を持ちます。
クラスやメソッド (或いは UI コンポーネントなど) を開発する上で必要となる事柄を全てリスト化し、消化していきます。全ての TODO が完了すれば、その開発対象が完成したことになります。
考えをシンプルにするため、TODO は一つずつ取り組んでいきます。一度に複数の TODO を取り扱おうとしないよう注意してください。
TODO リストのおかげで対応忘れの心配はありませんので、安心して一つ一つの TODO に集中できます。
書き方
TODO リストの書き方に細かいルールはありません。
TODO には主にテストすべき振る舞いを書きますが、それ以外のことでも、その開発対象を開発する上で必要なことであれば何でも記載して構いません。担当者への確認事項や後回しにするリファクタリング内容など何でもです。
また、一つの TODO に対して複数のテストケースが必要になっても構いません。
下記は TODO リストの一例です。
-
plus(a, b)
-
正常系
- 正の数
- 負の数
- 小数
-
異常系
- 最大値の境界値テスト
- 最小値の境界値テスト
-
その他
- 最大値・最小値の具体的な仕様を○○さんに確認
-
正常系
私はツリー形式を好みますがフラット形式でも構いません。
チームとしてルールやフォーマットを決めておくと良いと思います。
作成
TODO リストの作成時には必要な項目を一通り洗い出します。
ただし、その後いつでも追加、変更、削除を行えます。
TODO リスト作成中にコンポーネントの細分化や詳細設計を行わないよう気を付けてください。設計はリファクタリングフェーズを起点に行っていきます。(その結果、新たな TODO が追加されることは問題ありません。)
更新
TODO リストには、いつでも追加、変更、削除を行えます。
進行中の TODO に集中するためにも、逐一更新をかけましょう。
レッドフェーズ
レッドフェーズは、What、つまり「これから何を実装するのか」という「目的」を具体化するフェーズです。
テストケースを作成し、実行ができるだけの空実装を用意することで、テストが失敗することを確認します。
テストの失敗を確認することは非常に重要です。
テスト自体が間違っていないことを確認するという意味も勿論ありますが、それ以上に、目的を明瞭にするために必要なことです。
目的がブレると途端に複雑な迷路に迷い込みます。
『テストが失敗した。だから成功させる。』
このシンプルな考え方を徹底するために欠かせないルーティーンが、失敗の確認というわけです。
グリーンフェーズ
グリーンフェーズは、How (手段)、つまり What を実現するためにシンプルに実装を行うフェーズです。
余計なことをせず、シンプルに実装を行い、テストを成功させます。
振る舞いの追加はグリーンフェーズでのみ許可されます。シンプルな考え方を徹底するために、このことを忘れないでください。
グリーンフェーズは速やかに完了させなければいけません。というよりも、レッド状態は速やかに解消しなければいけません。
グリーンフェーズは、いわば霧の中手探りにゴールに向かって突き進んでいる状態です。ここで時間をかけて悩みながら迷いながらコードを実装した末にテストが通らなかったら、霧の中をまた引き返しどこで間違ったのか・そもそも考え方は合っていたのかを必死に探さなければいけなくなります。まさに迷走です。
グリーンフェーズは兎にも角にも、速やかにゴールすることが最重要というわけです。
そのためには、3+11の戦略を使い分ける必要があります。
戦略:仮実装
グリーンフェーズにおける必須条件はテスト成功だけです。テストが成功するのであれば、グリーンフェーズ完了時点では実装が不完全でも問題ありません。
迷わず短時間で実装する自信が無い時には、ロジックを組まず、固定値を返す仮実装を行うことで、テストを通してしまいます。
it("足し算ができること", () => {
expect(plus(1, 1)).toEqual(2);
});
function plus(a, b) {
return 2;
}
テストを通したら、リファクタリングフェーズでロジックを完成させます。
ただし、リファクタリングフェーズに進むことに不安がある場合は、グリーンフェーズを延長して戦略を三角測量に切り替えます。
戦略:三角測量
一つの振る舞いを、異なる2つ (以上) のシナリオでテストします。
これによりロジックの妥当性を高めることができます。
it.each([
[{ a: 1, b: 1 }, { result: 2 }],
[{ a: 1, b: 2 }, { result: 3 }],
])("足し算ができること [%#] (args=%o, expected=%o)", (args, expected) => {
expect(plus(args.a, args.b)).toEqual(expected.result);
});
function plus(a, b) {
return a + b;
}
戦略:明白な実装
迷わず短時間で実装する自信がある時は、仮実装ではなく最初から実装を行います。
it("足し算ができること", () => {
expect(plus(1, 1)).toEqual(2);
});
function plus(a, b) {
return a + b;
}
ただし、予想に反してテストが失敗してしまった (単純に修正できない) 場合は、戦略を仮実装に切り替えます。
戦略:撤退
仮実装も明白な実装も容易でないという時は、ゴールが遠すぎるということです。
その場合は一旦戦略的撤退を行い、下記のような視点でゴールの再設定を行ってください。
- テストケースを分割したり一部のアサーションをコメントアウト2することでゴールを短くできないか
- もっと簡単なテストに書き直せないか
- 一旦後回しにして他の (一部の共通実装を済ませられるような) アイテムを進めることで、結果的にゴールを短くできないか
リファクタリングフェーズ
リファクタリングフェーズは、コードや設計の改善を行うフェーズです。
既存 (テスト対象以外) のコード・設計も加味した上で、コード・設計をよりシンプルに洗練させます。
また、仮実装などの不完全なロジックを完成させるのもリファクタリングの一つとして捉えます。
リファクタリングフェーズでは命綱となる正解コードを得ていますので、後はテストが失敗しないように気を付けながらコードを変えていくことで、安全にリファクタリングができます。
それでもリファクタリングに不安がある場合は、三角測量によってテストを追加します。
リファクタリングにおいて特に重要なのは重複の除去です。
同じようなコード、同じようなロジック、同じようなクラスが既にある場合は共通化を行います。
それ以外にも、可読性の向上のため、コードの「不吉な匂い」を嗅ぎ付け、様々なリファクタリング技術を使用してコードを洗練させます。(リファクタリング技術については本記事では扱いません。書籍「リファクタリング」が詳しいです。)
リファクタリングフェーズでは振る舞いの追加は禁止です。TODO を追加して後で改めて対応を行います。
既存コードの変更や新規クラスの追加など大掛かりな対応が必要な場合も、TODO を追加して慎重に対応を行います。
よくある疑問
初期設計はやらない?
ウォーターフォール型開発のような、包括的で完全な初期設計はやりません。
詳細な UML を用意して保守していくようなこともしません。(要所要所で抽象的な UML を残すことはあります。)
進化的設計により、無駄のないシンプルな設計を徐々に導出していきます。
ただし、軽量な初期設計をすることはあります。
チームの作業開始がもたつかないよう、およその方向付けを行うことが目的です。
そして、初期設計には固執せず、あくまで TDD で設計を導いていきます。
将来必要になる仕様追加・変更のことは考えないの?
YAGNI 原則に従います。
"You Ain't Gonna Need It" (そんなもの必要にならないって)
- 「今」必要なことだけをやれば良い
- 「将来的にこう拡張されるだろう」という不確かな憶測で、余計な機能、設計、実装を用意してはいけない
ということです。
余計な物を用意しても、将来それがそのまま完璧に役目をこなすことなんて滅多にありません。大抵の場合は「そのままでは要求にフィットしない」「そもそも必要にならなかった」となります。
まだ必要となっていないものが紛れ込んでいると下手に手を加えられませんし邪魔物以外の何物でもありません。
シンプルイズベスト、つまり不要です。
TDD の基礎をしっかり守っていれば、YAGNI 原則は自ずと適用されます。
- 開発対象を完成させるために TODO を用意する
- TODO にないことはやらない
- TODO を消化するためにテストケースを用意する
- テストを成功させるためだけに実装を用意する
- コードや設計を洗練させるためにリファクタリングする (振る舞いを追加しない)
テストはどういう単位で用意していくの?
基本的には、全てのクラス (の、privateでないメソッド) に対して用意することになっています。
ただし、必ずそうしなければならないわけではなく、色々なパターンがあります。
- 全てのクラスに対して用意する
- モジュール外に公開されるクラス・メソッドに対して用意する
- 特定の上位レイヤー内のクラスに対して用意する
下位モジュールや DB に依存している箇所をどうやってテストすれば良い?
モックを使用して切り離します。
モックはどの言語でも大抵ユニットテストフレームワークに備わっているかと思います。3
モックを使うことで、呼び出された回数や渡された引数を検証したり、振る舞いをテストケースから細かく制御したりできます。
テストコードのリファクタリングは必要?
テストコードの可読性は重要です。適宜リファクタリングをした方が良いです。
ただし、製品コードとテストコードではリファクタリングの観点がやや異なります。
製品コードでは設計を俯瞰的に理解しやすいよう、構造化・部品化を積極的に行いますが、テストコードでは局所的な理解に時間が取られないよう、構造化・部品化は消極的に行います。for 文などのロジックの組み込みについても消極的です。コードの見通しの良さを重視し、できるだけ一目で理解できるよう心がけるのが良いです。
似たようなパターンが続くとき一つ一つ丁寧にやってると面倒なんだけど?
似たようなパターンが続いてやるべきことが明白な時は加速をかけても構いませんが、プログラミング初心者や TDD 初心者はやらない方が良いかと思います。
加速する場合も要所要所でやはり慎重さが求められます。
例えば本記事では足し算を行う plus(a, b) 関数をサンプルに使っていましたが、この次に引き算を行う minus(a, b) 関数を開発する場合、作業のほとんどは plus(a, b) の時と同じになります。
TODO リストをコピペで作成しても構いませんが、引き算特有の追加 TODO が無いかはしっかり検討する必要があります。(引き算だと特に無いかもしれませんが、割り算だとゼロ除算の考慮が追加で必要ですよね)
テストコードも全てコピペして一括置換で plus を minus に書き換えパラメータだけ調整するでも良いですが、空実装を用意してテスト失敗を確認することはしっかり行うべきです。
参考
-
書籍「テスト駆動開発」では3つの戦略とされていますが、本記事ではこれに「撤退」の戦略を付け加えています。 ↩
-
一つのテストケースに複数のアサーションを設置することは、アサーションルーレットというアンチパターンとされています。アサーションのコメントアウトよりもテスト分割を積極的に検討しましょう。 ↩
-
C++ は微妙ですが。Google Mock があるけどモック対象の I/F をトレースしないといけないのがツラい。 ↩
-
私が直接読んだのは、2003年に刊行された旧版となります。 ↩
-
2004年に刊行された本のため、今となっては特にツール周りの情報が古いとは思いますが、会話形式で TDD を楽しく学べる良書です。 ↩