テストのないレガシー業務アプリに安全に手を入れる ── 特性化テストとリファクタリングの実践

· · レガシー技術, 既存資産活用, リファクタリング, テスト設計, 特性化テスト, C#, .NET, 保守, 判断表, 技術相談

「直したいところは分かっているんです。でも、触ったせいで別の場所が壊れたらと思うと、手が出せなくて」──テストのない業務アプリを引き継いだ方から、よく聞く言葉です。

VB6や.NET Framework、Accessで書かれた業務アプリの多くには、自動テストがありません。仕様書も更新が止まっていて、「コードが唯一の仕様書」という状態です。それでも業務は続いており、消費税率の変更、帳票レイアウトの修正、取引先の追加といった改修要求は待ってくれません。

当ブログでは「VB6アプリはいつまで動くのか ── 現実的な.NET移行の進め方」で、旧システムを「動く仕様書」として出力を突合しながら移行を進める考え方を紹介しました。この記事はその考え方を、移行ではなく「いま動いているコードにその場で手を入れる」場面に適用します。中心になる道具は特性化テスト(characterization test)です。テストがないコードでも、これから壊すかもしれない挙動を先にテストで固定してしまえば、リファクタリングも機能追加も格段に安全になります。

1. まず結論

  • いきなり直さないでください。まず現状の挙動をテストで固定します。仕様書がなくても、いま動いているコードの出力そのものが仕様です。
  • そのための道具が特性化テストです。「正しい挙動」ではなく「現在の挙動」を記録するテストで、帳票・CSV・計算結果などの出力をそのまま期待値として保存し、変更前後で差分比較します(ゴールデンマスター法)。
  • テストを差し込めない構造(UIイベントハンドラー直書き、DateTime.Nowやファイルパスの直接参照)には、メソッド抽出とインターフェース差し込みという最小限の変更で「継ぎ目(seam)」を作ります。大改造は不要です。
  • リファクタリングと機能追加を同じコミットに混ぜないでください。リファクタリングは「差分ゼロ」、機能追加は「意図した差分だけ」が合格条件で、混ぜると差分の意味が判別できなくなります。
  • テストをどこまで整備するかは、改修規模×システム残存年数×障害時の影響で決めます。全部にユニットテストを張るのが常に正解ではなく、「特性化テストだけ」「触らない」が正解の場面もあります。
  • CIがなくても始められます。テストプロジェクト1つと期待値ファイルのフォルダーがあれば、手元で回すだけでも安全性は大きく変わります。

2. なぜレガシーコードは「触ると壊れる」のか

レガシーコードの改修が怖いのは、コードが古いからではありません。変更した結果が正しいかどうかを、確かめる手段がないからです。

Michael Feathersは著書『レガシーコード改善ガイド』で、レガシーコードを「単に古いコード」ではなく「テストのないコード」と定義しました。1 テストがなければ、コードが良くなっているのか悪くなっているのか、変更のたびに速く確かめる方法がない、というのがその理由です。この定義に従えば、昨日書かれたコードでもテストがなければレガシーコードです。

テストのないコードでは、次の悪循環が回り始めます。

  1. テストがないので、変更の影響範囲が分からず怖い
  2. 怖いので、既存の構造を直さず最小限のコピペと条件分岐の追加で済ませる
  3. その場しのぎの修正が積み重なり、コードはさらに読めなく・壊れやすくなる
  4. 壊れやすくなったので、ますます怖くなる(1に戻る)

この悪循環を断つ入口は「勇気を出して大規模リファクタリングする」ことではありません。順序は逆で、先に安全網(テスト)を張り、怖さの原因を取り除いてから直すのです。ただしここに鶏と卵の問題があります。テストを書くには、テスト可能な構造が必要です。しかしテスト可能な構造にするには、コードを変更(リファクタリング)しなければならない。テストのないコードを、テストなしで変更することになってしまいます。

この矛盾を解くために、レガシーコード改修は次の順序で進めます。1

  1. 変更する箇所の周辺だけ、現在の挙動を外側から固定する(特性化テスト)
  2. その安全網の内側で、壊すリスクが極めて低い最小限の変更(メソッド抽出など)を行い、テストの差し込み口を作る
  3. 細かいテストが書ける構造になったら、本来やりたかった変更(リファクタリング・機能追加)に着手する

以降の章で、この1と2を具体的に見ていきます。

3. 特性化テスト ── 「現在の挙動」を記録する

3.1 通常のテストと何が違うか

通常のテストは「仕様上こうあるべき」という正しい挙動を検証します。特性化テストは違います。いまのコードが実際にどう振る舞っているかを、正しいかどうかの判断を保留したまま記録します。

