Use Cases (更新: 2026/6/7)

テストがないレガシーコードをClaude Codeで段階置き換えする手順

テストのない古いコードを、特性評価テストで現状を固定し、ストラングラーフィグ・パターンで少しずつ置き換える。Claude Codeで安全に進める実践手順を、動くコードと失敗談つきで。

テストがないレガシーコードをClaude Codeで段階置き換えする手順

「この関数、誰が書いたか分からないけど、触ると請求がバグるから放置で」

前職のチームで、僕が一番よく聞いたセリフです。10年もののPHP混じりの注文処理に、テストはゼロ。仕様書は退職した先輩の頭の中。動いてはいる。でも誰も中身を理解していない。そういうコードが、月末の売上計算を静かに支えていました。

Claude Codeが来て、最初に思ったのは「これで一気に書き直せる」でした。先に言っておくと、その発想で突っ込んで、見事にやらかしました。AIに「きれいにして」と頼んだ翌日、丸め処理が変わって請求額が1円ずつズレていたんです。誰も気づかないまま3日経った。冷や汗ものでした。

それ以来、僕のレガシー改善の進め方はガラッと変わりました。鍵は2つ。今の振る舞いをテストで先に固めること、そしてストラングラーフィグ・パターンで古い実装を少しずつ絞め殺すように置き換えること。今日はその手順を、コピペで動くコードと一緒に書きます。

この記事の要点

  • レガシーコードは「古い」のではなく「テストがなくて触れない」コード。先に直すべきはコードより安全網
  • 仕様が分からなくても書けるのが特性評価テスト(characterization test)。今の出力をそのまま正解として固定する。
  • 一気に書き換えず、新旧を並走させて1経路ずつ移すストラングラーフィグ・パターンで事故を局所化する。
  • Claude Codeには最初「読むだけ・変更しない」で地図を作らせ、テストが緑になってから小さく直させる。
  • 1PRに「型変更・移動・仕様変更・依存更新」を混ぜると、AIが正しく動いてもレビューが破綻する。

レガシーコードの正体は「古さ」ではなく「触れなさ」

レガシーコードという言葉、つい「古いコード」と訳しがちです。でも現場の痛みはそこじゃない。本当に怖いのは、変更した瞬間に別の画面や帳票が壊れそうで、誰も触りたがらない状態のほうです。書いた本人がいない。テストがない。だから動作の保証が、祈りしかない。

マイケル・フェザーズの定義が一番しっくりきます。「レガシーコードとは、テストのないコードである」。古くても、テストでガッチリ守られていれば怖くない。逆に昨日書いたコードでも、テストがなければもうレガシーです。

ここがClaude Codeの使い方を分ける分岐点になります。テストがない状態でAIに書き換えさせるのは、目隠しで手術するようなもの。腕がいいほど、間違った場所を素早く切る。だから順番が大事です。観察 → 現状をテストで固定 → 小さく置き換える。この順番を守るだけで、AIを使う・使わないに関係なく事故が激減します。公式のClaude Code common workflowsでも、コード理解・リファクタリング・テスト・PR作成を段階的に回す流れが示されています。

ここで一つ、用語を平易にしておきます。この記事で何度か出てくる「足場」は、harness(ハーネス)の訳です。Claude Codeが安全に作業するためのテスト・設定・手順書のまとまり、くらいに思ってください。足場なしで高所作業をしないのと同じで、足場なしで大規模変更を始めない。それだけの話です。

まず「変更しない」でClaude Codeに地図を描かせる

最初にやるのは、書き換えではなく棚卸しです。Claude Codeにコードを読ませて、地図を作らせます。ここで一番大事なのは「まだ変更しないでください」と明示すること。初回から編集を許すと、仕様が分からない状態で「正しそうな差分」が出てきて、それを信じてしまう。これが事故の入り口です。

@src/legacy と @test を読んでください。
まだファイルは変更しないでください。

