TDD
テスト駆動開発 (TDD) 徹底解説
1. はじめに
テスト駆動開発 (Test-Driven Development、以下 TDD) は、ソフトウェア開発における「テストを先に書く」ことを中心に据えた開発スタイルである。単に「自動テストを書く」ことを意味するのではなく、テストを通じて設計を駆動 (drive) し、コードがどのように振る舞うべきかを実装の前に明確にすることを核とする。本稿では TDD の歴史、基本サイクル、哲学、学派の違い、アーキテクチャとの関係、テストダブル、設計原則、CI/CD への統合、そして実際のコード例まで、A4 で約 30 ページに相当する分量で踏み込んで解説する。
1.1 TDD が解決しようとしている問題
TDD が登場する以前、多くの開発現場では次のような問題が常態化していた。
- テストが後付けされる: 実装が一段落した後にテストが追加される。実装に都合の良いテストばかりが書かれ、本当に検証したい振る舞いを見逃す。
- 回帰バグが増殖する: テストを書く文化がないため、ある修正が別の機能を壊す。テストが手作業に依存しているため、リリース直前に大量のバグが発見される。
- 設計が「あとで考える」になる: 実装を進めながら設計を固めるため、責務の境界が曖昧で、巨大なクラスや手続き的な巨大関数が生まれる。
- リファクタリングが怖い: テストがないコードに手を入れると、何が壊れるか分からない。結果として、技術的負債が蓄積し続ける。
TDD はこれらの問題に対し、「テストを先に書くことを強制する」という極めてシンプルなルールで応答する。テストを先に書くことで、
- 「本当に必要な振る舞い」が実装前に言語化される。
- テストが書きやすい設計を選ばざるを得なくなる (= 結合度が下がる、責務が分離される)。
- すべての本番コードはテストに守られた状態で生まれる。
- リファクタリングが安全に行えるようになる。
1.2 TDD は「テスト技法」ではなく「設計技法」である
本稿で繰り返し強調する重要な主張がある。TDD は QA の仕事ではなく、開発者の設計手段である。Kent Beck や Robert C. Martin (Uncle Bob)、GeePaw Hill、Sandro Mancuso といった TDD の主要な提唱者が一貫して述べているのは、「TDD で得られる最大の成果は、最終的なテストの存在ではなく、テストを書く過程で得られる『設計のフィードバック』である」という点である。
テストが書きにくいと感じたとき、それは多くの場合、設計に問題があるサインである。例えば、
- セットアップが膨大 → コラボレータが多すぎる、責務が分離できていない
- モックの設定が複雑 → 副作用と純粋ロジックが混在している
- 一つのメソッドに多数のテストが必要 → 単一責任原則が破られている
- グローバル状態のリセットが必要 → 純粋関数で書けるはずの処理が状態を持っている
TDD はこのような「設計のにおい」を、コードを書く瞬間にフィードバックとして与えてくれる。本稿は、この観点を起点として TDD を立体的に理解することを目指す。
1.3 本稿の構成
本稿は次の流れで TDD を解説する。
- 歴史的背景と Kent Beck の原典
- Red-Green-Refactor の基本サイクルと「3 原則」
- TDD の哲学と設計面での効用
- テストの種類とテストピラミッド
- Detroit 派と London 派という二大学派
- ヘキサゴナル/ポート&アダプタとの相性
- テストダブル (Mock/Stub/Fake/Spy/Dummy) の正確な分類
- TypeScript + Vitest による具体的な TDD カタの実例
- SOLID 原則との関係
- リファクタリング技法
- CI/CD との統合
- カバレッジとミューテーションテスト
- アンチパターン
- BDD・PBT・Approval Testing との比較
- チームへの導入戦略
- ツールとエコシステム
各章では、可能な限り具体的なコード例と図を交えながら、「なぜそうするのか」を含めて説明する。
2. 歴史と背景
2.1 起源 — Kent Beck と XP
TDD の現代的な定式化は、Kent Beck によって 1990 年代後半から 2000 年代初頭にかけて行われた。Beck は Smalltalk コミュニティ出身で、自身が設計した最初の単体テストフレームワーク SUnit (1994) を皮切りに、Erich Gamma と共同で Java 版 JUnit を開発した。これが今日の xUnit 系列フレームワークの祖となる。
Beck はまた、Extreme Programming (XP) の主要な提唱者でもあった。XP は 1996 年の Chrysler Comprehensive Compensation (C3) プロジェクトで体系化され、ペアプログラミング、継続的インテグレーション、リファクタリング、シンプル設計、そして Test-First Programming を中核プラクティスに据えた。TDD の最初の名前は「Test-First Programming」であり、これが後に「Test-Driven Development」へと発展した。
2.2 原典『Test-Driven Development: By Example』(2003)
TDD を世に広く知らしめた書籍は、2003 年に Kent Beck が著した Test-Driven Development: By Example である。本書は二つのパートから成る。
- Part I — The Money Example: 多通貨の金額計算を題材に、Java で TDD のサイクルを 1 ステップずつ実演する。Beck が「To Do リスト」をどう更新し、何を最小実装し、いつリファクタリングするかが、実際のコード差分とともに示されている。
- Part II — The xUnit Example: テストフレームワーク自体を TDD で作る。「テストフレームワークをテストする」というメタ的な題材で、xUnit ファミリーの設計原理を示している。
この書籍が定義した語彙 (Red, Green, Refactor、To Do リスト、Triangulation、Obvious Implementation、Fake It Till You Make It など) は、今日の TDD コミュニティで標準的に使用される。
2.3 派生と発展
TDD は登場後、複数の方向に派生・発展した。
- 2002〜2006 — London School / Mockist: Steve Freeman、Nat Pryce、Tim Mackinnon らがロンドンの XP 道場で発展させたスタイル。インタラクション (協働オブジェクト間のメッセージング) を中心に据え、モックを積極的に使う。後に Growing Object-Oriented Software, Guided by Tests (GOOS, 2009) として体系化される。
- 2003 — Mock Roles, Not Objects: Mackinnon らが OOPSLA で発表した論文。モックは「実装の代わり」ではなく「役割 (role) の発見」のためにあると主張。
- 2006 — BDD (Behavior-Driven Development): Dan North が「TDD で開発者が困る点」(何をテストすべきか分からない、用語が技術的すぎる) を解決するために提唱。
should、given/when/then、ユビキタス言語を取り入れた。 - 2008〜 — ATDD/Specification by Example: Gojko Adzic らによる、ビジネス側のステークホルダと開発者の協働を目指す手法。Cucumber、FitNesse などのツールが登場。
- 2010〜 — Outside-In TDD: GOOS が体系化したアプローチ。受け入れテストから始め、外側 (UI/API 境界) から内側 (ドメイン) へと TDD を進める。
- 2014 — "Is TDD Dead?": David Heinemeier Hansson が「TDD は死んだ」とブログで述べたことを契機に、Beck・Martin Fowler との 5 回連続の対話セッションが行われ、TDD のスコープと適用領域が再整理された。
- 2010 年代後半〜 — Property-Based Testing の浸透: Haskell の QuickCheck (1999) を起源に、JavaScript の fast-check、Python の Hypothesis などが普及。TDD と組み合わせた使い方が議論されるようになった。
2.4 なぜ今も TDD が重要なのか
2026 年現在、コードを書く環境は大きく変化している。AI コーディングアシスタントが日常的に使われるようになり、人が書くコード量よりも生成されるコード量の方が多い現場も珍しくない。それでもなお (むしろそれゆえに) TDD は重要である。
- 生成されたコードの正しさを検証する仕掛け が必要である。テストがなければ、AI が生成した実装を信頼する根拠がない。
- AI に対する仕様 としてテストを使える。期待する振る舞いをテストで先に表現しておけば、AI には「このテストを通すコードを書いて」と頼むだけでよい。
- 設計のレビュー はますます人間の役割になる。テストが書きやすい構造になっているかどうかは、設計品質の重要な代理指標となる。
このように、TDD は古典的なプラクティスでありながら、現代のソフトウェア開発の文脈においてもその価値を失っていない。むしろ「テストを仕様として扱う」「テストで設計を駆動する」という姿勢は、AI 時代において一層重要性を増している。
3. TDD の基本サイクル — Red, Green, Refactor
3.1 サイクルの定義
TDD のすべては、次の 3 ステップを「短く、何度も」繰り返すことに集約される。
- Red — 失敗するテストを書く。まだ実装は存在しないか、あるいは新しい振る舞いがまだ実装されていない状態。テストは確実に失敗する (赤くなる) ことを確認する。
- Green — テストを通すための 最小限の実装 を書く。美しさは問わない。コピー & ペースト、ハードコード、何でも構わない。重要なのは「全テストが通る」状態にすること。
- Refactor — テストが通ったままの状態で、重複の除去・名前の改善・抽象化など、構造の改善を行う。テストが緑のままであることを常に確認しながら進める。
このサイクルを 1〜10 分程度で 1 周し、何十回も回すのが古典的な TDD のリズムである。
3.2 各ステップの目的と心構え
Red のステップ
Red の目的は、「これからどう振る舞うべきか」を 実行可能な形で 宣言することである。次の点に注意する。
- テストが本当に失敗することを確認する。コンパイルエラーで落ちるのは Red ではない (まだ何もテストしていない)。テスト実行までは到達し、アサーションで失敗することが望ましい。
- 小さく始める。最初の Red は「メソッドが存在する」「特定の入力に対して特定の出力を返す」といった、最小単位の振る舞いを対象にする。
- テスト名で意図を表現する。
test1ではなく、returns "Fizz" when input is divisible by 3のように、振る舞いを文章として記述する。
Green のステップ
Green の目的は、ただ一つ、「テストを通すこと」である。意図的に「醜い」コードを書くことが推奨される。
- Fake It Till You Make It: 最初はテストの期待値をそのまま return してもよい。
return "Fizz";のような実装でも、Red の状態から脱出できれば十分。 - Obvious Implementation: 自明な場合 (例:
1 + 1の足し算) は、最初から正しい実装を書いてよい。 - Triangulation: 1 つのテストでは実装が一意に決まらない場合、テストを 2 つ、3 つと増やして実装を「三角測量」する。テストを増やすたびにハードコードでは対応できなくなり、本当のロジックが現れる。
Refactor のステップ
ここが TDD の最大の旨味である。テストという安全網がある状態で、構造を磨く。
- Rule of Three: 同じパターンが 3 回現れたら抽象化する。1 回目はそのまま、2 回目は気にしつつそのまま、3 回目で初めて抽出する。
- テストコード自体もリファクタリング対象: テストのセットアップ・命名・ヘルパー関数を整える。テストコードは本番コードと同等以上に「可読性」が重要。
- 緑を維持する: リファクタリング中は、機能を変えてはいけない。1 つの小さな変更ごとにテストを走らせる。
3.3 Uncle Bob の三原則 (The Three Laws of TDD)
Robert C. Martin (Uncle Bob) は、TDD のリズムを次の 3 つの法則で表現した。これは Beck よりも厳格なバージョンの TDD と言える。
- 失敗するテストがない限り、本番コードを書いてはならない。
- コンパイルが通らない、もしくは失敗するテスト以上のテストコードを書いてはならない。
- 現在失敗しているテストを通す以上の本番コードを書いてはならない。
これを字義通りに守ると、開発者は数秒〜数十秒の単位で「テストを書く」「失敗を確認する」「最小実装を書く」「成功を確認する」というサイクルを繰り返すことになる。極端に思えるが、このリズムは「常にコードがテストに守られている」状態を保証し、デバッグ時間を劇的に減らす。
3.4 サイクルの長さと粒度
TDD のサイクルは「短ければ短いほど良い」というのが多くの経験者の合意である。一般的には次のような目安が挙げられる。
| 状態 | サイクル長 |
|---|---|
| 慣れた領域・既知のドメイン | 30 秒〜2 分 |
| 不慣れな領域・新しい技術 | 2〜10 分 |
| 設計探索が必要な箇所 | スパイク (時限付き探索) を別途行う |
サイクルが伸びてきたら、テストが大きすぎるサインである。テストを分割し、より小さなステップに刻むことで、フィードバックループを短く保つ。
3.5 To Do リスト
Beck は『TDD By Example』の中で、機能を着手する前に To Do リスト を書くことを推奨している。これは BDD のシナリオ書きに似ているが、TDD ではより細かい単位で書く。
例: 「電卓」を作る場合の To Do リスト
[ ]1 + 1 = 2 を返す[ ]加算がオーバーフローしたら例外を投げる[ ]0 で割ったら DivisionByZero を投げる[ ]連続入力に対応する[ ]小数点を扱う
To Do リストは「考えたが、今は実装しない」を可視化するツールである。実装中に思いついたエッジケースは、現在のサイクルを中断せずにリストに追記し、後で取り組む。
4. TDD の哲学 — 設計を駆動するもの
4.1 「テストが先」が意味すること
TDD で最も誤解されているのは、「テストを先に書く」が「テストが目的である」と解釈されることである。実際には、TDD における「テストを先に書く」とは次のような意味である。
- API を利用者の視点から設計する: テストは、これから書こうとするコードの「最初の利用者」である。テストを書く瞬間、開発者は実装者ではなく利用者の立場に立つ。
- 責務の境界を強制的に決める: テストを書くためには「何を入力として与え、何を期待するか」を決めなければならない。これが自然と「このコンポーネントの責任は何か」を定義する。
- 完了の定義を明確にする: テストが通れば「機能が完成した」と言える。それ以上でも以下でもない。
つまり TDD の「テスト」は、検証よりも 仕様 および 設計の対話相手 としての役割が大きい。
4.2 Emergent Design (創発的設計)
TDD は「設計を最初に全部決める」のではなく、「テストとリファクタリングの繰り返しの中で、必要な構造が浮かび上がってくる」という創発的設計 (Emergent Design) を支持する。
創発的設計の鍵は YAGNI (You Aren't Gonna Need It) の原則である。「将来必要になるかもしれない機能」を先回りして実装しない。今のテストを通すために必要な構造だけを作り、必要が生じたときに拡張する。これが過剰設計と過剰汎用化を防ぐ。
4.3 シンプル設計の 4 つのルール
Kent Beck はシンプル設計を次の 4 つのルールで表現した。優先順位の高いものから順に並んでいる。
- テストが通る (Passes its tests)
- 意図を明らかにする (Reveals intention)
- 重複がない (No duplication)
- 要素数が最小 (Fewest elements)
「テストが通る」が最優先であることに注目したい。どんなに美しい設計でも、振る舞いが正しくなければ意味がない。次に「意図」が来るのは、コードは書く時間より読む時間の方がはるかに長いからである。
4.4 テスト容易性は設計品質の代理指標
「テストが書きにくい」と感じるとき、ほぼ確実に設計に問題がある。代表的な「設計のにおい」とその意味を表にまとめる。
| テストでの困難 | 示唆される設計問題 |
|---|---|
| セットアップが長い・依存が多い | 結合度が高い・責務が多い |
| プライベートメソッドをテストしたくなる | 別クラスとして抽出すべき責務がある |
モックの戻り値の連鎖 (a.b().c().d()) | デメテルの法則違反 |
| 同じセットアップが多くのテストで必要 | クラスのコンテキスト依存が強すぎる |
| 時刻・乱数・I/O のせいで非決定的 | 純粋関数として切り出せていない |
| アサーションが複雑 | 出力が「観測しにくい」副作用に頼っている |
このように、TDD は「コードを書く前に設計の悪臭をかぎ分ける」嗅覚を与えてくれる。
4.5 速いフィードバックの価値
TDD のもう一つの哲学的支柱は 「フィードバックループは短ければ短いほど良い」 である。バグの修正コストは、発見が遅れるほど指数関数的に上昇する。
| バグが発見される段階 | 相対的な修正コスト |
|---|---|
| 開発者の手元 (TDD) | 1x |
| ローカルでの統合テスト | 5x |
| CI のフルパイプライン | 10x |
| ステージング環境 | 50x |
| 本番環境 | 100x 以上 |
TDD は、最も短いフィードバックループ (秒単位) を提供する。コードを 1 行書いて Enter を押す前にテストが落ちる、というのが理想である。
4.6 「動作するきれいなコード」への道
Beck は『TDD By Example』の冒頭で、ソフトウェア開発の目標を「動作するきれいなコード (clean code that works)」と定義した。これを達成する道筋として TDD は次のように機能する。
- まず「動作する」を達成する (Red → Green)
- 次に「きれい」を達成する (Green → Refactor)
- これを最小単位で繰り返す
「動作する」と「きれい」を一度に追求しようとすると、人間の認知は破綻しやすい。一度に一つのことだけを考える ためのリズムが、Red-Green-Refactor の本質である。
5. テストの種類とテストピラミッド
5.1 テストの分類
TDD の文脈で扱う自動テストは、粒度・対象範囲によっていくつかの層に分類される。
| 種類 | 対象 | 速度 | 代表的なツール |
|---|---|---|---|
| Unit (単体) | 単一クラス・関数の振る舞い | ミリ秒 | Vitest, Jest, JUnit |
| Integration (統合) | 複数コンポーネントの連携、DB / 外部 API | 秒 | Testcontainers, Wiremock |
| Contract (契約) | サービス間 API の取り決め | 秒 | Pact, Spring Cloud Contract |
| Component / Service | デプロイ単位の境界 | 数秒〜数十秒 | Cucumber, REST-assured |
| End-to-End (E2E) | システム全体のユーザシナリオ | 数十秒〜分 | Playwright, Cypress |
TDD のサイクルで主に書くのは Unit テスト である。Integration / E2E は TDD の中央サイクルでは書かないが、Outside-In TDD では受け入れテスト (E2E に近い粒度) から始めて Unit テストへとブレイクダウンする。
5.2 テストピラミッド
Mike Cohn が提唱したテストピラミッドは、テストの「分布」を表現する古典的な指針である。
指針:
- 下層 (Unit) ほどテスト数を多く、実行を高速に。
- 上層 (E2E) は最小限に。重要なユーザシナリオを 1 〜数本のスモークテストとして残す。
- 中間層は「結合点でしか発見できないバグ」をカバーする (DB のクエリ、HTTP の境界、認可)。
5.3 テストピラミッドのアンチパターン
Ice Cream Cone (アイスクリームコーン)
ピラミッドが逆さまになった反パターン。E2E テストばかりが多く、Unit テストが少ない。
- 症状: CI が遅い、フレーキー (たまに失敗する) なテストが多い、デバッグに時間がかかる。
- 原因: 「実際に動く環境でしかテストできない」設計、QA 主導でテストが書かれた歴史。
Dual Cone (砂時計)
E2E と Unit は多いが、中間層が空洞。
- 症状: 統合点でのバグが本番で初めて発覚する。
- 対策: Contract テスト、Component テストを意識的に追加する。
5.4 Test Trophy (テストトロフィー)
Kent C. Dodds がフロントエンド開発の文脈で提唱した派生概念。
[E2E]
[Integration] ← 最も投資する
[Unit]
[Static (型/Lint)] ← 基盤
- フロントエンドでは Integration テスト (複数コンポーネントを組み合わせた、ブラウザ環境に近いテスト) が費用対効果が最も高い。
- 静的解析 (TypeScript、ESLint) をテストの一種としてピラミッドの基盤に置く。
5.5 Solitary vs. Sociable (孤独 vs. 社交的)
Jay Fields が Working Effectively with Unit Tests で導入した分類で、Unit テストの内部スタイルを表す。
- Solitary (孤独): テスト対象クラスのコラボレータをすべてモック・スタブで置き換える。テスト対象だけを孤立させてテストする。London 派の中心的スタイル。
- Sociable (社交的): コラボレータを実物のまま使う (DB や HTTP は別)。値オブジェクトや純粋なロジックは置き換えない。Detroit 派の中心的スタイル。
| 観点 | Solitary | Sociable |
|---|---|---|
| 失敗時の局所性 | 高い (1 クラスのみ落ちる) | 低い (連鎖して落ちうる) |
| リファクタ耐性 | 低い (内部実装に依存) | 高い (公開振る舞いに依存) |
| 設計フィードバック | 強い (協調を意識) | 弱い |
| セットアップ | モックが必要 | 実オブジェクトを組み立てる |
実務では「両方を使い分ける」のが現実解である。後の章で詳しく述べる。
6. TDD の二大学派 — Detroit 派と London 派
6.1 二つのスタイルの存在
TDD には大きく分けて二つの学派がある。両者は「Red-Green-Refactor を回す」という骨格は共通だが、テスト対象の境界の引き方 と コラボレータの扱い方 が異なる。
| Detroit / Classicist | London / Mockist | |
|---|---|---|
| 別名 | Chicago, Classical | Outside-In, Mockist |
| 主導者 | Kent Beck, Ron Jeffries | Steve Freeman, Nat Pryce |
| 主な書籍 | TDD By Example (2003) | GOOS (2009) |
| 検証スタイル | State (状態) | Interaction (相互作用) |
| テストの粒度 | Sociable | Solitary |
| 進め方 | Inside-Out (内側から外へ) | Outside-In (外側から内へ) |
| ダブル使用 | 必要なときだけ | 積極的に |
6.2 Detroit (Classicist) 派
思想
Kent Beck は SUnit / JUnit を生み出した本人であり、シンプル設計の四原則を提唱した。Detroit 派は Beck の流儀に近く、次のような特徴を持つ。
- 状態に対する検証: 「この入力に対してこの出力が返る」「この操作の結果、内部状態がこうなっている」を検証する。
- コラボレータの実物使用: 値オブジェクトや純粋なロジックを持つクラスは、テスト中も実物を使う。モックは I/O 境界 (DB, HTTP) に限定する。
- Inside-Out の進行: ドメインの中心 (エンティティ、値オブジェクト) から TDD を始め、徐々にユースケース・アダプタへと広げる。
- 創発的設計を信じる: テストとリファクタリングの繰り返しから、自然と設計が浮かび上がる。
強み
- リファクタリング耐性が高い: 内部実装が変わってもテストが落ちにくい。
- 失敗時のシグナルが本質的: 振る舞いが壊れた時にテストが落ちる。
- オーバーモッキングを避けられる
弱み
- 失敗時の局所性が低い: 一つのバグで複数のテストが連鎖して落ちうる。
- 大規模システムでは始点が見えにくい: 「どこから書き始めるか」を判断するセンスが必要。
6.3 London (Mockist) 派
思想
GOOS (Growing Object-Oriented Software, Guided by Tests) で体系化されたスタイル。次のような特徴を持つ。
- 相互作用に対する検証: 「このオブジェクトはコラボレータに対して、このメソッドをこの順序で呼ぶ」を検証する。
- コラボレータをすべてダブルに置き換え: テスト対象を孤立させる。
- Outside-In の進行: 受け入れテスト → ユースケース → ドメインモデル と、外側から内側へ書き進める。
- 役割の発見: モックは「まだ存在しない協調オブジェクト」を表現するプレースホルダ。モックを書きながら必要な役割 (インターフェース) を発見する。
強み
- 責務分離が強制される: 「このクラスはこのコラボレータに何を頼むか」を明示的に書くため、依存関係が表面化する。
- 失敗時の局所性が高い: 1 クラスにバグがあれば、1 つのテストだけが落ちる。
- インターフェース設計が改善される
弱み
- オーバーモッキング: モックの設定が実装と密結合になり、リファクタリングが難しくなる。
- 「テストが通っても動かない」可能性: モック同士の整合性は別途 Contract テストで担保する必要がある。
- テストが「実装の写し鏡」になりやすい: 何が起きるかではなく、どう実装されるかをテストしてしまう。
6.4 比較例
「ユーザを登録し、確認メールを送る」ユースケースで、両者がどう書くかを比較する。
Detroit スタイル
// テスト
describe("RegisterUser (Detroit)", () => {
it("登録後にユーザがリポジトリに保存され、ステータスは ACTIVE", async () => {
const repo = new InMemoryUserRepository();
const mailer = new InMemoryMailer();
const usecase = new RegisterUser(repo, mailer);
await usecase.execute({ email: "a@b.com", name: "Alice" });
const saved = await repo.findByEmail("a@b.com");
expect(saved?.name).toBe("Alice");
expect(saved?.status).toBe("ACTIVE");
expect(mailer.sent).toHaveLength(1);
expect(mailer.sent[0].to).toBe("a@b.com");
});
});
InMemoryUserRepository と InMemoryMailer は Fake (作業する偽物)。状態を実際に保持し、テスト後に検証できる。
London スタイル
describe("RegisterUser (London)", () => {
it("ユーザを保存してから確認メールを送る", async () => {
const repo = mock<UserRepository>();
const mailer = mock<Mailer>();
const usecase = new RegisterUser(repo, mailer);
await usecase.execute({ email: "a@b.com", name: "Alice" });
expect(repo.save).toHaveBeenCalledWith(
expect.objectContaining({ email: "a@b.com", status: "ACTIVE" })
);
expect(mailer.sendConfirmation).toHaveBeenCalledWith("a@b.com");
});
});
repo.save と mailer.sendConfirmation の 呼び出し を検証する。順序を厳密にしたければ expect(...).toHaveBeenCalledBefore(...) を使う。
6.5 どちらを選ぶか — 実務的な指針
二者択一ではなく、コンテキストに応じて使い分けるのが現実解である。
- 値オブジェクト・ドメインロジック: Detroit 派。ロジック自体の正しさを状態で検証する。
- アプリケーションサービス・ユースケース: London 派寄り。外部依存を切り出して契約を明示する。
- アダプタ層 (DB / HTTP): 統合テスト (Testcontainers / Wiremock) で本物相手にテストする。
- UI コンポーネント: Integration / Component テスト (Testing Library) で振る舞いベース。
ハイブリッドな運用では、「ドメインの中核は Sociable、境界は Solitary」という線引きが多くのチームで採用されている。
7. アーキテクチャと TDD
7.1 ヘキサゴナルアーキテクチャ (Ports and Adapters)
Alistair Cockburn が 2005 年に提唱した Hexagonal Architecture (Ports and Adapters) は、TDD と非常に相性が良い。中心にビジネスロジック (ドメイン) を置き、外部 (DB、UI、メッセージング、外部 API) との接点を ポート (インターフェース) として定義する。
ポイントは次の通り。
- ドメインは外部に依存しない: ドメインからアダプタへの依存はない。逆向きの依存だけ。
- ポートは抽象: ドメインから見た「協力者の役割」を表す。
- アダプタが具象実装: DB や HTTP など、技術的詳細はアダプタに閉じ込める。
TDD でこの構造を作る場合、
- ユースケースのテストでは、Outbound Port をテストダブルに置き換える (London 派の出番)。
- ドメインモデルのテストでは、純粋なロジックなのでダブル不要 (Detroit 派の出番)。
- アダプタは統合テストで実物相手に検証する (Testcontainers, Wiremock)。
7.2 Outside-In TDD
GOOS で体系化された Outside-In TDD は、ヘキサゴナルアーキテクチャを TDD で構築するアプローチである。
進め方:
- 受け入れテスト (Acceptance Test) を書く — システム外部から見た振る舞いを宣言する。最初は当然失敗する。
- 失敗を分析し、必要なオブジェクトと役割を発見する — 「何があれば受け入れテストが通るか」を考える。
- 内側の Unit テストを書く — 役割ごとに Solitary な Unit テストで TDD を回す。コラボレータはモックで表現。
- すべての Unit テストが緑になったら、受け入れテストに戻る — まだ失敗していれば、別の役割が必要。
- 受け入れテストが緑になったら、リファクタリング — 全体のサイクル完了。
このプロセスでは、最初はモックだけで存在する「役割」が、徐々に具象クラス・インターフェースとして形を成していく。
7.3 ATDD と BDD
Acceptance Test Driven Development (ATDD) と Behavior Driven Development (BDD) は、Outside-In TDD の上位に位置する。受け入れテストの書き方・チームでの活用法に焦点を当てる。
Gherkin による記述
Cucumber などで使われる Gherkin 記法は、ビジネス側でも読める形式でテストを記述する。
Feature: ユーザ登録
Scenario: 有効なメールアドレスでの登録
Given メールアドレス "alice@example.com" がまだ登録されていない
When ユーザが "alice@example.com" "Alice" で登録を行う
Then ユーザはアクティブ状態で保存されている
And 確認メールが "alice@example.com" に送られている
このシナリオを通すために、ドメイン層の Unit テストを TDD で書き進める。
Three Amigos
BDD では、機能着手前に PO (プロダクト) Dev (開発) QA (品質) の 3 人がシナリオを書き起こす「Three Amigos セッション」を推奨する。これによって、
- 仕様の曖昧さがコードに到達する前に解消される
- テスト可能な記述が最初から得られる
- 実装の前に「完了の定義」が共有される
7.4 Clean Architecture との関係
Robert C. Martin の Clean Architecture (2017) は、ヘキサゴナルアーキテクチャ・Onion Architecture などを総合した形で提示されたもので、TDD との相性が極めて良い。
- 依存性の方向: 内側 (Entity, Use Case) は外側 (Adapter, Framework) に依存しない。これにより内側を Unit テストで完全にカバーできる。
- 境界 (Boundary): 入出力のインターフェースを明示する。これがそのままモック対象となる。
- Use Case Interactor: ユースケースを 1 クラスとして表現する。これが TDD の中心的なテスト対象となる。
7.5 テスト容易性とアーキテクチャの相互作用
TDD を続けると、自然と次のような構造に収束しやすい。
- コア (純粋関数 / 値オブジェクト) が大きくなる
- 副作用 が境界に集約される
- 依存性注入 (DI) が常用される
- インターフェース が「役割」として明示される
これは関数型プログラミングの「Functional Core, Imperative Shell」(Gary Bernhardt) の考え方とも一致する。TDD は明示的にこの構造を強制するわけではないが、テストしやすいコードを追求すると自然とこうなる。
8. テストダブル — 5 つの種類とその使い分け
8.1 用語の整理
「Mock」という言葉は実務で乱用されがちだが、Gerard Meszaros が xUnit Test Patterns (2007) で定義し、Martin Fowler が "Mocks Aren't Stubs" で広めた厳密な分類がある。これを Test Doubles (テストダブル) と総称する。
| 種類 | 役割 | 例 |
|---|---|---|
| Dummy | 引数を埋めるだけ。実際には使われない | 必要だが使わない Logger を null の代わりに渡す |
| Stub | 決まった答えを返す | repo.find(id) を呼ばれたら常にこのユーザを返す |
| Spy | Stub に「呼ばれたか」の記録機能を追加 | テスト後に mailer.sentTo を確認 |
| Mock | 期待される呼び出しを事前定義し、自動検証 | 「このメソッドはちょうど 1 回呼ばれるはず」 |
| Fake | 動く実装を持つが本番用ではない | InMemory のリポジトリ |
8.2 各ダブルの使い所
Dummy
// Logger を要求するが、テスト中は使わない
class NullLogger implements Logger {
info(_msg: string): void {}
error(_msg: string): void {}
}
const usecase = new RegisterUser(repo, mailer, new NullLogger());
Stub
const stubUserRepo: UserRepository = {
findByEmail: async () => ({ id: "1", email: "a@b.com", status: "ACTIVE" }),
save: async () => {},
};
戻り値を固定するのが Stub。呼び出し回数を検証しないなら Stub で十分。
Spy
class MailerSpy implements Mailer {
public sent: { to: string; subject: string }[] = [];
async send(to: string, subject: string, body: string) {
this.sent.push({ to, subject });
}
}
呼び出された事実を「事後に」記録し、テストで expect(mailer.sent[0].to).toBe(...) のように検証する。
Mock
import { mock, when, verify } from "ts-mockito";
const mailer = mock<Mailer>();
when(mailer.send(anyString(), anyString(), anyString())).thenResolve();
await usecase.execute({ email: "a@b.com", name: "Alice" });
verify(mailer.send("a@b.com", "Welcome", anything())).once();
Mock は 事前に期待を宣言 し、検証も自動で行う。期待と異なる呼び出しがあった瞬間に失敗する。
Fake
class InMemoryUserRepository implements UserRepository {
private map = new Map<string, User>();
async save(u: User) { this.map.set(u.id, u); }
async findById(id: string) { return this.map.get(id) ?? null; }
async findByEmail(email: string) {
return [...this.map.values()].find(u => u.email === email) ?? null;
}
}
Fake は本物の代わりに動作する軽量な実装。Stub/Mock より複雑だが、複数回操作するテストで一貫した状態を保てる。
8.3 Mock vs. Spy の違い (微妙だが重要)
- Spy: 「呼び出しを記録するが、検証は呼び出し側が後で行う」
- Mock: 「期待をあらかじめ宣言し、フレームワーク側が自動検証する」
Spy の方が「テストが何を検証しているか」が読みやすく、Mock は宣言的だが過剰になりやすい。実務では「Spy 寄りの Mock」(jest.fn() で記録だけして後で expect する) を使うことが多い。これは厳密には Spy に近い。
8.4 「動詞」と「名詞」を見分ける指針
GOOS は次のような指針を提示している。
Stub queries, Mock commands. (問い合わせ的なメソッドは Stub にし、命令的な副作用を伴うメソッドは Mock にする)
find,get,readのような クエリ (副作用なし) → Stub で戻り値を制御save,send,notifyのような コマンド (副作用あり) → Mock で呼ばれたかを検証
これは Bertrand Meyer の Command-Query Separation とも一致する。
8.5 オーバーモッキングの危険
モックを過剰に使うと、テストが「実装の写し鏡」になり、リファクタリング耐性が失われる。次のような兆候に注意する。
- 1 つのテストに 5 個以上のモックがある
- モックの戻り値が連鎖している (
a.foo().bar().baz()) - 内部のヘルパー関数までモック化している
- 振る舞いを変えていないのに、リファクタするとテストが落ちる
このような場合、「モックすべきは Owned Type (自分が定義したインターフェース) のみ」 という指針が役立つ。サードパーティライブラリ (axios, AWS SDK 等) を直接モックするのではなく、自分のドメイン用の Adapter インターフェースを 1 枚噛ませて、そのインターフェースだけモックする。
// Bad: axios を直接モック
jest.mock("axios");
// Good: Adapter を介してモック
interface PaymentGateway {
charge(amount: Money, cardToken: string): Promise<ChargeResult>;
}
class StripePaymentGateway implements PaymentGateway { /* axios 呼び出し */ }
// テストでは PaymentGateway をモック
const gateway = mock<PaymentGateway>();
この一段の抽象化が、テストの安定性とドメインの可読性を同時に向上させる。
9. 具体例 — TypeScript と Vitest による TDD カタ
本章では、TypeScript + Vitest を使って、実際の TDD サイクルを 4 つのカタで体験する。すべてのコードは RED → GREEN → REFACTOR のステップを明示的に踏む。
9.0 セットアップ
// package.json
{
"name": "tdd-katas",
"type": "module",
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"coverage": "vitest run --coverage"
},
"devDependencies": {
"vitest": "^1.6.0",
"@vitest/coverage-v8": "^1.6.0",
"typescript": "^5.4.0"
}
}
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
thresholds: { lines: 90, functions: 90, branches: 85 },
},
},
});
9.1 FizzBuzz カタ
Cycle 1 — 1 の入力に対して "1" を返す
RED:
// src/fizzbuzz.test.ts
import { describe, it, expect } from "vitest";
import { fizzbuzz } from "./fizzbuzz";
describe("fizzbuzz", () => {
it("returns '1' for 1", () => {
expect(fizzbuzz(1)).toBe("1");
});
});
// src/fizzbuzz.ts
// (まだ何もない)
→ 失敗 (fizzbuzz が定義されていない)
GREEN — Fake It:
// src/fizzbuzz.ts
export function fizzbuzz(_n: number): string {
return "1";
}
→ パス
Cycle 2 — 2 で "2" を返す (Triangulation)
RED:
it("returns '2' for 2", () => {
expect(fizzbuzz(2)).toBe("2");
});
→ 失敗
GREEN — 一般化:
export function fizzbuzz(n: number): string {
return String(n);
}
→ パス
Cycle 3 — 3 で "Fizz"
RED:
it("returns 'Fizz' for 3", () => {
expect(fizzbuzz(3)).toBe("Fizz");
});
GREEN:
export function fizzbuzz(n: number): string {
if (n === 3) return "Fizz";
return String(n);
}
Cycle 4 — 6 でも "Fizz" (Triangulation)
RED:
it.each([3, 6, 9])("returns 'Fizz' for %i", (n) => {
expect(fizzbuzz(n)).toBe("Fizz");
});
GREEN:
export function fizzbuzz(n: number): string {
if (n % 3 === 0) return "Fizz";
return String(n);
}
Cycle 5 — 5 で "Buzz"
RED:
it.each([5, 10, 20])("returns 'Buzz' for %i", (n) => {
expect(fizzbuzz(n)).toBe("Buzz");
});
GREEN:
export function fizzbuzz(n: number): string {
if (n % 3 === 0) return "Fizz";
if (n % 5 === 0) return "Buzz";
return String(n);
}
Cycle 6 — 15 で "FizzBuzz"
RED:
it.each([15, 30, 45])("returns 'FizzBuzz' for %i", (n) => {
expect(fizzbuzz(n)).toBe("FizzBuzz");
});
GREEN:
export function fizzbuzz(n: number): string {
if (n % 15 === 0) return "FizzBuzz";
if (n % 3 === 0) return "Fizz";
if (n % 5 === 0) return "Buzz";
return String(n);
}
REFACTOR — 重複の除去
export function fizzbuzz(n: number): string {
const fizz = n % 3 === 0 ? "Fizz" : "";
const buzz = n % 5 === 0 ? "Buzz" : "";
return fizz + buzz || String(n);
}
テストはすべて緑のまま。重複が削減され、ロジックが「3 と 5 で割り切れるか」という本質に圧縮された。
9.2 Money カタ (Beck の縮小版)
通貨を持つ金額の足し算と乗算を実装する。
Cycle 1 — Dollar の乗算
RED:
// src/money.test.ts
import { describe, it, expect } from "vitest";
import { Money } from "./money";
describe("Money", () => {
it("multiplies a Dollar amount", () => {
const five = Money.dollar(5);
expect(five.times(2)).toEqual(Money.dollar(10));
});
});
GREEN:
// src/money.ts
export class Money {
constructor(private readonly amount: number, private readonly currency: string) {}
static dollar(a: number) { return new Money(a, "USD"); }
times(multiplier: number): Money {
return new Money(this.amount * multiplier, this.currency);
}
equals(other: Money) {
return this.amount === other.amount && this.currency === other.currency;
}
}
toEqual を使うために equals を定義 (Vitest は構造比較もできるが、ドメインオブジェクトとして equals を持つのが望ましい)。
Cycle 2 — Franc の乗算
RED:
it("multiplies a Franc amount", () => {
const five = Money.franc(5);
expect(five.times(3)).toEqual(Money.franc(15));
});
GREEN:
static franc(a: number) { return new Money(a, "CHF"); }
times は通貨を保持したまま乗算するので、すでに動く。
Cycle 3 — 異なるインスタンスの等価性
RED:
it("treats different currencies as not equal", () => {
expect(Money.dollar(5).equals(Money.franc(5))).toBe(false);
});
→ すでに equals で通貨を比較しているのでパス。
Cycle 4 — 加算 (異なる通貨)
RED:
it("adds two Dollar amounts", () => {
const sum = Money.dollar(5).plus(Money.dollar(3));
expect(sum).toEqual(Money.dollar(8));
});
it("throws when adding different currencies", () => {
expect(() => Money.dollar(5).plus(Money.franc(3))).toThrow();
});
GREEN:
plus(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error(`Currency mismatch: ${this.currency} vs ${other.currency}`);
}
return new Money(this.amount + other.amount, this.currency);
}
REFACTOR — 値オブジェクトの不変性を厳格化
export class Money {
private constructor(
public readonly amount: number,
public readonly currency: string
) {
if (!Number.isFinite(amount)) throw new Error("amount must be finite");
}
static of(amount: number, currency: string) { return new Money(amount, currency); }
static dollar(a: number) { return Money.of(a, "USD"); }
static franc(a: number) { return Money.of(a, "CHF"); }
times(n: number) { return Money.of(this.amount * n, this.currency); }
plus(o: Money) {
this.assertSameCurrency(o);
return Money.of(this.amount + o.amount, this.currency);
}
equals(o: Money) { return this.amount === o.amount && this.currency === o.currency; }
private assertSameCurrency(o: Money) {
if (this.currency !== o.currency) {
throw new Error(`Currency mismatch: ${this.currency} vs ${o.currency}`);
}
}
}
テストが緑のまま設計が磨かれた。コンストラクタを private にしてファクトリ経由のみ生成するようにし、不変条件をコンストラクタで強制した。
9.3 Bowling Game カタ
Uncle Bob の有名なカタ。10 フレームのボウリングのスコアを計算する。
Cycle 1 — ガターゲーム (全ピン外し)
RED:
// src/bowling.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { Game } from "./bowling";
describe("Bowling Game", () => {
let game: Game;
beforeEach(() => { game = new Game(); });
const rollMany = (n: number, pins: number) => {
for (let i = 0; i < n; i++) game.roll(pins);
};
it("scores 0 for a gutter game", () => {
rollMany(20, 0);
expect(game.score()).toBe(0);
});
});
GREEN:
// src/bowling.ts
export class Game {
private rolls: number[] = [];
roll(pins: number) { this.rolls.push(pins); }
score(): number { return 0; }
}
Cycle 2 — 全フレーム 1 ピン
RED:
it("scores 20 when all rolls knock 1 pin", () => {
rollMany(20, 1);
expect(game.score()).toBe(20);
});
GREEN:
score() { return this.rolls.reduce((a, b) => a + b, 0); }
Cycle 3 — スペア
RED:
it("counts spare bonus", () => {
game.roll(5); game.roll(5); // spare
game.roll(3);
rollMany(17, 0);
expect(game.score()).toBe(16); // 10 + 3 + 3
});
GREEN:
score() {
let score = 0;
let i = 0;
for (let frame = 0; frame < 10; frame++) {
if (this.rolls[i] + this.rolls[i + 1] === 10) { // spare
score += 10 + this.rolls[i + 2];
i += 2;
} else {
score += this.rolls[i] + this.rolls[i + 1];
i += 2;
}
}
return score;
}
Cycle 4 — ストライク
RED:
it("counts strike bonus", () => {
game.roll(10); // strike
game.roll(3); game.roll(4);
rollMany(16, 0);
expect(game.score()).toBe(24); // 10+3+4 + 3+4
});
GREEN:
score() {
let score = 0, i = 0;
for (let frame = 0; frame < 10; frame++) {
if (this.rolls[i] === 10) {
score += 10 + this.rolls[i + 1] + this.rolls[i + 2];
i += 1;
} else if (this.rolls[i] + this.rolls[i + 1] === 10) {
score += 10 + this.rolls[i + 2];
i += 2;
} else {
score += this.rolls[i] + this.rolls[i + 1];
i += 2;
}
}
return score;
}
REFACTOR — 意図を明らかにするヘルパー
export class Game {
private rolls: number[] = [];
roll(pins: number) { this.rolls.push(pins); }
score(): number {
let score = 0;
let i = 0;
for (let frame = 0; frame < 10; frame++) {
if (this.isStrike(i)) { score += 10 + this.strikeBonus(i); i += 1; }
else if (this.isSpare(i)) { score += 10 + this.spareBonus(i); i += 2; }
else { score += this.frameScore(i); i += 2; }
}
return score;
}
private isStrike(i: number) { return this.rolls[i] === 10; }
private isSpare(i: number) { return this.rolls[i] + this.rolls[i + 1] === 10; }
private strikeBonus(i: number) { return this.rolls[i + 1] + this.rolls[i + 2]; }
private spareBonus(i: number) { return this.rolls[i + 2]; }
private frameScore(i: number) { return this.rolls[i] + this.rolls[i + 1]; }
}
ロジックは同じだが、score() メソッドがほぼ自然言語のように読めるようになった。これがリファクタリングの本領。
9.4 Outside-In の例 — ユースケースとアダプタ
「商品をカートに追加する」ユースケースを Outside-In で TDD する。
受け入れテスト
// tests/acceptance/add-to-cart.feature.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { buildApp } from "../../src/app";
describe("Acceptance: add to cart", () => {
it("adds an item to a customer's cart", async () => {
const app = buildApp({ inMemory: true });
await app.customers.create({ id: "c1", name: "Alice" });
await app.products.create({ id: "p1", name: "Widget", priceCents: 500 });
await app.cart.addItem({ customerId: "c1", productId: "p1", quantity: 2 });
const cart = await app.cart.getCart("c1");
expect(cart.items).toEqual([{ productId: "p1", quantity: 2, lineTotalCents: 1000 }]);
expect(cart.totalCents).toBe(1000);
});
});
このテストは最初、buildApp も cart.addItem も存在しないので失敗する。これを通すために、内側を Solitary Unit テストで書いていく。
ユースケースの Unit テスト
// src/usecases/add-to-cart.test.ts
import { describe, it, expect, vi } from "vitest";
import { AddToCart } from "./add-to-cart";
import type { ProductRepository, CartRepository } from "../ports";
describe("AddToCart", () => {
it("loads the product, computes line total, and persists the cart", async () => {
const products = {
findById: vi.fn(async () => ({ id: "p1", name: "Widget", priceCents: 500 })),
} satisfies Partial<ProductRepository> as ProductRepository;
const carts = {
findByCustomerId: vi.fn(async () => ({ customerId: "c1", items: [] })),
save: vi.fn(async () => {}),
} satisfies Partial<CartRepository> as CartRepository;
const usecase = new AddToCart(products, carts);
await usecase.execute({ customerId: "c1", productId: "p1", quantity: 2 });
expect(carts.save).toHaveBeenCalledWith({
customerId: "c1",
items: [{ productId: "p1", quantity: 2, lineTotalCents: 1000 }],
});
});
it("throws when product not found", async () => {
const products = { findById: vi.fn(async () => null) } as unknown as ProductRepository;
const carts = {
findByCustomerId: vi.fn(async () => ({ customerId: "c1", items: [] })),
save: vi.fn(),
} as unknown as CartRepository;
const usecase = new AddToCart(products, carts);
await expect(usecase.execute({ customerId: "c1", productId: "x", quantity: 1 }))
.rejects.toThrow("Product not found");
});
});
実装 (GREEN の最小版)
// src/ports.ts
export interface Product { id: string; name: string; priceCents: number; }
export interface CartItem { productId: string; quantity: number; lineTotalCents: number; }
export interface Cart { customerId: string; items: CartItem[]; }
export interface ProductRepository {
findById(id: string): Promise<Product | null>;
}
export interface CartRepository {
findByCustomerId(id: string): Promise<Cart | null>;
save(cart: Cart): Promise<void>;
}
// src/usecases/add-to-cart.ts
import type { ProductRepository, CartRepository } from "../ports";
export class AddToCart {
constructor(private products: ProductRepository, private carts: CartRepository) {}
async execute(input: { customerId: string; productId: string; quantity: number }) {
const product = await this.products.findById(input.productId);
if (!product) throw new Error("Product not found");
const cart = (await this.carts.findByCustomerId(input.customerId))
?? { customerId: input.customerId, items: [] };
cart.items.push({
productId: product.id,
quantity: input.quantity,
lineTotalCents: product.priceCents * input.quantity,
});
await this.carts.save(cart);
}
}
Fake アダプタ
受け入れテストを通すため、InMemory の Fake アダプタを書く。
// src/adapters/in-memory.ts
import type { ProductRepository, CartRepository, Product, Cart } from "../ports";
export class InMemoryProductRepository implements ProductRepository {
private map = new Map<string, Product>();
async create(p: Product) { this.map.set(p.id, p); }
async findById(id: string) { return this.map.get(id) ?? null; }
}
export class InMemoryCartRepository implements CartRepository {
private map = new Map<string, Cart>();
async findByCustomerId(id: string) { return this.map.get(id) ?? null; }
async save(c: Cart) { this.map.set(c.customerId, c); }
}
組み立て
// src/app.ts
import { InMemoryProductRepository, InMemoryCartRepository } from "./adapters/in-memory";
import { AddToCart } from "./usecases/add-to-cart";
export function buildApp(opts: { inMemory: boolean }) {
const products = new InMemoryProductRepository();
const carts = new InMemoryCartRepository();
// 他のユースケース類は省略
const addToCart = new AddToCart(products, carts);
return {
customers: { create: async (_c: { id: string; name: string }) => {} },
products: { create: (p: { id: string; name: string; priceCents: number }) => products.create(p) },
cart: {
addItem: addToCart.execute.bind(addToCart),
getCart: async (id: string) => {
const c = await carts.findByCustomerId(id);
const total = c?.items.reduce((s, i) => s + i.lineTotalCents, 0) ?? 0;
return { ...c, items: c?.items ?? [], totalCents: total };
},
},
};
}
これで受け入れテストが緑になる。各層が独立にテストされ、組み立てたときに全体として正しく動く。これが Outside-In TDD の典型的な進行である。
10. 設計原則と TDD
10.1 SOLID 原則と TDD
TDD を続けると、SOLID 原則は「守るべき制約」というより「自然と現れる結果」になる。
Single Responsibility (単一責任)
テストが大きくなりすぎたとき、それは「このクラスに責任が多すぎる」というシグナルである。テストを分割しようとすると、クラスを分割するのが自然な結論になる。
例: ユーザ登録の処理が「バリデーション」「永続化」「メール送信」をすべて行うと、テストでこれらを区別するのが面倒になる。Validator、Repository、Mailer の 3 つに分けると、各々が小さく、テストもシンプルになる。
Open-Closed (開放閉鎖)
拡張に開かれ、修正に閉じている設計。TDD では、新しい振る舞いを追加するときに既存のテストが落ちるのは避けたい。これを促すには、ポリモーフィズムでの拡張が自然となる。
// Bad: 新しい支払い方法を追加するたびに既存コードを修正
function charge(method: string, amount: Money) {
if (method === "card") { /* ... */ }
else if (method === "paypal") { /* ... */ }
// 新しい方法を追加するときにここを編集 → 既存テストが影響を受ける
}
// Good: 戦略パターン
interface PaymentStrategy { charge(amount: Money): Promise<Receipt>; }
class CardPayment implements PaymentStrategy { /* ... */ }
class PayPalPayment implements PaymentStrategy { /* ... */ }
// 新しい方法は新しいクラスを追加するだけ。既存テストは無影響。
Liskov Substitution (リスコフの置換)
サブタイプはスーパータイプとして振る舞えなければならない。TDD では、インターフェースに対するテスト (Contract Test) を書くことで、すべての実装が同じ契約を満たすことを保証できる。
function userRepositoryContract(makeRepo: () => UserRepository) {
describe("UserRepository contract", () => {
it("returns null for unknown id", async () => {
const repo = makeRepo();
expect(await repo.findById("nope")).toBeNull();
});
it("returns saved user", async () => {
const repo = makeRepo();
await repo.save({ id: "1", email: "a@b.com", status: "ACTIVE", name: "A" });
expect(await repo.findById("1")).toMatchObject({ email: "a@b.com" });
});
});
}
describe("InMemoryUserRepository", () => userRepositoryContract(() => new InMemoryUserRepository()));
describe("PostgresUserRepository", () => userRepositoryContract(() => new PostgresUserRepository(testDb)));
Interface Segregation (インターフェース分離)
多すぎるメソッドを持つインターフェースは、モックの設定を煩雑にする。テストを書きながら、本当に必要なメソッドだけを持つ小さなインターフェースに分離する動機が生まれる。
Dependency Inversion (依存関係逆転)
具象に依存せず抽象に依存する。TDD では、コラボレータをモックするためにインターフェースが必要になり、自然と DI が常用される。
10.2 DRY と Tell, Don't Ask
DRY (Don't Repeat Yourself)
リファクタ段階で「3 回現れたら抽出する」を実践する。ただし、早すぎる抽象化は避ける。Sandi Metz の名言「重複は誤った抽象化より好ましい」は TDD でも生きる。同じに「見える」コードでも、別の理由で存在しているなら、まだ抽象化のタイミングではない。
Tell, Don't Ask
「オブジェクトに状態を尋ねて判断する」のではなく、「オブジェクトに命令する」。
// Bad: Ask
if (account.getBalance() >= amount) {
account.setBalance(account.getBalance() - amount);
}
// Good: Tell
account.withdraw(amount); // 内部で残高チェックも行う
TDD では、account.withdraw(amount) をテストする方が、getBalance と setBalance の組み合わせをテストするよりはるかに楽である。これが自然と Tell, Don't Ask に向かわせる。
10.3 値オブジェクトとイミュータビリティ
TDD は「同じ入力に対して同じ出力」をテストしやすいため、純粋関数と値オブジェクトを好む。たとえば Money.of(100, "USD").plus(Money.of(50, "USD")) が新しい Money を返す設計は、副作用がなくテストが書きやすい。
逆に「money.add(50) でこの money の値が変わる」設計は、共有された参照を介して予期しない結果を生む可能性があり、テストでも複雑なセットアップが必要になる。
10.4 ドメイン駆動設計 (DDD) との親和性
DDD の戦術設計 (Entity, Value Object, Aggregate, Domain Service) は TDD と非常に相性が良い。
- Value Object: 純粋関数的にテスト可能。Detroit 派の主戦場。
- Entity: 不変条件をテストで明示できる。
- Aggregate: 一貫性境界をテストで定義する。
- Domain Service: ステートレスなロジックを集約する。複雑な計算ロジックを TDD で開発する典型例。
DDD と TDD を組み合わせると、ドメインモデルが進化するたびに、テストも進化していく。テスト群がドメイン知識のドキュメントとなり、新メンバが「このコンポーネントは何をするのか」をテストから読み取れるようになる。
11. リファクタリング — Refactor 段階の技法
TDD のサイクルで最も学習効果が高いのは Refactor 段階である。ここでは Martin Fowler の Refactoring (1999, 2018) で紹介される代表的な技法を、TDD の文脈で実演する。
11.1 Extract Method / Extract Function
長いメソッドの一部を、意図を表す名前のメソッドに切り出す。
// Before
function printOwing(invoice: Invoice) {
console.log("***********************");
console.log("**** Customer Owes ****");
console.log("***********************");
let outstanding = 0;
for (const o of invoice.orders) outstanding += o.amount;
console.log(`name: ${invoice.customer}`);
console.log(`amount: ${outstanding}`);
}
// After
function printOwing(invoice: Invoice) {
printBanner();
const outstanding = calculateOutstanding(invoice);
printDetails(invoice, outstanding);
}
function printBanner() {
console.log("***********************");
console.log("**** Customer Owes ****");
console.log("***********************");
}
function calculateOutstanding(invoice: Invoice) {
return invoice.orders.reduce((s, o) => s + o.amount, 0);
}
function printDetails(invoice: Invoice, outstanding: number) {
console.log(`name: ${invoice.customer}`);
console.log(`amount: ${outstanding}`);
}
各ステップの後にテストを実行する。安全網があるからこそ、思い切った再構成ができる。
11.2 Rename
名前は最も強力なドキュメントである。命名がしっくり来ないときは、まだ概念が整理されていないサインでもある。IDE のリネーム機能を使えば、すべての参照箇所を瞬時に変更できる。
// Before: 何の amount?
function calc(amount: number, rate: number) { return amount * rate; }
// After
function calculateInterest(principal: number, annualRate: number) {
return principal * annualRate;
}
11.3 Replace Conditional with Polymorphism
switch や if-else の連鎖を、ポリモーフィズムで置き換える。
// Before
function area(shape: { kind: string; size?: number; w?: number; h?: number }) {
switch (shape.kind) {
case "square": return shape.size! ** 2;
case "rect": return shape.w! * shape.h!;
default: throw new Error("unknown");
}
}
// After
abstract class Shape { abstract area(): number; }
class Square extends Shape {
constructor(private size: number) { super(); }
area() { return this.size ** 2; }
}
class Rectangle extends Shape {
constructor(private w: number, private h: number) { super(); }
area() { return this.w * this.h; }
}
11.4 Introduce Parameter Object
引数が多すぎる関数を、関連する引数をまとめたオブジェクトに置き換える。
// Before
function reserveSeat(flightId: string, customerId: string, seatRow: number, seatCol: string, mealPref: string) {}
// After
type Reservation = {
flightId: string;
customerId: string;
seat: { row: number; col: string };
mealPref: string;
};
function reserve(r: Reservation) {}
11.5 Replace Magic Number with Named Constant
// Before
if (date.getDay() === 0 || date.getDay() === 6) {}
// After
const SUNDAY = 0;
const SATURDAY = 6;
const isWeekend = (d: Date) => d.getDay() === SUNDAY || d.getDay() === SATURDAY;
11.6 Refactor の進め方 — 小さなステップ
リファクタリングの大原則は 「テストを赤くしない」。次のような進め方をする。
- 現在の状態でテストが緑であることを確認。
- 1 つの小さな変換 (リネーム、メソッド抽出、引数追加など) を行う。
- 即座にテストを実行する。
- 緑なら次へ。赤なら直前の変更を戻す。
- 一定の単位でコミット。
「1 つの大きなリファクタ」より「100 個の小さな変換」の方が安全である。途中でいつ中断しても問題ない状態を保つ。
11.7 Refactor とテストコード自身
テストコード自体もリファクタリングの対象である。次のような変換が代表的。
- Extract Test Helper: 重複するセットアップをヘルパー関数に。
- Object Mother / Test Data Builder: 「テスト用の典型的な User」をビルダーで作る。
- Custom Matcher: 複雑な検証を
expect(...).toBeValidUser()のように。
// Test Data Builder の例
class UserBuilder {
private data: User = { id: "1", email: "a@b.com", status: "ACTIVE", name: "A" };
withEmail(email: string) { this.data.email = email; return this; }
withStatus(status: User["status"]) { this.data.status = status; return this; }
build(): User { return { ...this.data }; }
}
// 使う側
const user = new UserBuilder().withStatus("PENDING").build();
テストの可読性が上がり、本質 (このテストでは status が PENDING であることが重要) が際立つ。
12. CI/CD と TDD
12.1 TDD は CI を前提とする
TDD で書いたテストは、ローカルでは数秒で完了する。これを CI で回すことで、
- 全員のコードが常に検証される
- 早期にリグレッションを検知できる
- メインブランチが常にデプロイ可能な状態に保たれる
CI が動かない、あるいは遅すぎる環境では、TDD の効果は半減する。
12.2 GitHub Actions のサンプル
# .github/workflows/test.yml
name: test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
node: [20, 22]
steps:
- uses: actions/checkout@v4
- name: Setup Node ${{ matrix.node }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'pnpm'
- name: Install pnpm
uses: pnpm/action-setup@v3
with: { version: 9 }
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm tsc --noEmit
- name: Unit tests
run: pnpm test:run --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
- name: Mutation tests (PR only)
if: github.event_name == 'pull_request'
run: pnpm stryker run
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: testpw
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: 'pnpm' }
- uses: pnpm/action-setup@v3
with: { version: 9 }
- run: pnpm install --frozen-lockfile
- run: pnpm test:integration
env:
DATABASE_URL: postgresql://postgres:testpw@localhost:5432/postgres
e2e:
runs-on: ubuntu-latest
needs: [test, integration]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: 'pnpm' }
- uses: pnpm/action-setup@v3
with: { version: 9 }
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps
- run: pnpm test:e2e
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
12.3 Pre-commit / Pre-push フック
ローカルでのフィードバックを早めるため、Husky + lint-staged を使う。
// package.json (抜粋)
{
"scripts": {
"prepare": "husky"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"vitest related --run"
]
}
}
# .husky/pre-commit
pnpm lint-staged
# .husky/pre-push
pnpm test:run
vitest related は変更されたファイルに関連するテストだけを走らせるため、コミット時の遅延を最小化する。
12.4 Pull Request ゲート
- すべての PR でテスト・型チェック・Lint がグリーンであること
- カバレッジが下がっていないこと (Codecov / Coveralls の閾値ガード)
- レビュー 1 人以上の承認
これらを GitHub の Branch Protection で強制する。これがないと、TDD の文化はすぐに崩れる。
12.5 テストの並列化と速度最適化
CI で 100 テストが 5 分かかると、開発者は「もう少しまとめて push しよう」と考え始め、フィードバックループが伸びる。次の手を取ろう。
- Vitest の並列実行: デフォルトで CPU コア数だけ並列。
--reporter=verbose --bail=1で早期失敗。 - シャーディング:
vitest --shard=1/4のように複数のジョブで分割。 - キャッシュ: 依存関係の
node_modules、ビルド成果物をキャッシュ。 - 影響テストのみ実行:
vitest relatedや Nx affected で、変更に関連するテストだけを走らせる。
CI のテスト時間を 1 分以内に収めるのが理想である。
13. カバレッジとミューテーションテスト
13.1 ライン/ブランチカバレッジの落とし穴
カバレッジは「テストがコードのどれだけを実行したか」を示す指標だが、実行 ≠ 検証 であることに注意が必要である。
function divide(a: number, b: number) { return a / b; }
it("works", () => {
divide(10, 2); // 何もアサートしていないが 100% カバレッジ
});
このテストはカバレッジ 100% だが、何も検証していない。カバレッジは「テストの量」を示すが、「テストの質」までは保証しない。
適切なカバレッジ目標
- ライン 80〜90% を目標に。100% を強要しない (限界費用が急上昇する)。
- ブランチカバレッジを併用 (条件分岐の網羅率)。
- 新規コードに対しては高い目標 (例: 95%)、既存コードはレガシー扱い。
13.2 ミューテーションテスト
カバレッジの限界を補うのが ミューテーションテスト である。
仕組み:
- 本番コードを意図的に「変異 (mutate)」させる (例:
>を>=に、+を-に変える、return x;をreturn null;に変える)。 - 変異させたコードに対してテストを走らせる。
- テストが落ちれば「殺された (killed)」 ── これは正しい振る舞い。
- テストが通ってしまえば「生存 (survived)」 ── これはテストが甘い、または検証していないケース。
- 殺せた変異の割合 (mutation score) が、テストの「実効性」の指標。
Stryker (TypeScript / JavaScript)
// stryker.conf.json
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"packageManager": "pnpm",
"testRunner": "vitest",
"reporters": ["progress", "clear-text", "html"],
"coverageAnalysis": "perTest",
"mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
"thresholds": { "high": 90, "low": 70, "break": 70 },
"timeoutMS": 30000
}
pnpm stryker run
実行すると、./reports/mutation/mutation.html に詳細なレポートが生成される。生存した変異がどこか、なぜ殺せなかったかが可視化される。
PIT (Java)
Java 系では PIT が代表的。Maven プラグインや Gradle プラグインで実行できる。
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.16.0</version>
<configuration>
<targetClasses><param>com.example.*</param></targetClasses>
<targetTests><param>com.example.*Test</param></targetTests>
<mutationThreshold>80</mutationThreshold>
</configuration>
</plugin>
13.3 ミューテーションスコアと TDD
TDD で素直に書いたテストは、ミューテーションスコアが自然と高くなる。なぜなら、各テストは具体的な振る舞いを検証するために書かれており、その振る舞いを変えるような変異は確実に失敗するからだ。
逆に、テストが「実装の流れ」を写しているだけだと、変異させてもテストが緑のまま、というケースが頻発する。これは テストが実装の写し鏡になっている サインであり、リファクタの余地がある。
13.4 カバレッジツールの実用設定
// vitest.config.ts (再掲)
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov", "json-summary"],
thresholds: {
lines: 90,
functions: 90,
branches: 85,
statements: 90,
},
exclude: [
"node_modules/",
"**/*.config.ts",
"**/*.d.ts",
"src/migrations/**",
],
},
},
});
thresholds で閾値を下回るとテストランが失敗する。CI で品質ゲートとして機能する。
14. アンチパターン
14.1 Test After Development
実装が終わった後にテストを追加するスタイル。「TDD」と区別して TAD (Test-After Development) と呼ばれる。
問題点:
- 実装に都合の良いテストばかり書かれる
- すでに動いているコードを通すだけのテストになる
- 設計のフィードバックが得られない (もう設計は固まっている)
- 「テスト書く時間がない」と削られやすい
「テストはあった方が良い」が「TDD ではない」。
14.2 Ice Cream Cone (アイスクリームコーン)
E2E テストばかり多く、Unit テストが少ない逆ピラミッド。
問題点:
- CI が遅い (E2E は秒〜分単位)
- フレーキー (時々失敗)
- バグの局所化が困難
対策: Unit テストへの投資。E2E はスモークテスト数本に絞る。
14.3 Brittle Mocks (脆いモック)
モックを過剰に使い、内部実装の変更に脆くなる。
// Bad: 内部の呼び出し順序まで検証している
expect(repo.beginTransaction).toHaveBeenCalledBefore(repo.update);
expect(repo.update).toHaveBeenCalledBefore(repo.commit);
// Good: 結果として何が起きたかを検証
const updated = await repo.findById("1");
expect(updated.status).toBe("UPDATED");
「実装をどうするか」ではなく「結果としてどうなるか」を検証する。
14.4 Snapshot Abuse (スナップショット濫用)
Jest / Vitest のスナップショットテストは便利だが、濫用すると「壊れたら更新する」だけのテストになる。
// Bad
expect(renderResult).toMatchSnapshot(); // スナップショットが大きすぎて読めない
// Good
expect(screen.getByRole("heading", { name: "Welcome" })).toBeVisible();
expect(screen.getByLabelText("Email")).toHaveValue("a@b.com");
スナップショットは「全体構造の安定性」を見るための補助。個別の振る舞いは明示的なアサーションで検証する。
14.5 Testing Implementation Details (実装詳細のテスト)
private メソッドや内部の状態をテストすると、リファクタで簡単に壊れる。
// Bad: 内部のキャッシュをテスト
expect(service["_cache"].size).toBe(1);
// Good: 観察可能な振る舞いをテスト
const result1 = await service.fetch("key");
const result2 = await service.fetch("key");
expect(slowApi.callCount).toBe(1); // 2 回目はキャッシュから
expect(result1).toEqual(result2);
14.6 過度なセットアップ (God Fixture)
すべてのテストで使う巨大な beforeEach を作ると、テスト同士が依存し合い、変更が困難になる。
// Bad: 100 行のセットアップを共通化
beforeEach(async () => {
await seedAllUsers();
await seedAllProducts();
await seedAllOrders();
// ...
});
// Good: 各テストが必要な状態を明示的に作る
it("...", async () => {
const user = await new UserBuilder().build();
const product = await new ProductBuilder().withPrice(1000).build();
// ...
});
14.7 Flaky Tests (フレーキーテスト)
時々失敗するテスト。原因の多くは:
- タイミング依存:
setTimeoutの待ち時間に依存 - 時刻依存:
new Date()の値に依存 - 乱数依存: シード固定なしの乱数
- テスト順序依存: 前のテストが残した状態
対策:
- 時刻は
vi.useFakeTimers()で制御 - 乱数はシード固定または DI で注入可能に
- テストは独立 (順序に依存しない) に
- フレーキーなテストは一時的に skip ではなく、原因を突き止めて修正
14.8 Slow Tests (遅いテスト)
Unit テストが 1 秒以上かかるなら、それは Unit テストではない (おそらく I/O が混入している)。
- DB アクセス → InMemory で代替
- HTTP コール → Mock または Adapter
- ファイル I/O → 仮想ファイルシステム
「すべての Unit テストで 1 秒未満」を死守すると、テスト全体の実行時間が線形でしか増えなくなる。
14.9 Mockist が陥りがちな「動かない緑」
すべてのコラボレータをモックにし、Unit テストはすべて緑だが、結合すると動かない、という状態。
対策:
- Contract テストで実装間の整合性を保証
- Acceptance テストで全体としての振る舞いを検証
- アダプタ層は実物相手の Integration テストで補強
14.10 「テストがあるから安全」の過信
カバレッジ 100% でも、検証していない振る舞いはバグる。テストが保証するのは「テストされた振る舞いだけ」 であり、「コード全体の正しさ」ではない。これを過信せず、常に「本当にこれで十分か」を問い続けることが重要である。
15. TDD と他手法の比較
15.1 BDD (Behavior-Driven Development)
BDD は Dan North が「TDD で開発者が困っていた点」を解消するために提唱した。
- 「何をテストすべきか」を仕様シナリオから導く
- ビジネス用語で記述する (テクニカル用語ではなく)
- Given-When-Then 構造で振る舞いを明示
Feature: 給与計算
Scenario: 残業手当
Given 月給 30 万円の社員 "山田" がいる
When 当月の残業時間が 20 時間である
Then 残業手当として 5 万円が加算される
実装の TDD はこのシナリオを満たすために行われる。TDD はミクロ、BDD はマクロのテスト駆動である。
15.2 Property-Based Testing (PBT)
PBT は「特定の入力 → 特定の出力」ではなく「入力の性質 → 出力の性質」を検証する。
import fc from "fast-check";
it("reverse(reverse(arr)) === arr", () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
expect([...arr].reverse().reverse()).toEqual(arr);
})
);
});
it("Money.plus is commutative", () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 1_000_000 }),
fc.integer({ min: 0, max: 1_000_000 }),
(a, b) => {
const left = Money.dollar(a).plus(Money.dollar(b));
const right = Money.dollar(b).plus(Money.dollar(a));
expect(left.equals(right)).toBe(true);
}
)
);
});
PBT は TDD と排他ではなく相補的である。例:
- 例ベース TDD で API の形を整える
- PBT で代数的性質 (結合律、可換律、冪等性) を検証
- 失敗が見つかれば、PBT が示す具体例を例ベースのテストとして残す
15.3 Approval Testing (承認テスト)
「黄金マスター (Golden Master)」とも呼ばれる手法。
import { verify } from "approvals";
it("renders the report", () => {
const html = renderReport(sampleData);
verify(html); // 初回は出力をファイルに保存。次回以降は差分を検出。
});
- 出力が大きくて手で書くのが大変な場合 (HTML、JSON、CSV)
- レガシーコードに後付けのテストを入れたい場合 (現状の振る舞いを「正」とする)
- 振る舞いが変わったら「承認 (approve)」して新しい黄金マスターにする
TDD とは目的が異なる。TDD は「これから書く」もの、Approval Testing は「すでに動いている」ものを保護する。
15.4 Type-Driven Development
静的型システムを「最初の防衛線」と捉え、型でできることは型で表現するスタイル。
type NonEmptyArray<T> = readonly [T, ...T[]];
function head<T>(arr: NonEmptyArray<T>): T {
return arr[0]; // 空配列はコンパイル時に弾かれる
}
「型でガードできるテストは書かない」というトレードオフがある。テストは振る舞いを、型は形を担当する。両者は相補的。
15.5 Contract Testing
サービス間の API の取り決めを検証する。Pact が代表例。
// Consumer
provider.given("a user exists with id 1")
.uponReceiving("a request for user 1")
.withRequest({ method: "GET", path: "/users/1" })
.willRespondWith({
status: 200,
body: { id: "1", name: "Alice" },
});
Consumer 側の期待を Pact ファイルとして書き出し、Provider 側の CI でこの期待を満たすかを検証する。マイクロサービス環境では必須。
15.6 比較表
| 手法 | 駆動するもの | 主な検証対象 | TDD との関係 |
|---|---|---|---|
| TDD | コードの設計 | 個別ユニットの振る舞い | 自分自身 |
| BDD | 仕様の理解 | 受け入れ可能な振る舞い | 上位 / 補完 |
| ATDD | 完了の定義 | ビジネス要件 | 上位 / 補完 |
| PBT | コードの一般化 | 不変条件・代数的性質 | 補完 |
| Approval | レガシー保護 | 出力の同一性 | 異なる目的 |
| Contract | サービス境界 | API の互換性 | 統合層で補完 |
| Type-Driven | データ構造 | 型レベルの正しさ | 補完 |
複数を組み合わせるのが実務での解。TDD だけ、BDD だけ、PBT だけ、では不十分なことが多い。
16. チームへの導入
16.1 段階的な導入戦略
TDD は「明日から全員でやろう」が最も失敗するパターン。次のような段階的アプローチが現実的。
Phase 1: 種まき (1〜3 か月)
- 1〜2 人のチャンピオンが新規コードに対して TDD を実践
- ペアプログラミング・モブプログラミングでスキルを伝播
- 週 1 回のカタ練習会 (1 時間)
Phase 2: 拡大 (3〜6 か月)
- 新規モジュールは TDD 必須に
- レガシーコードへの修正時にも、テストを先に書くことを推奨
- コードレビューで「テストファースト」の証跡を確認
Phase 3: 文化化 (6 か月〜)
- カバレッジ・ミューテーションスコアの目標を設定
- TDD なしの PR はマージしない (例外あり)
- スプリントレビューでテストコードのデモも行う
16.2 Coding Dojo (コーディング道場)
Emily Bache が The Coding Dojo Handbook で体系化した学習形式。
Randori 形式:
- 1 つのカタを選ぶ (FizzBuzz、Bowling Game、Roman Numerals 等)
- 全員で大画面を見る。2 人がタイピング。
- 5 分ごとにドライバ (タイプする人) を交代
- 全員で TDD のサイクルを守る (Red の前にコードを書かない)
- 終了後にふりかえり
道場の効果:
- 知識の共有 (ショートカット、テクニック)
- 個人のクセの修正 (「ここで Refactor 入れた方が良い」というフィードバック)
- 心理的安全性の構築 (全員が学習者)
16.3 ペア / モブプログラミング
TDD はペア / モブと相性が極めて良い。
- 片方が Red を書き、もう片方が Green を書く (Ping-Pong)
- 議論しながらテストの意図を明確にできる
- 設計レビューがコーディングと同時に行われる
A: "次は『負の数を入れたら例外を投げる』のテストを書くね"
B: "それ、本当に例外でいいの? Result 型で Either を返した方が...?"
A: "ああ、それいいね。じゃあ Result 型で書こう"
このような対話が、設計の質を上げる。
16.4 アンチハラスメント
TDD の導入で多いのが「テストを書かないこと」を理由にした個人攻撃。これは逆効果である。
- 「なぜテストを書けなかったのか」をシステムの問題として扱う
- ツール、ビルド速度、知識ギャップのどれが原因かを特定
- レガシーコードでテストが書けない箇所はリファクタの優先度を上げる
16.5 メトリクスの取り扱い
メトリクスは「目的」ではなく「指標」。次のように使う。
| 指標 | 何を見るか | 注意点 |
|---|---|---|
| カバレッジ | テストの量 | 100% を強要しない |
| ミューテーションスコア | テストの実効性 | 計算コストが高い |
| ビルド時間 | フィードバックループ | 5 分超えたら最適化 |
| Flaky 率 | テスト品質 | 1% 超えたら原因究明 |
| PR からマージまでの時間 | プロセス健全性 | 1 日以内が望ましい |
メトリクスを評価対象にすると、Goodhart の法則 (指標が目標になると、指標として機能しなくなる) が発動する。あくまで「健康診断」として扱う。
16.6 レガシーコードへの TDD 導入
Michael Feathers の Working Effectively with Legacy Code (2004) が定番の指針。
- Seam (継ぎ目) を見つける: コードの振る舞いを変えずにテスト用の置き換えを差し込めるポイント
- Sprout Method / Class: 既存メソッドに手を入れず、新しいメソッドを生やしてそこは TDD で書く
- Characterization Test: 現状の振る舞いをテストにする (Approval Testing が有効)
- Strangler Fig パターン: 古いコードを徐々に新しい (TDD で書かれた) コードで覆っていく
レガシーコードに対しては、いきなり TDD を始めるのではなく、まず「現状を保護するテスト」を書いてから手を入れる。
17. ツールとエコシステム
17.1 JavaScript / TypeScript
| ツール | 用途 | 特徴 |
|---|---|---|
| Vitest | Unit / Integration | Vite ベース、ESM ネイティブ、高速 |
| Jest | Unit / Integration | 老舗、エコシステム豊富 |
| Mocha + Chai | Unit | 古典的、組み合わせ自由 |
| Testing Library | UI コンポーネント | 振る舞い中心の検証 |
| Playwright | E2E | 複数ブラウザ対応、トレース機能 |
| Cypress | E2E | 開発者体験良好 |
| Sinon | Spy/Stub/Mock | フレームワーク非依存 |
| MSW | HTTP モック | Service Worker でリアル |
| fast-check | PBT | 高速、シュリンク強力 |
| Stryker | ミューテーションテスト | 主要テストランナーに対応 |
// Vitest + Testing Library + MSW の組み合わせ例
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
import { render, screen } from "@testing-library/react";
const server = setupServer(
http.get("/api/users/:id", () => HttpResponse.json({ id: "1", name: "Alice" }))
);
beforeAll(() => server.listen());
afterAll(() => server.close());
it("shows user name", async () => {
render(<UserProfile id="1" />);
expect(await screen.findByText("Alice")).toBeVisible();
});
17.2 Java
| ツール | 用途 |
|---|---|
| JUnit 5 (Jupiter) | Unit テストの標準 |
| AssertJ | 流暢なアサーション |
| Mockito | Mock/Spy のデファクト |
| Testcontainers | DB / Kafka を Docker で起動 |
| WireMock | HTTP モック |
| REST Assured | API テスト |
| Cucumber | BDD/ATDD |
| PIT | ミューテーションテスト |
| JqWik | PBT |
@Test
void registersUser() {
var repo = mock(UserRepository.class);
var mailer = mock(Mailer.class);
var usecase = new RegisterUser(repo, mailer);
usecase.execute(new RegisterUser.Input("a@b.com", "Alice"));
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(repo).save(captor.capture());
assertThat(captor.getValue())
.extracting(User::email, User::status)
.containsExactly("a@b.com", Status.ACTIVE);
verify(mailer).sendConfirmation("a@b.com");
}
17.3 Python
- pytest: フィクスチャシステムが強力
- unittest.mock: 標準ライブラリ
- Hypothesis: PBT
- mutmut / cosmic-ray: ミューテーションテスト
- tox / nox: 環境マトリクス
17.4 Go
- testing: 標準パッケージ (シンプルで強力)
- testify: アサーション・モック
- gomock: コード生成型のモック
- dockertest: Docker 統合
- gomutesting: ミューテーション
17.5 Rust
- #[test]: 言語組み込み
- mockall: マクロベースのモック
- proptest / quickcheck: PBT
- cargo-mutants: ミューテーション
17.6 Integration テストのインフラ
| ツール | 役割 |
|---|---|
| Testcontainers | 任意のサービスを Docker で起動 |
| WireMock | HTTP のスタブ・モック |
| LocalStack | AWS のローカルエミュレーション |
| Kind / k3d | ローカル Kubernetes |
| Pact / Spring Cloud Contract | Contract テスト |
// Testcontainers (Node.js) の例
import { GenericContainer } from "testcontainers";
let pgContainer: StartedTestContainer;
let pool: Pool;
beforeAll(async () => {
pgContainer = await new GenericContainer("postgres:16")
.withEnvironment({ POSTGRES_PASSWORD: "test" })
.withExposedPorts(5432)
.start();
pool = new Pool({
host: pgContainer.getHost(),
port: pgContainer.getMappedPort(5432),
user: "postgres", password: "test", database: "postgres",
});
await runMigrations(pool);
}, 60_000);
afterAll(async () => {
await pool.end();
await pgContainer.stop();
});
it("persists and reads back a user", async () => {
const repo = new PostgresUserRepository(pool);
await repo.save({ id: "1", email: "a@b.com", status: "ACTIVE", name: "A" });
const found = await repo.findById("1");
expect(found?.email).toBe("a@b.com");
});
これで「実物の PostgreSQL に対する」統合テストが、CI でも開発機でも同じように動く。
18. まとめ
TDD は単なるテスト技法ではなく、設計を駆動する開発のリズムである。本稿で見てきた論点を改めて整理する。
18.1 中核となる主張
- TDD はテストではなく設計のための実践である。最終成果物としてテストが残るのは副作用に近い。本質は「テストを書く過程で得られる設計のフィードバック」である。
- Red-Green-Refactor のリズムを死守する。短く、何度も。Refactor を省略すると技術的負債が、Red を省略するとオーバーエンジニアリングが、それぞれ蓄積する。
- 学派の使い分けが重要。ドメインの中核は Detroit (Sociable, State-based)、境界は London (Solitary, Interaction-based)。一方の学派に固執しない。
- テストダブルは厳密に区別する。Dummy, Stub, Spy, Mock, Fake はそれぞれ目的が違う。「とりあえず Mock」は思考停止。
- Outside-In TDD と Hexagonal Architecture が現代の標準的組み合わせ。受け入れテストから始めて、ドメインへと内側に進む。
- CI とミューテーションテストが TDD を支える。テストの「量」だけでなく「実効性」を測る。
18.2 TDD で得られるもの
- 動作するきれいなコード — Beck が掲げた目標そのもの
- 設計のフィードバックループ — 数秒〜数十秒で「これでいいか」が分かる
- 回帰バグからの保護 — リファクタリングが恐れずできる
- ドキュメントとしてのテスト — 振る舞いが実行可能な形で残る
- チームの共通言語 — テストの読み書きが設計議論の土台になる
18.3 TDD で得られないもの
TDD は万能ではない。次のことは TDD だけでは解決しない。
- 要件の正しさ — TDD はテストが正しいことを前提とする。要件分析は別の活動。
- アーキテクチャの大きな決定 — モジュール分割や技術選定は、TDD のサイクルの外で行う。
- パフォーマンス — Unit テストでは性能問題は捕まらない。負荷テストが必要。
- UX の質 — 動くからといって使いやすいとは限らない。
- セキュリティ — 専門のツールとレビューが別途必要。
18.4 これから始めるための手順
TDD を未経験のチームが始めるなら、次の順で。
- 個人のカタ練習 — FizzBuzz、Bowling Game、Roman Numerals を 1 週間。手と目に Red-Green-Refactor のリズムを覚えさせる。
- 新規コードに限定して TDD — 既存コードはいったん放置。新しいモジュールから始める。
- ペア / モブで実践 — 一人だとサボりがちなので、二人以上で。
- CI を整える — テストが落ちたら気づける環境を先に作る。
- 少しずつレガシーへ — Characterization テストから始めて、小さく覆っていく。
- コーチを呼ぶ — 外部のコーチが半日入るだけでも一気に加速する。
18.5 AI 時代の TDD
2026 年現在、AI コード生成が日常化している。この文脈で TDD は次のような形で価値を再定義されつつある。
- テスト = AI への仕様書: 「このテストを通すコードを書いて」と頼めば、AI は明確な目標を持つ。
- AI が書いたコードを TDD のテストで検証: テストがないと「動いているように見える」だけのコードを信じることになる。
- AI が単調な Green の実装を担当、人間は Red と Refactor に集中: 役割分担が明確化。
- AI ペアに対する規律としての TDD: 1 回のプロンプトで巨大な実装を生成させず、サイクル単位で対話する。
TDD は古くて新しい。コードを書く前に、何を達成したいかをテストで宣言する という姿勢は、AI と協働する開発でこそ威力を発揮する。
18.6 最後に
TDD はスキルである。最初は遅く、ぎこちなく、面倒に感じるはずである。Kent Beck は「TDD は道具であり、宗教ではない」と繰り返し述べている。本稿で示した原則・パターン・アンチパターンを参考に、自分のコンテキストで「使える形」を見つけてほしい。
すべてのテストを書く必要はない。すべてのコードに先にテストを書く必要もない。ただし、書こうと決めたテストは、書く前に書く。この一線を守ることが、TDD のすべてである。
"Test-Driven Development is a way of managing fear during programming." — Kent Beck, Test-Driven Development: By Example
恐怖に飲まれず、小さく前進する。これが TDD の真髄である。