たとえば端数処理が四捨五入なのか切り捨てなのか、仕様書には書かれていないとします。現行コードが切り捨てで動いていて、業務がそれで10年回っているなら、少なくとも「切り捨てである」ことが事実上の仕様です。特性化テストはこれを「現在の出力は◯◯である」という形でそのまま固定します。もしそれがバグだったとしても、まず固定します。挙動を変える(バグを直す)のは、安全網ができた後に、意図した変更として別途行います。

3.2 ゴールデンマスター法の手順

出力の単位が大きいレガシーコードには、ゴールデンマスター法が最も費用対効果の高い特性化テストです。手順は素朴です。

  1. 変更対象の機能が生成する出力(帳票のテキスト、CSV、計算結果の一覧など)を特定する
  2. 代表的な入力データを用意し、現行コードを実行して出力を得る
  3. その出力を期待値ファイル(ゴールデンマスター)としてそのまま保存し、リポジトリに入れる
  4. 以後、コードを変更するたびにテストを実行し、出力と期待値ファイルの差分がゼロであることを確認する

C#での実装は、特定のライブラリに依存しない次のような素朴なもので十分です。

[Fact]
public void 月次請求一覧_ルデンマスタ()
{
    // 1. 代表的な入力(本番からマスキングして抜いたデータなど)を読む
    var input = File.ReadAllLines(TestDataPath("billing-input-202606.csv"));

    // 2. 既存ロジックをそのまま呼び、出力文字列を得る
    string actual = BillingReport.Generate(input);

    // 3. 期待値ファイルが無いのは「テスト環境が壊れている」か「初回」。
    //    どちらにせよ黙って通さず、記録だけ残して必ず失敗させる
    string expectedPath = TestDataPath("billing-expected-202606.txt");
    if (!File.Exists(expectedPath))
    {
        File.WriteAllText(expectedPath + ".candidate", actual);
        Assert.Fail("期待値ファイルがありません。.candidateの内容をレビューし、" +
                    "問題なければ期待値としてコミットしてください。");
    }

    // 4. 保存済みの挙動と完全一致することを検証する
    string expected = File.ReadAllText(expectedPath);
    Assert.Equal(expected, actual);
}

期待値ファイルが見つからないときに現在の出力をそのまま期待値として保存し、テストを成功させてしまう実装は避けてください。期待値のコミット漏れやテスト環境の配置ミスがあったとき、CIが退行を検出せずに緑のまま通過してしまいます。初回の記録は上のように候補ファイル(.candidate)を出力したうえで明示的に失敗させ、人がレビューしてから期待値としてコミットする一方通行にします。

差分が出たときに Assert.Equal のメッセージだけでは追いにくいので、実務では失敗時に実際の出力を billing-actual-202606.txt のような別ファイルへ書き出しておき、WinMergeなどの差分ツールで期待値と比較できるようにしておくと調査が速くなります。

3.3 入力の選び方と出力の正規化

入力は「代表+境界」で選びます。通常ケースを1〜2件、それに加えて月末締め・ゼロ件・マイナス値・特定取引先の例外処理など、コードを読んで見つけた分岐が通る入力を足していきます。本番データをマスキングして使えるなら、それが最も現実の分岐を通します。

出力に混ざる非決定的な値は、比較前に正規化します。印字日時、処理時間、GUID、自動採番などは実行のたびに変わるため、そのままでは毎回差分が出ます。出力を生成した後、正規表現で 印刷日時: 2026/07/17 16:00印刷日時: <DATE> に置換するといった前処理を挟んでから比較します。

どんな出力がゴールデンマスターに向くかの目安を整理します。

出力の種類 適性 補足
CSV・固定長ファイル そのまま保存・比較できる。最初に狙うべき対象
帳票(テキスト・印刷プレビューの元データ) PDF化の手前の文字列を捕まえる。PDFバイナリ比較は避ける
計算結果の一覧(金額、在庫数など) 結果をCSV等に吐くテスト専用メソッドを足してもよい
DBへの書き込み内容 書き込み後のテーブル内容をSELECTしてCSV化し比較する
画面表示そのもの 文字列に落とせるなら可。画面操作の自動化は「WindowsデスクトップアプリのUI自動テスト」で扱った別の道具立てが要る
外部システムへの送信 送信直前のデータを捕まえる継ぎ目(次章)が必要

4. テストを差し込む「継ぎ目(seam)」の作り方

ゴールデンマスターを書こうとすると、多くのレガシーコードで壁に当たります。ロジックがUIイベントハンドラーに直書きされていて、画面を起動しないと実行できないのです。ここで必要になるのが、テストコードから挙動を差し替え・観測できる場所、Feathersの言う継ぎ目(seam)です。1