次の形式で調査結果を出してください。
1. 主要ファイルと責務
2. 外部I/O(DB、API、ファイル操作、グローバル変数などの副作用)
3. 仕様として守るべき振る舞い(金額・丸め・税・割引・エラー文言)
4. テストが足りない箇所
5. 小さく安全に直す順番(1回の差分は100行以内を目安に)

不明な仕様は推測せず「要確認」と書いてください。

このプロンプトは、Claude Codeを「実装者」ではなく「レビュアー兼調査員」として使うためのものです。公式のHow Claude Code worksにある通り、Claude Codeはファイルを読み、コマンドを実行し、編集もできます。能力があるからこそ、最初のフェーズはあえて手を縛る。出てきた地図の「要確認」項目が、あなたが次に人間に聞くべきリストになります。

仕様が分からなくても書ける:特性評価テスト

ここが今回の山場です。「テストを書け」と言われても、仕様が分からないから困っているわけで。そこで使うのが**特性評価テスト(characterization test)**です。

考え方はシンプル。正しい仕様を書くのではなく、今の出力をそのまま正解として固定する。たとえ今の挙動がバグっていても、まず「現状」を凍結する。リファクタリング中にうっかり挙動を変えてしまったら、テストが赤くなって教えてくれる。安全網としてはこれで十分です。

記事用に小さくした注文処理で手を動かしてみます。実際の業務コードはDBや外部APIが絡みますが、考え方は同じです。

mkdir legacy-modernization-demo
cd legacy-modernization-demo
npm init -y
npm install -D vitest typescript @types/node
npm pkg set type="module"
npm pkg set scripts.test="vitest run"
npm pkg set scripts.typecheck="tsc --noEmit"
mkdir -p src/legacy test

古い実装は、1ファイルにロジックが固まっています。極端に汚くはせず、現場でよくある「動くけど触りにくい」温度感にしています。

// src/legacy/orderProcessor.js
export function processOrder(order) {
  if (!order || !Array.isArray(order.items) || order.items.length === 0) {
    return { status: "error", message: "items is required" };
  }

  const subtotal = order.items.reduce((sum, item) => {
    return sum + item.price * item.qty;
  }, 0);

  const discount = order.customer?.type === "vip" ? subtotal * 0.1 : 0;

  return {
    status: "confirmed",
    total: subtotal - discount,
    items: order.items,
    discount
  };
}

次が特性評価テストです。ポイントは「理想の仕様」ではなく「今壊してはいけない現状」を書くこと。VIP割引が10%なのが正しいかどうかは、いったん問わない。今そうなっている、という事実を固定します。

// test/orderProcessor.test.ts
import { describe, expect, it } from "vitest";
import { processOrder } from "../src/legacy/orderProcessor.js";

describe("processOrder の現状の振る舞いを固定する", () => {
  it("一般顧客の合計を現状どおり計算する", () => {
    const result = processOrder({
      items: [
        { id: "A1", qty: 2, price: 1000 },
        { id: "B2", qty: 1, price: 500 }
      ],
      customer: { id: "C1", type: "regular" }
    });

    expect(result).toMatchObject({
      status: "confirmed",
      total: 2500,
      discount: 0
    });
  });

  it("VIPには現状10%割引が入る", () => {
    const result = processOrder({
      items: [{ id: "A1", qty: 1, price: 10000 }],
      customer: { id: "C2", type: "vip" }
    });

    expect(result.status).toBe("confirmed");
    expect(result.total).toBe(9000);
    expect(result.discount).toBe(1000);
  });

  it("itemsが空ならエラー文言を返す", () => {
    const result = processOrder({
      items: [],
      customer: { id: "C3", type: "regular" }
    });

    expect(result.status).toBe("error");
    expect(result.message).toContain("items");
  });
});

npm test が緑になったら、ここでようやくAIに手を入れさせます。コツは、出力を観察してテストに落とす作業をClaude Code自身にも手伝わせること。「いろんな入力を投げて、返り値を toMatchObject で固定するテストを20件作って」と頼むと、人間が見落とす境界値まで拾ってくれます。ただし生成されたテストは必ず人間が読む。AIが「これが正しい」と決めた値を、現状確認なしで信じない。テスト戦略の優先順位そのものに迷ったら、テスト戦略の決め方Claude CodeでTDDを実践する方法も合わせて読むと、どこから固めるかの判断が早くなります。

