TDD(テスト駆動開発) - アプリケーションを設計するために実装前にテストを書く
一般的に自動テストは処理の実装後に記述します。しかし、通常の実装順とは逆に自動テストを書いた後にテスト対象を実装し、さらに自動テストを利用して絶えずリファクタリングをする手法が存在します。これをTDD(Test-Driven Development/テスト駆動開発)といいます。
TDDはSUnitというテストフレームワークを開発したKent Beck氏が考案した手法であり、日本では主に和田卓人氏が書籍の翻訳(いわゆるTDD本)や記事の執筆、講演活動などを通して開発者に広めている手法です。
TDDは単なるテストファーストではない
TDDはテストファーストとよく混同されます。それは、実装前にテストを書くことが両者に共通した特徴だからです。
では両者の相違点は何でしょうか。その答えはテストファーストとTDDの実装方法の違いに隠されています。FizzBuzzを例に解説します。
テストファーストはテスト対象を十分に分析してテストケースを網羅的に記述した後に、初めて実装に取りかかります。このため、テストファーストの場合は以下のテストケースに対して自動テストを全て用意した後に、初めてFizzBuzzの実装に取りかかります。
-
3の倍数を渡すと「Fizz」を返す
- 3を渡すとFizzを返す
- 6を渡すとFizzを返す
-
5の倍数を渡すと「Buzz」を返す
- 5を渡すとBuzzを返す
- 10を渡すとBuzzを返す
-
3と5両方の倍数を渡すと「FizzBuzz」を返す
- 15を渡すとFizzBuzzを返す
- 30を渡すとFizzBuzzを返す
テストファーストの問題点は、テスト対象を分析すること、網羅的なテストを実装すること、テストを全て通る処理を実装することがフェーズに分かれてしまっていることです。一つひとつのフェーズを完了させるためには時間がかかります。また、実装が完了した後に分析漏れが発覚した場合、一度完了したフェーズに戻って最初からフェーズからやり直すとまた時間がかかってしまいます。TDDはこれらの課題を解決しています。
TDDは実装手法ではなく設計手法である
TDDの進め方は以下のように定型化されており、誰でも気軽に始めることができます[3]。
- 目標をTODOリストとして書き出す
- TODOリストから一つピックアップし、テストを書く
- テストコードを実行して失敗させる(レッド)
- 実装コードを書く
- できる限り最短でテストが通るコードを実装する(グリーン)
- コードの重複を除去する(リファクタリング)
- 目標の考慮漏れがあればTODOリストに追加する
- 次のTODOを選び、2に進む
TDDはテスト対象の分析、テストの記述、実装、そしてリファクタリングをそれぞれ小さいステップに分割し、それらをサイクルとみなします。そして、TODOリストが全て完了するまで何度もこのサイクルを繰り返すのです。なお、ここでいうTODOリストは上記のテストケース群を指します。
FizzBuzzを一から実装する場合、まずは「3を渡すとFizzを返す」というテストを書いて実行します。
test('3を渡すとFizzを返す', () => { expect(fizzBuzz(3)).toBe('Fizz') })
テスト対象はまだ存在しないため、テストは失敗します(レッド)。そこで、テストを通す(グリーン)ために3が与えられたらFizzを返すだけの処理を実装します。
export function fizzBuzz(num: number) { if (num === 3) return 'fizz' }
次にリファクタリングを検討します。今回はできることがないので次のTODO「6を渡すとFizzを返す」を選んでテストを実装します。
test('3を渡すとFizzを返す', () => { expect(fizzBuzz(3)).toBe('Fizz') }) test('6を渡すとFizzを返す', () => { expect(fizzBuzz(6)).toBe('Fizz') })
これらを実行し、テストが失敗したことを確認した後に次の処理を実装します。
export function fizzBuzz(num: number) { if (num === 3) return 'fizz' if (num === 6) return 'fizz' }
ここでリファクタリングが可能か検討します。3と6はともに3の倍数なので、以下のようにリファクタリングできます。
export function fizzBuzz(num: number) { if (num % 3 === 0) return 'fizz' }
リファクタリングが完了したので、次の「5を渡すとBuzzを返す」というTODOを選択し、これまでと同様のステップで実装を進めます。
このようにTDDは分単位で完了する小さいサイクルを回してインクリメンタルに実装を進める手法です。テスト対象が実装されていない状態でテストを記述するため、そもそもテスト対象のクラス名やメソッド名は何にするのか、メソッドの引数はどうするのか、テストが通るようにどう実装するのか、また実装後にリファクタリングは可能かといった考慮をします。つまり、設計をするポイントがあらゆるステップに存在するのです。
TDDは、テスト対象の実装前には命名などといった設計を、テスト対象の実装後にはリファクタリングといった再設計をその活動の中心に据えているのです。これこそTDDが設計手法であるとされる所以です(※)。
※ エンジニアが学習するべき物事を順番に紹介しているDeveloper RoadmapsのBackendエンジニア向けのマップでは、テスト駆動開発(Test-Driven Development)がGoFのデザインパターンやドメイン駆動設計(Domain-Driven Design)と同様に開発と設計の原則(Design and Development Principles)に分類されています。
TDDはテスト手法ではなく設計手法である - TDDのテストの限界
TDDは自動テストを活用した設計手法です。しかし、TDDはテスト技法ではありません。TDDで開発して内部品質を高めたとしても、バグが混入する可能性を否定できないのです。ここにTDDで作成するテストの限界があります。
極端な例ですが全てのコードをTDDで実装した場合、全ての実装に対してテストが書かれているためテストカバレッジは100%になります。しかし、テストカバレッジが100%であってもバグがあるかもしれないことを、アジャイルテストの4象限を参考に解説します。
アジャイルテストの4象限の図によると、テストには開発を支援するテスト(左側)と製品を批評するテスト(右側)に分けることができます。左側のテストは開発を支援するテストであり、TDDで活用するテストは特に左下のQ1のUnit Testsと左上のQ2のExamplesです。
このため、TDDで実装してテストカバレッジが100%のソフトウェアであっても、右側のQ3の探索テストや受け入れテスト、Q4のパフォーマンステストやセキュリティテストといった製品を批評するテストを実施するとバグが出る可能性があるのです。
例えば、TODOリストで考慮できていないテストケースがある場合、バグが起きる可能性があります。上記のFizzBuzzの例では関数の引数に必ず数値が渡ってくることを前提としていますが、3と5の倍数以外の値が渡された場合のTODOが漏れています(このケースはあえてTODOリストから外していました)。受け入れテストで1という数字が渡されるとおそらくバグが発生するでしょう。
TDDで書くテストは、積極的にバグを見つけるという本来的な意味のテスト(testing)ではなく、開発者自身が用意したテストケース群(examples)に限って問題がないことを確認する(checking)ためのテストなのです[4]。また、開発者が想定した入力例と出力例に基づいて設計と実装を進めるというTDDの特徴から、TDDはExample-Guided Developmentの一種であると指摘されています[5]。
ただし、TDDを採用してもバグが出る可能性があるからといってTDDを諦める必要はありません。TDDはそもそも設計手法でありテスト手法ではないのですから、TDDに対する過信を捨て、内部品質を高める設計手法として適切に活用すれば十分なのです。
TDDと自動テストの関係をまとめると、TDDは自動テストを使った設計手法です。そして、その自動テストは積極的にバグを見つけるテストではなく確認(checking)のためのテストであることに留意が必要です。
最後に
連載の最後に、前回紹介したDevOpsの考え方を参照しながらリファクタリングとTDDをソフトウェアの全体像の中に位置付けます(図はContinuous Testing in DevOpsより引用)。
リファクタリングはリリース前の開発フェーズで実施されることが一般的です。また、Releaseしたあと一周回って八の字の左側に返ってきた時に、Planのステップで計画されることもあるでしょう。よって、リファクタリングは(Plan)→Code→Test→Refactoring→Test→Mergeという流れになります(以下の図は筆者が改変したものです)。
一方、TDDはテストを先に書いてから実装に着手します。このためリファクタリングのフローとは異なり、(Branch)→Test→Code→Refactoring→Test→...→(Merge)というサイクルを繰り返すことになります。
TDDは自動テストとリファクタリングを活用して、ソフトウェア開発におけるCodeという1ステップの中で小さくて着実なサイクルを回しているのです。
本連載では3回にわたりソフトウェア開発における自動テストの位置付けとその活用方法を紹介してきました。本連載が読者の方々にとって自動テストに対する理解を深めるきっかけとなり、ユーザーをバグから守る堅牢なコードを書く一助になれば幸いです。