4.1 メソッド抽出でロジックをUIから引き剥がす

典型的なBeforeはこうです。計算・DBアクセス・時刻依存・画面更新が1つのイベントハンドラーに同居しています。

// Before: すべてがイベントハンドラーに直書き
private void btnCalc_Click(object sender, EventArgs e)
{
    var rows = LoadRowsFromDb();                          // DB直アクセス
    var now = DateTime.Now;                               // 現在時刻に依存
    decimal total = 0;
    foreach (var row in rows)
    {
        if (row.SalesDate.Year == now.Year &&
            row.SalesDate.Month == now.Month)              // 当月分だけ集計
        {
            total += Math.Floor(row.Amount * 1.1m);        // 端数処理という業務ルール
        }
    }
    lblTotal.Text = total.ToString("N0");                  // 画面へ直接反映
}

このままでは、当月分の集計ロジックをテストするために画面とDBと「今日の日付」が必要です。最小限の変更でテスト可能にするには、計算部分だけをメソッドに抽出し、外部依存(DBの結果と現在時刻)を引数に変えるのが定石です。Visual Studioのメソッド抽出リファクタリング(Ctrl+R, M)を使えば、手作業の書き換えミスも減らせます。2

// After: 計算だけを抽出し、「DBの結果」と「現在時刻」を引数として受け取る
internal static decimal CalcMonthlyTotal(IEnumerable<SalesRow> rows, DateTime now)
{
    decimal total = 0;
    foreach (var row in rows)
    {
        if (row.SalesDate.Year == now.Year &&
            row.SalesDate.Month == now.Month)
        {
            total += Math.Floor(row.Amount * 1.1m);
        }
    }
    return total;
}

private void btnCalc_Click(object sender, EventArgs e)
{
    var rows = LoadRowsFromDb();
    lblTotal.Text = CalcMonthlyTotal(rows, DateTime.Now).ToString("N0");
}

イベントハンドラー側は「読み込み→計算→表示」の3行になり、抽出したメソッドは任意の行データと任意の日付でテストできます。月末・月初・うるう年のような時刻絡みの境界も、new DateTime(2028, 2, 29) のような日付を渡すだけで再現できます。

4.2 インターフェース差し込みで依存を切り替え可能にする

引数化で済まない規模の依存(あちこちでDateTime.Nowを参照している、ファイルパスが直書きされている等)には、依存をインターフェースで包んで差し込みます。Microsoft Learnの.NETユニットテストのベストプラクティスでも、DateTime.Now への直接依存はテストから制御できない典型例とされ、インターフェースで包んで継ぎ目(seam)を導入する方法が紹介されています。3

public interface IClock
{
    DateTime Now { get; }
}

public sealed class SystemClock : IClock
{
    public DateTime Now => DateTime.Now;
}

// テスト側では固定時刻を返す実装を差し込む
public sealed class FixedClock : IClock
{
    private readonly DateTime _fixed;
    public FixedClock(DateTime value) => _fixed = value;
    public DateTime Now => _fixed;
}

既存クラスのコンストラクターに IClock を追加すると呼び出し元をすべて直す必要が出るため、移行期は「引数なしコンストラクターは SystemClock を使う」という既定値付きのコンストラクターを併設し、呼び出し元を段階的に直していくのが現実的です。ファイルパスやDB接続文字列の直書きも同じ要領で、「読み書きする」操作だけの小さなインターフェースに包みます。

なお、Visual Studioには既存クラスからインターフェースを抽出するリファクタリング(Extract Interface)が組み込まれており、この種の変更を機械的に行えます。2

継ぎ目作りで守るべき原則は1つです。継ぎ目を作る変更そのものは、挙動を1ミリも変えないこと。メソッド抽出とインターフェース差し込みは、どちらもコンパイラとIDEの支援で機械的に行える、挙動保存性の高い操作です。この段階で「ついでに」ロジックを直したくなりますが、それは安全網が張られた後の仕事です。

5. どこまでやるかの判断表

特性化テストと継ぎ目作りにも工数はかかります。すべてのレガシーコードに同じ水準のテストを整備するのは、中小規模の現場では現実的ではありませんし、その必要もありません。判断軸は3つです。

  • 改修の規模: 数行のバグ修正か、機能追加か、構造の変更を伴うか
  • システムの残存年数: あと1〜2年で移行・廃止予定か、5年以上使い続けるか
  • 障害時の影響: 帳票の見た目が崩れる程度か、請求金額や在庫数を間違えるか