ストラングラーフィグ・パターンで一経路ずつ絞め殺す

テストで現状を固めたら、次は置き換え方です。ここで多くの人が「よし全部書き直すぞ」と一気にいって溺れます。僕もそうでした。

代わりに使うのがストラングラーフィグ・パターン。締め殺しの木(イチジクの一種)が、宿主の木に巻きついて少しずつ覆い、最後に元の木を内側から置き換えてしまう——あの生態が名前の由来です。マーティン・ファウラーが提唱した移行戦略で、要は新しい実装で古い実装を一枚ずつ包み、経路を1本ずつ移し、全部移ったら古い方を消す

コードで見るとイメージしやすいです。まず古い processOrder を直接呼ばず、入口(ファサード)を1枚かませます。

// src/orderFacade.ts
import { processOrder as legacyProcessOrder } from "./legacy/orderProcessor.js";
import { processOrder as modernProcessOrder } from "./modern/orderProcessor.js";

// 環境変数や顧客IDで「どちらの実装に流すか」を切り替える門。
// 最初は legacy 100%。安全を確認した経路だけ modern に倒していく。
const USE_MODERN = process.env.USE_MODERN_ORDER === "1";

export function processOrder(order: unknown) {
  if (USE_MODERN) {
    return modernProcessOrder(order as never);
  }
  return legacyProcessOrder(order as never);
}

この門が入ったら、新実装 src/modern/orderProcessor.ts を、特性評価テストを緑に保ったまま育てます。新旧の出力を突き合わせる「シャドー実行」を挟むと、さらに安全です。

// src/orderShadow.ts
import { processOrder as legacy } from "./legacy/orderProcessor.js";
import { processOrder as modern } from "./modern/orderProcessor.js";

// 本番は legacy の結果を返しつつ、裏で modern も実行して差分だけログる。
// ユーザーに影響を出さずに「新実装が現状と一致するか」を本番データで検証できる。
export function processOrderWithShadow(order: unknown) {
  const result = legacy(order as never);

  try {
    const shadow = modern(order as never);
    if (JSON.stringify(shadow) !== JSON.stringify(result)) {
      console.warn("[shadow-diff]", { input: order, legacy: result, modern: shadow });
    }
  } catch (e) {
    console.warn("[shadow-error]", { input: order, error: String(e) });
  }

  return result;
}

進め方はこうです。①ファサードで入口を一本化する → ②新実装を書き、特性評価テストで現状一致を確認する → ③シャドー実行で本番データの差分ログを数日眺める → ④差分ゼロを確認したら門を modern に倒す → ⑤旧実装と門を消す。1経路ずつなので、何かあっても被害はその経路だけ。フラグを戻せば即座に旧実装へ復帰できます。これがレガシー改善で僕が手放せなくなった型です。

Claude Codeへの依頼も、この粒度に合わせます。

@src/legacy/orderProcessor.js と @test/orderProcessor.test.ts を読んでください。
既存テストを緑に保ったまま、新実装を src/modern/orderProcessor.ts に作ってください。

条件:
- 公開関数名 processOrder と引数・返り値の形は変えない
- status, total, discount, message の互換性を維持する
- まず型定義を追加し、その後に検証・計算・本体へ責務を分割する
- 旧実装は削除せず残す(後でストラングラーフィグで切り替えるため)
- 変更後に npm test と npm run typecheck を必ず実行する
- 差分ごとに「何の互換性を守ったか」を一行で説明する

近代化後の形:責務を割っても仕様は割らない

新実装側は、型・検証・計算・本体に分けます。大事なのは分割そのものじゃありません。金額計算の仕様をテストで守ったまま、レビューしやすい単位にすること。ここを取り違えると「きれいになったけど挙動が変わった」という最悪の結果になります。

