working-with-legacy-code について
Michael C. Feathersの著書をもとに、テストのないレガシーコードを変更する開発者向けの実践ガイドです。テストの導入、依存関係の分離、検証可能な変更を段階的に進め、安易な書き直しを避けます。
主な機能
- 01テストポイントからリファクタリングまでの手順
- 02依存関係のシームと有効化ポイント
- 03特性テストとゴールデンマスター
- 04SproutとWrapの手法
- 05依存関係を切る技法の一覧
活用シーン
- 01未テストのモジュールにテストハーネスを導入
- 02レガシーコードの不具合を安全に修正
- 03リファクタリングと動作変更の分離
- 04DB・時計・I/Oに依存するコードのテスト
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ wondelai/ skills --skill working-with-legacy-codeインストール後、Claude Code で/working-with-legacy-codeを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/case-studies.mdchange-algorithm.mdcharacterization-tests.mddependency-breaking.mdseams.md詳細
レガシーコードを効果的に扱う
テストのないコードを変更するための実践マニュアルです。Michael C. Feathers の Working Effectively with Legacy Code から要点をまとめています。テストできないクラスをテストハーネスに組み込み、特性テストで現在の振る舞いを固定し、書き直しに頼らず、一度に一つずつ安全性を検証しながら変更するために活用してください。
基本原則
レガシーコードとは、単にテストのないコードです。 古いコードでも、醜いコードでもなく、テストされていないコードです。テストがなければ、変更によって振る舞いが保たれるかどうかわからず、編集はすべて賭けになります。大切なのは、何かを変更する前に、テストを用意できる程度に依存関係を切り離すことです。まずテストで覆ってから変更し、編集して祈るのはやめましょう。
スコアリング
目標:10/10。 以下の原則に照らして、テストのないコードへの変更を 0~10 で評価してください。現在のスコアと、10/10 に到達するために必要な具体的な手順を報告してください。
- 9~10: 変更箇所を編集前に特性テストでカバーしている。振る舞いの変更とリファクタリングを、個別に検証した別々の手順として提供している。最も侵襲性の低い手法で依存関係を切り離している
- 7~8: ほとんどの変更箇所にテストがあるものの、リファクタリングと振る舞いの変更を混在させたコミットが時折ある、または必要以上に大掛かりな依存関係の整理を行っている
- 5~6: 特性テストはいくつかあるものの、重要な経路の変更を確信に頼って行っている。見返りを得る計画のないまま、スプラウトコードが蓄積している
- 3~4: 手作業で確認する「編集して祈る」方式。変更後にテストを書き、新しいコードがたまたま行う処理をそのまま期待値としている
- 0~2: 絡み合ったコードをテストせずに直接編集し、リファクタリングと振る舞いの変更を一つのコミットに混在させ、テストではなく書き直しを提案している
フレームワーク
1. レガシーコードのジレンマと変更アルゴリズム
基本概念: ジレンマは、コードを安全に変更するにはテストが必要なのに、テストを用意するにはコードを変更しなければならないことです。これを解決するには、変更箇所を特定し、テスト箇所を見つけ、依存関係を切り離し、テストを書いてから、変更とリファクタリングを行うという、決まった順序に従います。テスト前の編集は慎重かつ機械的に行い、本当の変更はセーフティネットの内側だけで行います。
効果がある理由: 編集して祈る方法では、フィードバックの代わりに注意深さを当てにします。しかし、十分に理解できていないコードに対して、注意深さだけでは対応しきれません。テストで覆ってから変更することで、既存の振る舞いをテストで強固に固定できます。そのため、意図しない変更があれば、後で本番環境に現れるのではなく、すぐに手元で検知できます。
重要なポイント:
- コードを変更する理由は二つあります。振る舞いを変更すること(機能追加、バグ修正)と、構造を改善すること(リファクタリング)です。一度に両方を行うと、失敗の原因を診断できなくなります
- テスト箇所は変更箇所とは異なることがほとんどです。影響は伝播するため、編集した場所ではなく、変更の影響が表れる場所をテストすることがよくあります
- テストがまだない段階で依存関係を切り離す編集を行う場合は、シグネチャを正確に維持し、影響を受ける箇所をすべてコンパイラーで見つけます
- カバレッジは実際に変更した経路に沿って広がります。予算がつかずに終わる専用の「テストプロジェクト」よりも効果的です
- 「プログラミングとは、一度に一つのことを行う技術です」。アルゴリズムの各手順は、それぞれ個別に検証できます
活用例:
| 文脈 | 活用方法 | 例 |
|---|---|---|
| テストのないモジュールのバグ修正 | バグに触れる前に、5つの手順を実行する | parseInvoice() をテストで固定してから、丸め誤差を修正する |
| 整理と機能追加が混在した PR | 構造だけを変更するコミットと、振る舞いだけを変更するコミットに分ける | 先に抽出と名前変更を行い、テストが成功した後で割引ルールを追加する |
| 「たった1行の変更」 | まず最も近いテスト箇所を見つける | 編集するプライベートメソッドを呼び出すパブリックメソッドに、1つのピンテストを追加する |
実際の変更で5つの手順を実行する際は、references/change-algorithm.md を参照してください。変更箇所とテスト箇所のチェックリスト、および「時間がない」場合の優先順位付けを含む、実務手順としてのアルゴリズムが記載されています。
2. シーム:コードを分割する場所
中心となる概念: シームとは、その場所を編集せずにプログラムの動作を変更できる箇所です。すべてのシームには有効化ポイントがあります。つまり、どの動作を実行するかを決める場所です。レガシーコードをテスト可能にする作業の大部分は、シームを探すことです。テストで、本番ソースを変更せずに、遅い依存関係、グローバルな依存関係、または外部依存関係を置き換えられる箇所を探します。
効果がある理由: テストするためにコードを編集しなければならない場合、特定しようとしている動作そのものを変えてしまうリスクがあります。シームを使うと、置き換えをサブクラス、インポート、ビルドフラグなどの場所に移せるため、テスト対象のコードは本番とまったく同じように実行され、テストは有効化ポイントから依存関係を制御できます。
主な知見:
- オブジェクトシームは、オブジェクト指向コードにおける基本形です。オーバーライド可能な呼び出しはすべてシームであり、その有効化ポイントはオブジェクトが作成または渡される場所です。
- リンクシームとインポートシームは、ビルド時またはロード時に実装を差し替えます。
jest.mockとunittest.mock.patchは、現代的な形のリンクシームです。 - プリプロセッサシーム(C/C++ マクロ)は最も荒っぽい手段です。使うのは最後にしましょう。
- メソッド本体の中にある
new Database()は、作られることのなかったシームです。実際の処理を行うコンストラクター、グローバル変数、静的要素、ハードコードされた I/O が、シームを失わせる原因です。 - 到達可能な有効化ポイントのないシームは役に立ちません。テストからその判断を制御できない場合は、シームを探し続けましょう。
- 動的言語では、ほぼすべての名前検索がシームになります。手軽ですが、内部実装のパッチはテストをファイル構成に密結合させます。
適用例:
| コンテキスト | 適用方法 | 例 |
|---|---|---|
| クラスが DB クライアントを自分で構築する | コンストラクターパラメーターによるオブジェクトシーム | constructor(db: Db = new ProdDb()) — テストではフェイクを渡します |
モジュールがトップレベルの send_email() を呼び出す | インポート/リンクシーム | mocker.patch("billing.send_email") または jest.mock("./mailer") |
| ロジックがシステム時刻を直接読み取る | 時計のシーム | now() プロバイダーを注入し、テストでは時刻を固定します |
特定のスタックでシームを探す場合は、references/seams.md を参照してください。コードと有効化ポイントを含むシームのカタログに加え、現代的なツールにおけるシーム(DI コンテナー、jest.mock、pytest monkeypatch、時計や設定のシーム)を掲載しています。
3. 特性テスト
中核となる考え方: 特性テストでは、仕様書やコメント、誰かの記憶にある動作ではなく、コードが今実際に行っている動作を記録します。失敗すると分かっている検証コードを書き、失敗メッセージから実際の動作を明らかにしてから、検証内容を変更してその動作を固定します。
有効な理由: レガシーシステムでは、実際の動作が事実上の仕様です。癖も含めて、呼び出し元やレポート、顧客がその動作に依存している場合があります。想像上の要件に基づいて書いたテストは、何の手がかりにもならない理由で失敗します。一方、特性テストはリファクタリング中に既存の動作を変更したとき、まさにその箇所で失敗します。
主なポイント:
- 手順:ハーネス内でコードを呼び出し、あり得ない値を検証します(
expect(total).toBe(-1))。失敗内容を読み取り、確認できた値を固定します - 依存関係を分離する理由は、センシングと分離の2つです。分離によってコードをハーネス内で実行できるようにし、センシングによって検証コードから計算結果を確認できるようにします
- レポート、生成ファイル、大きな JSON など、複雑な出力にはゴールデンマスターを使います。一度出力全体を保存し、その後は常に差分を比較します
- スナップショットテストはゴールデンマスターです。最初のスナップショットをコードと同じようにレビューし、変動するデータを正規化してください。そうしないと、ノイズまで固定してしまいます
- 特性を調べているときにバグが見つかった場合は、コメントとチケットを添えてその動作を固定します。後続のコードが誤った動作に依存している可能性があるため、修正は後で、意図を明確にした別の変更として行います
- システム全体ではなく、変更が及ぶ分岐を特性テストで記録します。カバレッジは変更に合わせます
適用例:
| コンテキスト | 適用方法 | 例 |
|---|---|---|
| 税額計算機のリファクタリング | 代表的な入力に対する出力を固定する | 20件のケースを実行し、それぞれ記録した結果を検証する |
| レガシーなレポート生成器 | ゴールデンマスターとの差分を確認する | レポートを生成し、リポジトリにチェックイン済みのマスターファイルと比較する |
| 値を固定する際に見つかった1つずれのバグ | 誤った値を固定し、記録する | assert days == 30 # BUG? expected 31 — TICKET-482 |
最初の検証コードを書くところからテストスイートで動作を固定するところまで、ゴールデンマスター、適切なスナップショットテスト、リファクタリングの変更前後の実例については、references/characterization-tests.md を参照してください。
4. スプラウトとラップ:テストを先に書けない場合の変更
中心となる概念: 今日、対象領域をテスト可能な状態にできない場合は、新しいロジックを未テストのコードの塊に織り込まないでください。スプラウトメソッドまたはスプラウトクラスを使い、新しい振る舞いを新規の完全にテスト済みのコードとして実装し、レガシーコードの該当箇所から1行で呼び出します。ラップメソッドまたはラップクラスを使う場合は、古いコードの名前を変更して退避し、その呼び出しの前後にデコレーターのように振る舞いを追加します。
有効な理由: 新しいメソッドやクラスであれば、実行環境でホストをインスタンス化できない場合でも、テスト駆動で実装できます。テスト可能にするために、ホストを実行環境に組み込む必要はありません。未テストのホストへの変更は呼び出し箇所1つだけなので、検証されていない影響範囲はメソッド全体ではなく1行にとどまります。
主なポイント:
- 新しいロジックを1か所に差し込む場合はスプラウトメソッド、テスト実行環境でホストクラスをインスタンス化することすらできない場合はスプラウトクラスを使います。
- ロギング、通知、計測など、古いコードと混ぜるのではなく、その前後を囲む振る舞いにはラップメソッドが適しています。
pay()の名前をrawPay()に変更し、ラッパーとしてpay()を作り直します。 - ラップクラスはデコレーターパターンです。複数の呼び出し箇所に追加の振る舞いが必要な場合や、クラスがすでに肥大化している場合に使います。
- トレードオフを正直に把握してください。ホストは未テストのままであり、良いコードを好ましくない場所に追加したことになります。
- スプラウトを負債として記録し、返済してください。次にその箇所を変更するときに、ホストをカバーします。
- スプラウトは変更アルゴリズムにおける戦術的な手段であり、コードをテスト可能にする取り組みの恒久的な代替策ではありません。
適用例:
| コンテキスト | 適用方法 | 例 |
|---|---|---|
400行の process() における延滞料金ルール | スプラウトメソッド、呼び出し1行 | total += lateFee(order) — lateFee() はテストを先に書いて実装 |
レガシーな pay() の前後での監査ログ記録 | ラップメソッド | 新しい pay() がログを記録し、rawPay() を呼び出して、再度ログを記録 |
| クラスをインスタンス化できない場合の新しい検証 | スプラウトクラス | レガシーコードから new OrderValidator().validate(data) を呼び出す |
5. 依存関係を断ち切るテクニック
中核となる概念: インスタンス化や検知を妨げるものを取り除く、機械的でリスクの低い手法の一覧です。インターフェースの抽出、コンストラクターのパラメーター化、メソッドのパラメーター化、ファクトリーメソッドまたはゲッターの抽出とオーバーライド、インスタンス委譲メソッドの導入、パラメーターの適合、メソッドオブジェクトの切り出し、サブクラス化とメソッドのオーバーライドがあります。テストが存在する前に実施するため、行き詰まりを解消できる最も侵襲性の低い手法を常に選んでください。
効果がある理由: コードのテストを難しくする理由は、繰り返し現れる少数のパターンに集約されます。実際の処理を行うコンストラクター、静的メンバーやシングルトン、生成できないパラメーター、巨大なメソッドなどです。それぞれの障害には名前の付いた、実践済みの対処法があるため、安全網のないコードに即興でメスを入れる代わりに、既知の手法を実行できます。
主な知見:
- 本番環境向けのデフォルト値を設定するコンストラクターのパラメーター化は、最も頼りになる手法です。既存の呼び出し元は変更せずにコンパイルでき、テストではフェイクを注入できます。
- インターフェースの抽出は、最も安全な手法です。インターフェースを導入しても動作は変わらず、型の制約が緩和されるだけです。
- サブクラス化とメソッドのオーバーライドは、この一覧の半分を支える手法です。危険な部分をスタブ化するテスト用サブクラスは、正当なツールであり、ハックではありません。
- 静的メンバーやシングルトンには、インスタンス委譲メソッドの導入によって呼び出し元に差し替え可能なインスタンスを渡せます。テストでは、静的セッターを使ってシングルトンを置き換えることもできます。
- フェイク化できないフレームワーク型に苦労するより、パラメーターの適合を使いましょう。
HttpServletRequestを独自の狭いインターフェースでラップし、それを使ってテストできます。 - 動的言語では、継ぎ目をより簡単に作れます。
unittest.mock.patchやjest.mockで複数の手法を代用できますが、パラメーター化のほうが、より良い設計を残せます。
適用例:
| コンテキスト | 適用方法 | 例 |
|---|---|---|
| コンストラクターが DB 接続を開く | コンストラクターのパラメーター化 | def __init__(self, conn=None): self.conn = conn or connect() |
静的メソッド Billing.charge() があらゆる箇所で呼び出される | インスタンス委譲メソッドの導入 | インスタンスの charge() が静的メソッドに委譲し、テストではオーバーライドする |
| 900 行のメソッドがローカル変数を抱え込んでいる | メソッドオブジェクトの切り出し | new RateCalculation(order, rates).run() — ローカル変数をフィールドにする |
特定の障害によってインスタンス化や検知ができない場合は、references/dependency-breaking.md を参照してください。各手法の変更前と変更後のコードに加え、障害と適切な手法を対応づける判断表があります。
6. もつれを解き、理解する
中核となる考え方: 理解していないコードを変更する前に、少ないコストで理解することに時間をかけます。影響スケッチは変更によって影響を受ける箇所をたどり、機能スケッチは巨大クラス内でメソッドやフィールドがどのようにまとまっているかを示します。スクラッチリファクタリングとは、学ぶために大胆にリファクタリングし、その後編集を破棄することです。そして、システムの仕組みを説明することで、単純化した要約を強いられます。その成果として、狭い範囲のテストで広範な動作をカバーできる要所が見つかります。
効果がある理由: レガシーコードでのボトルネックは、タイピングではなく理解です。影響スケッチを使えば、「何が壊れる可能性があるか」という不安を有限のリストに変えられます。また、要所にテストを置けば、いくつかのテストでメソッド群全体をしっかり押さえられ、多くの場合、隠れていたクラス境界をどこに引くべきかも明らかになります。
主な洞察:
- 影響スケッチ:変数またはメソッドごとに円を描き、「影響する」関係ごとに矢印を引きます。変更箇所から先をたどり、動作が及ぶ可能性のあるすべての箇所を確認します
- 要所とは、影響スケッチが狭まる場所です。そこでテストすれば、その上流にあるものすべてをカバーできます
- スクラッチリファクタリングは、読むための手法としてのリファクタリングです。1時間ほど抽出、名前変更、単純化を行ってから元に戻します。得た知見はチェックアウト後も残ります
- 巨大メソッドへのアプローチ:要所でゴールデンマスターテストを作成し、Break Out Method Objectを行ってから、新しいクラスの内部をリファクタリングします
- 巨大クラスへのアプローチ:機能ごとのまとまりをスケッチし、自然な境界に沿って抽出します
- 時間がない場合のトリアージ:ある箇所を変更するのが初めてなら、スプラウトまたはラップを追加します。同じ箇所を再び変更するなら、その箇所にはテストを追加する価値があります
適用例:
| 状況 | 適用方法 | 例 |
|---|---|---|
| 「このフィールドを変更したら、何が壊れるか?」 | フィールドから外側へ影響スケッチを作成する | 3つの参照箇所が見つかり、2つの要所テストでカバーできた |
| 5,000行のクラスに機能を追加する | 要所のテストを作成し、その後スプラウトを追加する | postInvoice()をカバーし、新しいルールをクラスとしてスプラウトする |
| チームの誰も理解していないコード | ブランチでスクラッチリファクタリングを行う | 学ぶために抽出と名前変更を行い、元に戻してから、本番の変更を計画する |
一連の手順を詳しく確認したい場合は、references/case-studies.mdを参照してください。未テストの800行のサービスへの機能追加、シングルトンだらけのモジュールへのテスト導入、バグ修正前の巨大メソッドの整理という3つのシナリオを扱っています。
よくある間違い
| 間違い | うまくいかない理由 | 修正方法 |
|---|---|---|
| リファクタリングと動作変更を同時に行う | 何かが壊れたとき、どの編集が原因か判断できません | コミットを分け、各ステップの間でテストを成功させます |
| レガシーコードに「こうあるべき」というテストを書く | 想像上の仕様が騒がしく失敗し、動作を支えるコードを「修正」してしまいます | コードが実際に何をするか特性化し、バグは別途報告します |
| 目につくものをすべてモックする | テストが実装に強く依存し、リファクタリングのたびに壊れます | インスタンス化または動作の検知を妨げるものだけをフェイクにします |
| 段階的なカバレッジ拡大ではなく、一度に全面書き換えを行う | 古いシステムは変化し続け、書き換えはリリースが遅れて、長年のエッジケースを見落とします | 少しずつカバーしながら変更します |
| 特性化の最中に見つけたバグを、黙って修正する | 呼び出し元やレポートが誤った動作に依存している場合があります | その動作をテストで固定し、記録してから、別の意図的な変更として修正します |
| テストが一つもない段階で大掛かりな整理を行う | 手作業による編集のたびに、セーフティネットなしで動作を変えるリスクがあります | 影響を最小限に抑える手法を使い、シグネチャを維持し、コンパイラを活用します |
| スプラウトを永久に追加し続ける | ホストコードは未テストのままで、スプラウトが次のレガシー層として固まってしまいます | スプラウトの負債を追跡し、次に触れる際に変更頻度の高い箇所をカバーします |
| 専用の「テストプロジェクト」を待つ | そのプロジェクトに予算が付くことはなく、カバレッジも増えません | リリースする変更のたびにカバレッジを広げます |
クイック診断
| 質問 | いいえの場合 | 対応 |
|---|---|---|
| 変更しようとしているコードをテストでカバーしていますか? | 編集して祈るだけです | 変更アルゴリズムを実行し、編集前に振る舞いをピン留めします |
| テストハーネスでクラスを構築できますか? | 依存関係が分離を妨げています | コンストラクターのパラメーター化、インターフェースの抽出、またはクラスの新規作成を行います |
| テストで変更の影響を検知できますか? | アサーションから影響が見えません | 検知ポイントを見つけ、ゲッターを抽出してオーバーライドします |
| このコミットは振る舞いのみ、または構造のみの変更ですか? | 混在しています | 分割し、2つの変更の間でテストを実行します |
| この変更が影響し得るすべてを把握していますか? | 影響範囲が不明です | 影響の概略図を描き、要所でテストします |
| アサーションは観測された振る舞いを示していますか? | 期待をテストしています | 確認し、失敗を読み、実際の値をピン留めします |
| 選んだシームは、利用可能な中で最も手軽なものですか? | 不要な外科手術です | まずコンストラクターのパラメーターとインポートのシームを優先します |
| この変更後、コードのテストカバレッジは向上しますか? | 次の変更にも今回と同じコストがかかります | 最も近いテストポイントに、少なくとも1つのピン留めテストを残します |
さらに読む
- "Working Effectively with Legacy Code" 著 Michael C. Feathers
- "Refactoring: Improving the Design of Existing Code" 著 Martin Fowler
- "Tidy First?: A Personal Exercise in Empirical Software Design" 著 Kent Beck
- "Kill It with Fire: Manage Aging Computer Systems (and Future Proof Modern Ones)" 著 Marianne Bellotti
著者について
Michael C. Feathers は、ソフトウェア設計と老朽化したシステムの再生に注力するコンサルティング会社、R7K Research & Conveyance の創設者です。レガシーコードを専門とする長年のコンサルタントであり、カンファレンスの講演者でもある彼は、Working Effectively with Legacy Code(2004年)を執筆し、この分野に実用的な定義をもたらしました。レガシーコードとは、単にテストのないコードのことです。