改修規模 残存年数 障害時の影響 推奨する水準
軽微(数行・設定値変更) 短い(〜2年) 小(表示崩れ程度) 特性化テストだけ。該当出力を固定して変更、差分確認で終わり
軽微〜中 短い 大(金額・在庫を扱う) 特性化テストだけを厚めに。入力パターンを境界含め増やす
中(機能追加・ロジック変更) 長い(5年〜) 小〜中 特性化テスト+変更箇所周辺だけユニットテスト整備(継ぎ目を作る)
中〜大 長い 特性化テスト+ユニットテスト整備+リリース単位を細かく分割
大(構造の刷新が必要) 短い 触らない。改修せず運用回避し、工数は移行・置き換えに回す
─(改修要求自体がない) 触らない。動いているコードを予防的にリファクタリングしない

下2行の「触らない」は消極的な選択ではなく、積極的な判断です。残存年数が短いシステムの内部品質に投資しても回収できません。その工数は「VB6 / Access業務アプリの延命と移行の判断表」で整理した移行判断と、移行先の設計に使うべきです。

また「ユニットテスト整備まで」進む場合も、何をユニットテストに書き、何を結合テスト(実DB・実ファイルを使うテスト)に残すかの線引きが要ります。この線引きは「ユニットテストと結合テストの境界をどう引くか」で判断表として整理していますので、併せて参照してください。ユニットテストが fast / isolated / repeatable であるべきという性質上、3 DBやファイルに触る特性化テストはユニットテストとは別のプロジェクト・別の実行単位に分けておくのが無難です。

6. 運用ルール ── 安全網を破らないために

特性化テストは、書いた後の運用を誤ると簡単に形骸化します。最低限のルールを3つに絞ります。

6.1 リファクタリングと機能追加を同じコミットに混ぜない

リファクタリングとは、挙動を変えずにコードを理解しやすく・保守しやすくする変更です。4 つまり合格条件はゴールデンマスターとの差分ゼロです。一方、機能追加・バグ修正の合格条件は意図した差分だけが出ることです。この2つを1つのコミットに混ぜると、差分が出たときに「意図した変更」なのか「壊した」のかを判別できなくなります。

変更の種類 ゴールデンマスターの扱い 合格条件
リファクタリング(構造の変更) 更新しない 差分ゼロ
バグ修正・機能追加(挙動の変更) 差分レビュー後に更新する 意図した差分のみ
継ぎ目作り(メソッド抽出・インターフェース差し込み) 更新しない 差分ゼロ
期待値の正規化ルール変更 再生成する 変更理由をコミットメッセージに明記

リリース単位でも同じです。「リファクタリングだけのリリース」は挙動が変わらないはずなので、障害が出たら即座にリファクタリングを疑えます。混ぜてしまうと、この切り分けが効きません。

6.2 期待値の更新は「差分レビュー→上書き」の順で

挙動を意図して変えたら、ゴールデンマスターも更新します。手順を固定してください。

  1. 変更後の出力を生成し、現在の期待値との差分を目視でレビューする
  2. 差分が意図した変更だけであることを確認する(意図しない行が1行でも変わっていたら調査)
  3. 新しい出力で期待値ファイルを上書きし、コードと同じコミットに含めて履歴に残す

危険なのは「テストが赤くなったから期待値を上書きして緑にする」という運用です。これをやると、退行がそのまま「正」として記録され、安全網が安全網でなくなります。

6.3 CIがなくても、手元で回る最小構成を作る

CIサーバーがない現場でも、次の最小構成なら今日から始められます。

  • ソリューションにテストプロジェクトを1つ追加する(.NET Frameworkのままでも、MSTest / NUnit / xUnitいずれも動きます)
  • 期待値ファイルと入力データは TestData フォルダーに置き、コードと一緒にバージョン管理する
  • コミット前に dotnet test(またはVisual Studioのテストエクスプローラー)を手で実行することをチームの約束にする
  • 実行結果の確認を忘れないよう、リリース手順書に「テスト実行と差分ゼロ確認」を1行入れる

テストの実行結果やアプリ側の出力を突合する際には、ログが整備されているほど原因調査が速くなります。ログに何を残すべきかは「自作ロガーの最小要件と結合テストチェックリスト」で扱っています。