// src/modern/orderTypes.ts
export type CustomerType = "regular" | "vip";

export type OrderItem = {
  id: string;
  qty: number;
  price: number;
};

export type OrderInput = {
  items: OrderItem[];
  customer: {
    id: string;
    type: CustomerType;
  };
};

export type OrderResult =
  | { status: "confirmed"; total: number; items: OrderItem[]; discount: number }
  | { status: "error"; message: string };

// src/modern/validators.ts
import type { OrderInput } from "./orderTypes";

export function validateOrder(order: OrderInput | null | undefined): string | null {
  if (!order || !Array.isArray(order.items) || order.items.length === 0) {
    return "items is required";
  }
  return null;
}

// src/modern/calculators.ts
import type { CustomerType, OrderItem } from "./orderTypes";

export function calculateSubtotal(items: OrderItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}

export function calculateDiscount(subtotal: number, customerType: CustomerType): number {
  return customerType === "vip" ? subtotal * 0.1 : 0;
}

// src/modern/orderProcessor.ts
import { calculateDiscount, calculateSubtotal } from "./calculators";
import type { OrderInput, OrderResult } from "./orderTypes";
import { validateOrder } from "./validators";

export function processOrder(order: OrderInput): OrderResult {
  const validationMessage = validateOrder(order);
  if (validationMessage) {
    return { status: "error", message: validationMessage };
  }

  const subtotal = calculateSubtotal(order.items);
  const discount = calculateDiscount(subtotal, order.customer.type);

  return {
    status: "confirmed",
    total: subtotal - discount,
    items: order.items,
    discount
  };
}

特性評価テストの import 先を新実装に向けて、npm testnpm run typecheck を回す。ここまでの差分なら、人間のレビューで一行ずつ追えます。逆に、型変換・命名変更・ディレクトリ移動・仕様変更・依存更新を全部同じPRに詰め込むと、Claude Codeが完璧に動いても、レビュー側が見落とします。差分は「意味の単位」で切る。これが鉄則です。

依存パッケージの更新は、必ず別トラックで

レガシー改善でよく見る事故が、コードのリファクタリングと依存パッケージのメジャー更新を同時にやることです。エラーが出たとき、型の問題なのか、ライブラリのAPI変更なのか、ビルド設定なのか、切り分けられなくなる。

Claude Codeには、まず調査だけを頼みます。

package.json と lockfile を読んでください。
まだ更新しないでください。

次を表で出してください。
- パッケージ名 / 現在のバージョン / 更新候補
- メジャー変更の有無
- 公式マイグレーションガイドのURL
- 影響するファイル
- 更新前に先に追加すべきテスト

テスト・型付け・依存更新は地続きですが、同じ差分に混ぜる必要はありません。削除やマイグレーション、デプロイのように副作用が大きい操作は、必ず人間の承認を挟みます。Claude Codeの権限と確認フローは公式のpermissionsを読んでおくと、どこで止まるかを設計できます。

僕がやらかした失敗3つ

正直に書きます。レガシー改善で踏んだ地雷の代表例です。

ひとつ目は、冒頭の丸め事故。テストなしでAIに「きれいにして」と頼み、subtotal * 0.1 の中間結果の扱いが微妙に変わって、1円ズレが本番に出ました。読みやすくなっても、丸め・割引・例外時の戻り値が変われば、それは立派な障害です。特性評価テストを先に置いていれば、コミット前に赤で止まっていました。

ふたつ目は、AIの「この方が自然です」を仕様として採用してしまったこと。古いAPIが null や空文字、特定の文言を返すのは、たいてい意図的な「契約」です。呼び出し側がその値に依存している。AIの美意識より、既存ユーザーの依存を優先する。これを学ぶのに一度本番を壊しました。

みっつ目は、巨大PRを作ったこと。ファイル移動・型変更・ロジック分割・依存更新・フォーマット変更を一気に混ぜたら、レビュアーが「もう全部OKでいいか」と投げた。レビューが機能しないPRは、出さないほうがマシです。今は1PR1意図を徹底しています。一次レビューをAIに任せる型はコードレビューをClaude Codeに任せるに整理しました。