7. まとめ

  • レガシーコードとは「テストのないコード」であり、1 触ると壊れる本当の原因は、変更結果を確かめる手段がないことです。直す前に、現在の挙動をテストで固定します。
  • 特性化テストは「正しい挙動」ではなく「現在の挙動」を記録するテストです。帳票・CSV・計算結果をそのまま期待値ファイルに保存して差分比較するゴールデンマスター法なら、素朴なC#コードだけで始められます。
  • テストを差し込めない構造には、メソッド抽出とインターフェース差し込みで継ぎ目(seam)を作ります。DateTime.Nowのような依存を包む手法はMicrosoftのユニットテスト指針でも示されている定石です。32
  • どこまで整備するかは改修規模×残存年数×障害時の影響で決めます。「特性化テストだけ」「触らない」も立派な判断です。
  • 運用ではリファクタリング(差分ゼロが合格)と機能追加(意図した差分だけが合格)を混ぜないこと、4 期待値の更新は必ず差分レビューを通すことを守ります。CIがなくても、手元でテストを回す約束だけで安全性は大きく変わります。

関連記事

関連する相談領域

合同会社小村ソフトでは、テストのない既存業務アプリへの特性化テストの導入、テスト可能な構造への段階的なリファクタリング、改修と移行のどちらに投資すべきかの判断整理を扱っています。

参考リンク

  1. Michael C. Feathers, “Working Effectively with Legacy Code” (Prentice Hall, 2004). 邦訳『レガシーコード改善ガイド』(翔泳社)。レガシーコードを「テストのないコード」と定義していること、特性化テスト(characterization test)により現在の挙動を記録してから変更に着手する手順、テストを差し込むための継ぎ目(seam)の概念について。  2 3 4

  2. Microsoft Learn, Extract and inline refactorings (Visual Studio). Visual StudioのC# / Visual Basic向けメソッド抽出(Ctrl+R, M)およびインターフェース抽出(Extract Interface)リファクタリングの操作手順について。  2 3

  3. Microsoft Learn, Unit testing best practices for .NET. 良いユニットテストの性質(fast / isolated / repeatable / self-checking / timely)、DateTime.Nowのような制御できない依存をインターフェースで包んで継ぎ目(seam)を導入する手法、インフラ依存をユニットテストに持ち込まず結合テストへ分けるべきことについて。  2 3

  4. Microsoft Learn, Refactor code (Visual Studio). リファクタリングとは、挙動を変えることなく、コードを保守・理解・拡張しやすくするために変更するプロセスであるという定義について。  2

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

ソースコードも仕様書もないシステムを引き継いだら ── 止めずに運用・保守するための実務手順

開発会社の廃業や担当者の退職で、ソースコードも仕様書もない業務システムだけが残った ── そんな状態から運用・保守を始めるための実務手順を整理します。動いている環境の保全とバックアップ、実行ファイル・DB・連携先の棚卸し、挙動からの仕様復元、逆コンパイルの技術的な可能性と法...

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

特性化テスト(characterization test)とは何ですか?
「正しい挙動」ではなく「現在の挙動」をそのまま記録するテストです。仕様書が残っていないレガシーコードでは、何が正しいかを確かめる手段がないことが多いため、まず今動いているコードの出力(帳票、CSV、計算結果など)を期待値として保存し、変更の前後で出力が変わっていないことを機械的に確認します。挙動を固定する安全網を張ってから、リファクタリングや機能追加に進むのが基本的な使い方です。
テストがまったくないレガシーコードは、どこから手を付ければよいですか?
これから変更する箇所の周辺だけに絞って特性化テストを書くのが現実的です。システム全体にテストを張るのは工数的に成立しないことがほとんどで、その必要もありません。まず変更対象の機能が生成する出力(帳票、CSV、DBへの書き込み内容など)を特定し、代表的な入力での出力をファイルに保存して固定します。その安全網の内側でメソッド抽出などの小さなリファクタリングを行い、ロジックをテスト可能な形に切り出してから本来の変更に着手します。
リファクタリングと機能追加を同じコミットに混ぜてはいけないのはなぜですか?
出力の差分が出たときに、原因を切り分けられなくなるからです。リファクタリングは「挙動が変わらないこと」を、機能追加は「意図した箇所だけ挙動が変わること」を確認する作業で、検証の合格条件が正反対です。混ぜてしまうと、ゴールデンマスターとの差分が「意図した変更」なのか「壊した」のかを判別できません。リファクタリングのコミットでは差分ゼロ、機能追加のコミットでは意図した差分だけ、と分けて確認するのが安全です。
ゴールデンマスター(期待値ファイル)はいつ更新しますか?
意図して挙動を変えたとき、つまり機能追加や不具合修正のコミットのタイミングだけです。更新の際は、変更前後の出力差分を目視でレビューし、意図した変更だけが含まれていることを確認してから新しい出力で期待値を置き換えます。テストが赤くなったからといって機械的に期待値を上書きすると、退行(意図しない挙動変化)をそのまま「正」として取り込んでしまい、安全網としての意味がなくなります。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る