始めるなら、ここから

いきなり「レガシー全部を近代化」を目標にしないでください。失敗しても戻せる、小さい一経路を選ぶ。注文合計の計算だけ、VIP割引だけ、バリデーションだけ。そのくらいでちょうどいいです。

順番はいつも同じです。①Claude Codeに「変更しない調査」で地図を描かせる → ②特性評価テストで現状を凍結する → ③ファサードを一枚かませる → ④新実装を書いてシャドー実行で差分を眺める → ⑤差分ゼロを確認して門を倒し、旧実装を消す。チームで回すなら、変更禁止領域・テストコマンド・レビュー観点をCLAUDE.mdのベストプラクティスに書いておくと、Claude Codeの出力が安定します。

もし「自分のプロダクトでこの足場をどう組むか」で詰まったら、研修・相談でレガシー改善の進め方ごと相談に乗っています。古いコードをAIで一気に置き換えるのではなく、テスト・権限・レビューの足場を先に作りたいチーム向けです。

よくある質問

Q. 特性評価テストとふつうのユニットテストは何が違いますか。 A. ユニットテストは「正しい仕様」を検証します。特性評価テストは「今の振る舞い」を、正誤に関係なくそのまま固定します。仕様が分からないレガシーコードでは、まず現状を凍結するこちらから始めます。仕様が判明したら、正しい挙動を表す本来のテストへ書き直していきます。

Q. ストラングラーフィグ・パターンは小さいコードでも必要ですか。 A. 関数1個なら過剰です。が、本番の金額計算や決済のように「壊すと困る」経路なら、規模が小さくてもファサード一枚+シャドー実行は効きます。新旧を本番データで突き合わせてから倒せる安心感は、コード量と無関係です。

Q. Claude Codeに最初から全面書き換えを頼んではいけませんか。 A. テストがない状態では避けます。差分が大きすぎてレビューが破綻し、AIが正しく動いても見落とすからです。「変更しない調査 → テスト固定 → 小さな置き換え」に分けると、各ステップで採用・却下を冷静に判断できます。

Q. 生成された特性評価テストはそのまま信じていいですか。 A. いいえ。AIが固定した期待値が、現状のバグまで「正解」として焼き付けている場合があります。境界値や金額まわりは、サンプルデータ・実ログ・過去の障害メモと突き合わせて人間が確認します。テストは便利な下書きであって、検証の代替ではありません。

Q. シャドー実行はどれくらいの期間回せばいいですか。 A. 経路を通るデータの多様性しだいです。月末だけ通る処理なら最低1か月。差分ログがゼロで安定したら倒す、という判断基準にすると期間に縛られません。差分が出たら、それは新実装のバグか、現状仕様の発見か、どちらかの貴重な情報です。

実際に試した結果

この記事のサンプルを legacy-modernization-demo として実際に作り、特性評価テストで3ケースを凍結 → 新実装をファサードで包む → シャドー実行で差分を眺める、までを通しました。効果が一番大きかったのは、Claude Codeに「変更しない調査」と「テストを緑に保ったままの小さな変更」を分けて頼んだ点です。最初から全面書き換えを頼んだとき(=冒頭の丸め事故)とは、差分の読みやすさがまるで違いました。

そして何より、失敗したときに戻る場所があるという心理的な軽さが効きます。特性評価テスト・型チェック・シャドーの差分ログ。この3つがそろうと、AIの提案を採用するか却下するかを、焦らず判断できる。レガシー改善で一番の生産性向上は、速く書くことではなく、この「落ち着いて止まれる状態」を先に作ることでした。古い木を一気に切り倒すのではなく、新しい木を巻きつけて、確認しながら一枝ずつ移す。遠回りに見えて、これが一番速い、というのが今の実感です。

#Claude Code #レガシーコード #リファクタリング #ストラングラーフィグ #特性評価テスト
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。