更新履歴(4件・最終更新 2026年08月02日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- 記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
- 外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
- テストプロジェクトから既存アプリを呼べるようにするまでの手順を新設しました。あわせて差分レビューの読み方の実例、dotnet test以外の実行手段、想定読者と前提を追加しています。
- 本文中の関連記事へのリンクの文言が、リンク先の現在のタイトルと食い違っていたのを、実際のタイトルに揃えました。本文の内容は変えていません。
- 初版公開
この記事を引用する(DOI: 10.5281/zenodo.21590026)
この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。
小村 豪(2026)「テストのないレガシー業務アプリに安全に手を入れる ── 特性化テストとリファクタリングの実践」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21590026 https://staging.comcomponent.com/blog/characterization-test-legacy-refactoring/
- DOI(最新版)
- 10.5281/zenodo.21590026
- DOI(この版)
- 10.5281/zenodo.21733002
「直したいところは分かっているんです。でも、触ったせいで別の場所が壊れたらと思うと、手が出せなくて」──テストのない業務アプリを引き継いだ方から、よく聞く言葉です。
VB6や.NET Framework、Accessで書かれた業務アプリの多くには、自動テストがありません。仕様書も更新が止まっていて、「コードが唯一の仕様書」という状態です。それでも業務は続いており、消費税率の変更、帳票レイアウトの修正、取引先の追加といった改修要求は待ってくれません。
当ブログでは「VB6アプリはいつまで動くのか ── ランタイムのサポート状況と現実的な.NET移行の進め方」で、旧システムを「動く仕様書」として出力を突合しながら移行を進める考え方を紹介しました。この記事はその考え方を、移行ではなく「いま動いているコードにその場で手を入れる」場面に適用します。中心になる道具は特性化テスト(characterization test)です。テストがないコードでも、これから壊すかもしれない挙動を先にテストで固定してしまえば、リファクタリングも機能追加も格段に安全になります。
想定している読者と前提は次のとおりです。自動テストの経験は不要で、テストを1本も書いたことがない状態から始められる内容にしています。前提として必要なのは、(1)対象アプリを自分でビルドできること(ソースと、ビルドが通る開発環境が手元にあること)、(2)ソースコードに変更を加えてよい立場であること、の2つだけです。コード例はC#(.NET Framework / .NETのどちらでも動く書き方)で示しますが、考え方は言語を問いません。逆に、ソースがない・ビルドできない場合はこの記事の手法はそのままでは使えないので、その前段の整理から必要になります。
1. まず結論
- いきなり直さないでください。まず現状の挙動をテストで固定します。仕様書がなくても、いま動いているコードの出力そのものが仕様です。
- そのための道具が特性化テストです。「正しい挙動」ではなく「現在の挙動」を記録するテストで、帳票・CSV・計算結果などの出力をそのまま期待値として保存し、変更前後で差分比較します(ゴールデンマスター法)。
- テストを差し込めない構造(UIイベントハンドラー直書き、
DateTime.Nowやファイルパスの直接参照)には、メソッド抽出とインターフェース差し込みという最小限の変更で「継ぎ目(seam)」を作ります。大改造は不要です。 - リファクタリングと機能追加を同じコミットに混ぜないでください。リファクタリングは「差分ゼロ」、機能追加は「意図した差分だけ」が合格条件で、混ぜると差分の意味が判別できなくなります。
- テストをどこまで整備するかは、改修規模×システム残存年数×障害時の影響で決めます。全部にユニットテストを張るのが常に正解ではなく、「特性化テストだけ」「触らない」が正解の場面もあります。
- CIがなくても始められます。テストプロジェクト1つと期待値ファイルのフォルダーがあれば、手元で回すだけでも安全性は大きく変わります。
この記事の知識マップ
この記事は、自動テストのないレガシーコードに安全に手を入れるための手順を扱います。まず特性化テストで現在の挙動を固定し、出力の単位が大きい場合はゴールデンマスター法で期待値ファイルとの差分比較を行います。UIに直書きされたロジックには、メソッド抽出とインターフェース差し込みで継ぎ目(seam)を作り、必要ならInternalsVisibleTo属性でinternalメンバーをテストから見えるようにします。リファクタリングは差分ゼロ、機能追加は意図した差分だけが合格条件であるため同じコミットに混ぜてはならず、期待値の更新は差分レビューを経てから行うことで、退行を見逃すリスクを防ぎます。
flowchart LR
accTitle: 特性化テストとレガシーコード改修の知識マップ
accDescr: 特性化テストとゴールデンマスター法でレガシーコードの現在の挙動を固定し、継ぎ目(seam)を作ってリファクタリングと機能追加を安全に分離する関係を示す図
characterization_test["特性化テスト"]
legacy_code["レガシーコード"]
method_extraction_refactoring["メソッド抽出"]
refactoring["リファクタリング"]
golden_master_testing["ゴールデンマスター法"]
regression_risk["退行リスク"]
golden_master_diff_review["期待値の差分レビュー"]
mixed_commit_antipattern["リファクタリングと機能追加の混在コミット"]
seam["継ぎ目(seam)"]
dependency_injection_seam["インターフェース差し込み"]
internals_visible_to["InternalsVisibleTo属性"]
output_normalization["出力の正規化"]
dotnet_test_command["dotnet testコマンド"]
vstest_console["vstest.console.exe"]
characterization_test -->|"より先に行うべき"| method_extraction_refactoring
characterization_test -->|"より先に行うべき"| refactoring
golden_master_testing -->|"実装を担う"| characterization_test
golden_master_testing -->|"推奨される対応"| legacy_code
legacy_code -.->|"原因になり得る"| regression_risk
characterization_test -->|"軽減する"| regression_risk
golden_master_diff_review -->|"軽減する"| regression_risk
mixed_commit_antipattern -.->|"原因になり得る"| regression_risk
method_extraction_refactoring -->|"実装を担う"| seam
dependency_injection_seam -->|"実装を担う"| seam
seam -.->|"で構成できる"| internals_visible_to
refactoring -->|"両立しない"| mixed_commit_antipattern
golden_master_testing -.->|"前提とする"| output_normalization
regression_risk -.->|"で確認できる"| dotnet_test_command
regression_risk -.->|"で確認できる"| vstest_console
characterization_test -->|"推奨される対応"| legacy_code
seam -->|"推奨される対応"| legacy_code
dotnet_test_command -.->|"両立しない"| legacy_code
vstest_console -.->|"推奨される対応"| legacy_code
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全19件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
2. なぜレガシーコードは「触ると壊れる」のか
レガシーコードの改修が怖いのは、コードが古いからではありません。変更した結果が正しいかどうかを、確かめる手段がないからです。
Michael Feathersは著書『レガシーコード改善ガイド』で、レガシーコードを「単に古いコード」ではなく「テストのないコード」と定義しました。1 テストがなければ、コードが良くなっているのか悪くなっているのか、変更のたびに速く確かめる方法がない、というのがその理由です。この定義に従えば、昨日書かれたコードでもテストがなければレガシーコードです。
テストのないコードでは、次の悪循環が回り始めます。
- テストがないので、変更の影響範囲が分からず怖い
- 怖いので、既存の構造を直さず最小限のコピペと条件分岐の追加で済ませる
- その場しのぎの修正が積み重なり、コードはさらに読めなく・壊れやすくなる
- 壊れやすくなったので、ますます怖くなる(1に戻る)
この悪循環を断つ入口は「勇気を出して大規模リファクタリングする」ことではありません。順序は逆で、先に安全網(テスト)を張り、怖さの原因を取り除いてから直すのです。ただしここに鶏と卵の問題があります。テストを書くには、テスト可能な構造が必要です。しかしテスト可能な構造にするには、コードを変更(リファクタリング)しなければならない。テストのないコードを、テストなしで変更することになってしまいます。
この矛盾を解くために、レガシーコード改修は次の順序で進めます。1
- 変更する箇所の周辺だけ、現在の挙動を外側から固定する(特性化テスト)
- その安全網の内側で、壊すリスクが極めて低い最小限の変更(メソッド抽出など)を行い、テストの差し込み口を作る
- 細かいテストが書ける構造になったら、本来やりたかった変更(リファクタリング・機能追加)に着手する
以降の章で、この1と2を具体的に見ていきます。
3. 特性化テスト ── 「現在の挙動」を記録する
3.1 通常のテストと何が違うか
通常のテストは「仕様上こうあるべき」という正しい挙動を検証します。特性化テストは違います。いまのコードが実際にどう振る舞っているかを、正しいかどうかの判断を保留したまま記録します。
たとえば端数処理が四捨五入なのか切り捨てなのか、仕様書には書かれていないとします。現行コードが切り捨てで動いていて、業務がそれで10年回っているなら、少なくとも「切り捨てである」ことが事実上の仕様です。特性化テストはこれを「現在の出力は◯◯である」という形でそのまま固定します。もしそれがバグだったとしても、まず固定します。挙動を変える(バグを直す)のは、安全網ができた後に、意図した変更として別途行います。
3.2 ゴールデンマスター法の手順
出力の単位が大きいレガシーコードには、ゴールデンマスター法が最も費用対効果の高い特性化テストです。手順は素朴です。
- 変更対象の機能が生成する出力(帳票のテキスト、CSV、計算結果の一覧など)を特定する
- 代表的な入力データを用意し、現行コードを実行して出力を得る
- その出力を期待値ファイル(ゴールデンマスター)としてそのまま保存し、リポジトリに入れる
- 以後、コードを変更するたびにテストを実行し、出力と期待値ファイルの差分がゼロであることを確認する
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などの差分ツールで期待値と比較できるようにしておくと調査が速くなります。
なお、このコード例は「既存ロジックの BillingReport.Generate をテストプロジェクトからそのまま呼べる」ことを前提にしています。レガシー現場で最初に詰まるのはたいていここなので、つなぎ方は3.4節にまとめました。
この手法は文献によって呼び名が違い、ゴールデンマスターテストのほかに承認テスト(approval testing)、スナップショットテスト(snapshot testing)とも呼ばれます。同じ考え方をライブラリ化したものもあり、.NETではApprovalTests.NetやVerifyが代表的です。期待値ファイルの命名規則、差分ツールの自動起動、期待値の承認操作といった、上のコードで自作した部分を肩代わりしてくれます。まずは上のような素朴な実装で始め、期待値ファイルが増えて管理が面倒になってきた段階でライブラリの導入を検討する、という順番で十分です。検索するときは「golden master」より「approval testing」「snapshot testing」のほうが情報が見つかります。
3.3 入力の選び方と出力の正規化
入力は「代表+境界」で選びます。通常ケースを1〜2件、それに加えて月末締め・ゼロ件・マイナス値・特定取引先の例外処理など、コードを読んで見つけた分岐が通る入力を足していきます。本番データをマスキングして使えるなら、それが最も現実の分岐を通します。
出力に混ざる非決定的な値は、比較前に正規化します。印字日時、処理時間、GUID、自動採番などは実行のたびに変わるため、そのままでは毎回差分が出ます。出力を生成した後、正規表現で 印刷日時: 2026/07/17 16:00 を 印刷日時: <DATE> に置換するといった前処理を挟んでから比較します。
どんな出力がゴールデンマスターに向くかの目安を整理します。
| 出力の種類 | 適性 | 補足 |
|---|---|---|
| CSV・固定長ファイル | ◎ | そのまま保存・比較できる。最初に狙うべき対象 |
| 帳票(テキスト・印刷プレビューの元データ) | ◎ | PDF化の手前の文字列を捕まえる。PDFバイナリ比較は避ける |
| 計算結果の一覧(金額、在庫数など) | ◎ | 結果をCSV等に吐くテスト専用メソッドを足してもよい |
| DBへの書き込み内容 | ◯ | 書き込み後のテーブル内容をSELECTしてCSV化し比較する |
| 画面表示そのもの | △ | 文字列に落とせるなら可。画面操作の自動化は「WindowsデスクトップアプリのUI自動テスト」で扱った別の道具立てが要る |
| 外部システムへの送信 | △ | 送信直前のデータを捕まえる継ぎ目(次章)が必要 |
3.4 テストプロジェクトから既存アプリを呼べるようにする
既存のWinForms / WPFアプリはEXEプロジェクトです。「テストプロジェクトを追加したものの、そこから本体のクラスが見えない」で止まるのがレガシー改修の第一関門なので、つなぎ方を整理します。
1. テストプロジェクトを1つ追加する。既存ソリューションに新しいテストプロジェクトを足します。.NET Frameworkのままでも、MSTest / NUnit / xUnitのいずれも使えます。テストプロジェクトのターゲットフレームワークは本体と合わせるのが基本です(本体が.NET Framework 4.8なら、テストプロジェクトも4.8)。ここがずれていると、参照を追加した時点で警告やロードエラーになります。
2. 本体プロジェクトへの参照を追加する。接続の方法は2通りあり、原則は前者です。
| つなぎ方 | 使う場面 | やり方 |
|---|---|---|
| プロジェクト参照(推奨) | 本体のソースがあり、同じソリューションでビルドできる | テストプロジェクトを右クリック → 参照の追加 → プロジェクト → 本体のEXEプロジェクトを選ぶ。EXEプロジェクトもアセンブリなので参照できます(「EXEだから参照できない」は誤解です) |
| DLL / EXEファイル参照 | 本体をソリューションに入れられない、ビルド済みバイナリしか手元にない | 参照の追加 → 参照 → 本体のbinにあるEXE / DLLファイルを直接指定する。ただし本体を再ビルドするたびに参照先が古くならないよう注意が要る |
参照方向はテスト → 本体の一方向だけです。本体からテストを参照すると循環参照になります。
3. internal のままテストしたいなら InternalsVisibleTo を使う。第4章で見るように、メソッド抽出でロジックを切り出すときは internal にしておきたい場面が多くあります(公開APIを増やさずにテストできるため)。その場合は、本体側のアセンブリに次の属性を1行足します。2
// 本体側の AssemblyInfo.cs か、任意のソースファイルの先頭に置く
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("MyApp.Tests")]
注意点が2つあります。2
- 本体とテストの署名状態をそろえる。両方とも未署名か、両方とも強い名前付きである必要があります。本体が強い名前付きの場合は、
InternalsVisibleTo("MyApp.Tests, PublicKey=0024...")のように完全な公開鍵(公開鍵トークンではありません)を書きます。公開鍵はsn -pとsn -tpで取り出せます。 privateは見えません。InternalsVisibleToが効くのはinternal/protected internal/private protectedだけです。privateメソッドをテストしたくなったら、それは「そのクラスが大きすぎる」というサインなので、第4章のメソッド抽出で切り出すほうが素直です。
4. データファイルの置き場所を確認する。テスト実行時のカレントディレクトリはテストの出力フォルダー(bin\Debug\...)になります。期待値ファイルや入力CSVはTestDataフォルダーに置き、プロパティの「出力ディレクトリにコピー」を「新しい場合はコピーする」に設定しておくと、3.2節のTestDataPathが素直に書けます。本体側がapp.configや設定ファイルを読んでいる場合も、テストプロジェクト側に同等の設定が要ることがあります。
ここまでできれば、3.2節のゴールデンマスターテストがそのまま書けるようになります。
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)を使えば、手作業の書き換えミスも減らせます。3
// 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)を導入する方法が紹介されています。4
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)が組み込まれており、この種の変更を機械的に行えます。3
継ぎ目作りで守るべき原則は1つです。継ぎ目を作る変更そのものは、挙動を1ミリも変えないこと。メソッド抽出とインターフェース差し込みは、どちらもコンパイラとIDEの支援で機械的に行える、挙動保存性の高い操作です。この段階で「ついでに」ロジックを直したくなりますが、それは安全網が張られた後の仕事です。
5. どこまでやるかの判断表
特性化テストと継ぎ目作りにも工数はかかります。すべてのレガシーコードに同じ水準のテストを整備するのは、中小規模の現場では現実的ではありませんし、その必要もありません。判断軸は3つです。
- 改修の規模: 数行のバグ修正か、機能追加か、構造の変更を伴うか
- システムの残存年数: あと1〜2年で移行・廃止予定か、5年以上使い続けるか
- 障害時の影響: 帳票の見た目が崩れる程度か、請求金額や在庫数を間違えるか
| 改修規模 | 残存年数 | 障害時の影響 | 推奨する水準 |
|---|---|---|---|
| 軽微(数行・設定値変更) | 短い(〜2年) | 小(表示崩れ程度) | 特性化テストだけ。該当出力を固定して変更、差分確認で終わり |
| 軽微〜中 | 短い | 大(金額・在庫を扱う) | 特性化テストだけを厚めに。入力パターンを境界含め増やす |
| 中(機能追加・ロジック変更) | 長い(5年〜) | 小〜中 | 特性化テスト+変更箇所周辺だけユニットテスト整備(継ぎ目を作る) |
| 中〜大 | 長い | 大 | 特性化テスト+ユニットテスト整備+リリース単位を細かく分割 |
| 大(構造の刷新が必要) | 短い | ─ | 触らない。改修せず運用回避し、工数は移行・置き換えに回す |
| ─(改修要求自体がない) | ─ | ─ | 触らない。動いているコードを予防的にリファクタリングしない |
下2行の「触らない」は消極的な選択ではなく、積極的な判断です。残存年数が短いシステムの内部品質に投資しても回収できません。その工数は「VB6 / Access業務アプリの延命と移行の判断表」で整理した移行判断と、移行先の設計に使うべきです。
また「ユニットテスト整備まで」進む場合も、何をユニットテストに書き、何を結合テスト(実DB・実ファイルを使うテスト)に残すかの線引きが要ります。この線引きは「ユニットテストと結合テストの境界をどう引くか」で判断表として整理していますので、併せて参照してください。ユニットテストが fast / isolated / repeatable であるべきという性質上、4 DBやファイルに触る特性化テストはユニットテストとは別のプロジェクト・別の実行単位に分けておくのが無難です。
6. 運用ルール ── 安全網を破らないために
特性化テストは、書いた後の運用を誤ると簡単に形骸化します。最低限のルールを3つに絞ります。
6.1 リファクタリングと機能追加を同じコミットに混ぜない
リファクタリングとは、挙動を変えずにコードを理解しやすく・保守しやすくする変更です。5 つまり合格条件はゴールデンマスターとの差分ゼロです。一方、機能追加・バグ修正の合格条件は意図した差分だけが出ることです。この2つを1つのコミットに混ぜると、差分が出たときに「意図した変更」なのか「壊した」のかを判別できなくなります。
| 変更の種類 | ゴールデンマスターの扱い | 合格条件 |
|---|---|---|
| リファクタリング(構造の変更) | 更新しない | 差分ゼロ |
| バグ修正・機能追加(挙動の変更) | 差分レビュー後に更新する | 意図した差分のみ |
| 継ぎ目作り(メソッド抽出・インターフェース差し込み) | 更新しない | 差分ゼロ |
| 期待値の正規化ルール変更 | 再生成する | 変更理由をコミットメッセージに明記 |
リリース単位でも同じです。「リファクタリングだけのリリース」は挙動が変わらないはずなので、障害が出たら即座にリファクタリングを疑えます。混ぜてしまうと、この切り分けが効きません。
6.2 期待値の更新は「差分レビュー→上書き」の順で
挙動を意図して変えたら、ゴールデンマスターも更新します。手順を固定してください。
- 変更後の出力を生成し、現在の期待値との差分を目視でレビューする
- 差分が意図した変更だけであることを確認する(意図しない行が1行でも変わっていたら調査)
- 新しい出力で期待値ファイルを上書きし、コードと同じコミットに含めて履歴に残す
危険なのは「テストが赤くなったから期待値を上書きして緑にする」という運用です。これをやると、退行がそのまま「正」として記録され、安全網が安全網でなくなります。
判断のしかたは、実際の差分を見るのがいちばん早いので例を挙げます。「消費税率10%の商品に軽減税率8%を追加する」改修をしたとして、期待値との差分がこう出たとします。
2026/06/30,A商事,事務用品, 10000, 1000, 11000
- 2026/06/30,A商事,飲料(軽減), 5000, 500, 5500
+ 2026/06/30,A商事,飲料(軽減), 5000, 400, 5400
2026/06/30,A商事,小計, 15000, 1500, 16500
- 2026/06/30,B工業,機械部品, 200000, 20000, 220000
+ 2026/06/30,B工業,機械部品, 200000, 20001, 220001
上2行(軽減税率の行の税額が500→400に変わった)は意図した変更です。改修の目的そのものなので、期待値を更新して構いません。しかし下2行、軽減税率と関係ないはずのB工業の税額が1円ずれているのは退行です。おそらく端数処理の共通関数に手を入れてしまっています。ここで「まあ1円だし」と期待値を上書きすると、以後この1円ずれが「正しい挙動」として固定されます。
運用ルールは1文にまとめられます。差分の1行1行について「なぜこの行が変わったか」を説明できないなら、期待値を更新してはいけません。説明できない行が1つでもあれば、原因が分かるまで調査します。ちなみに上の例では、小計行が更新されていないことにも気づけます(軽減税率の行が変われば小計も変わるはず)。変わるべきなのに変わっていない行も、差分レビューで見つけるべき対象です。
6.3 CIがなくても、手元で回る最小構成を作る
CIサーバーがない現場でも、次の最小構成なら今日から始められます。
- ソリューションにテストプロジェクトを1つ追加する(.NET Frameworkのままでも、MSTest / NUnit / xUnitいずれも動きます。つなぎ方は3.4節)
- 期待値ファイルと入力データは
TestDataフォルダーに置き、コードと一緒にバージョン管理する - コミット前にテストを手で実行することをチームの約束にする
- 実行結果の確認を忘れないよう、リリース手順書に「テスト実行と差分ゼロ確認」を1行入れる
実行手段はdotnet testだけではありません。旧形式(非SDKスタイル)のcsprojが混ざったソリューションでは、dotnet testが期待どおりに動かないことがあります。その場合の選択肢は2つです。
- Visual Studioのテストエクスプローラー。ビルドすればテストが自動的に検出され、GUIから実行できます。テスト未経験のメンバーにはこちらのほうが導入が楽です。
vstest.console.exe。ビルド済みのテストDLLを直接指定して実行するコマンドラインツールで、Developer Command Promptから使えます。6
vstest.console.exe MyApp.Tests\bin\Debug\MyApp.Tests.dll /logger:trx
どれを使うかは環境に合わせて構いません。大事なのは「コミット前に必ず1回走らせる」という約束のほうです。
テストの実行結果やアプリ側の出力を突合する際には、ログが整備されているほど原因調査が速くなります。ログに何を残すべきかは「自作ロガーの最小要件と結合テストチェックリスト」で扱っています。
7. まとめ
- レガシーコードとは「テストのないコード」であり、1 触ると壊れる本当の原因は、変更結果を確かめる手段がないことです。直す前に、現在の挙動をテストで固定します。
- 特性化テストは「正しい挙動」ではなく「現在の挙動」を記録するテストです。帳票・CSV・計算結果をそのまま期待値ファイルに保存して差分比較するゴールデンマスター法(承認テスト / スナップショットテスト)なら、素朴なC#コードだけで始められます。
- 最初の関門は「テストプロジェクトから既存EXEのコードを呼べるようにする」ことです。EXEプロジェクトもプロジェクト参照でき、
internalのままテストしたいなら本体側にInternalsVisibleToを1行足します(3.4節)。2 - テストを差し込めない構造には、メソッド抽出とインターフェース差し込みで継ぎ目(seam)を作ります。
DateTime.Nowのような依存を包む手法はMicrosoftのユニットテスト指針でも示されている定石です。43 - どこまで整備するかは改修規模×残存年数×障害時の影響で決めます。「特性化テストだけ」「触らない」も立派な判断です。
- 運用ではリファクタリング(差分ゼロが合格)と機能追加(意図した差分だけが合格)を混ぜないこと、5 期待値の更新は必ず差分レビューを通すことを守ります。CIがなくても、手元でテストを回す約束だけで安全性は大きく変わります。
関連記事
- ユニットテストと結合テストの境界をどう引くか
- VB6アプリはいつまで動くのか ── ランタイムのサポート状況と現実的な.NET移行の進め方
- VB6 / Access業務アプリの延命と移行 ── 残す・包む・置き換えるの判断表
- 自作ロガーの最小要件と結合テストチェックリスト
- WindowsデスクトップアプリのUI自動テスト
関連する相談領域
合同会社小村ソフトでは、テストのない既存業務アプリへの特性化テストの導入、テスト可能な構造への段階的なリファクタリング、改修と移行のどちらに投資すべきかの判断整理を扱っています。
参考リンク
-
Michael C. Feathers, “Working Effectively with Legacy Code” (Prentice Hall, 2004). 邦訳『レガシーコード改善ガイド』(翔泳社)。レガシーコードを「テストのないコード」と定義していること、特性化テスト(characterization test)により現在の挙動を記録してから変更に着手する手順、テストを差し込むための継ぎ目(seam)の概念について。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, InternalsVisibleToAttribute Class. 通常は同一アセンブリ内でしか見えない型・メンバを、指定したフレンドアセンブリから見えるようにする属性であること、対象は
internal/protected internal/private protectedでありprivateは含まれないこと、現在のアセンブリとフレンドアセンブリは両方とも未署名か両方とも強い名前付きでなければならないこと、強い名前付きの場合は公開鍵トークンではなく完全な公開鍵を指定する必要があり、sn -pとsn -tpで取得できることについて。 ↩ ↩2 ↩3 -
Microsoft Learn, Extract and inline refactorings (Visual Studio). Visual StudioのC# / Visual Basic向けメソッド抽出(Ctrl+R, M)およびインターフェース抽出(Extract Interface)リファクタリングの操作手順について。 ↩ ↩2 ↩3
-
Microsoft Learn, Unit testing best practices for .NET. 良いユニットテストの性質(fast / isolated / repeatable / self-checking / timely)、
DateTime.Nowのような制御できない依存をインターフェースで包んで継ぎ目(seam)を導入する手法、インフラ依存をユニットテストに持ち込まず結合テストへ分けるべきことについて。 ↩ ↩2 ↩3 -
Microsoft Learn, Refactor code (Visual Studio). リファクタリングとは、挙動を変えることなく、コードを保守・理解・拡張しやすくするために変更するプロセスであるという定義について。 ↩ ↩2
-
Microsoft Learn, VSTest.Console.exe command-line options. VSTest.Console.exeがテストを実行するコマンドラインツールであること、テストファイル(DLL)を直接指定して実行できること、Developer Command Promptから利用できることについて。 ↩
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
VB6アプリはいつまで動くのか ── ランタイムのサポート状況と現実的な.NET移行の進め方
VB6アプリはいつまで動くのか。ランタイムはWindows 11でも動作対象、IDEはサポート終了という現状を整理し、全面リライト・自動変換・段階移行の判断表、移行前の棚卸し、VB6と.NETの非互換までをまとめます。
DLL・COMインターフェースの後方互換性 ── どの変更が呼び出し側を壊すのかの判断表
DLLやCOMコンポーネントのどの変更が呼び出し側を壊すのか。バイナリ互換・ソース互換・動作互換の3層を整理し、変更内容別の判断表、COMインターフェース不変の鉄則、semver運用までを実務ガイドとしてまとめます。
業務アプリのDBスキーマをバージョン管理する ── 「客先ごとにDBが違う」を防ぐマイグレーションの実践
客先ごとに分散する業務アプリのDBスキーマをバージョン管理する実践ガイド。PRAGMA user_versionと前進マイグレーションのC#実装、EF Core Migrations・DbUp・自前実装の判断表、2段階リリースまで整理します。
ソースコードも仕様書もないシステムを引き継いだら ── 止めずに運用・保守するための実務手順
ソースコードも仕様書もない業務システムの運用・保守を始める実務手順を整理します。動いている環境の保全とバックアップ、実行ファイル・DBの棚卸し、挙動からの仕様復元、延命・ラップ・再構築の判断まで解説します。
Windowsのプロセス間通信をどう選ぶか ── 名前付きパイプ / TCP / gRPC / 共有メモリ / COM 判断表
Windowsアプリ同士の連携手段をどう選ぶか。名前付きパイプ、ローカルTCP、gRPC、共有メモリ、ファイル連携、COMの得意分野と落とし穴を判断表で整理し、定番構成と名前付きパイプの実装例まで解説します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
ActiveX / 移行テーマ
COM / ActiveX / OCX を残すか、包むか、置き換えるかを整理するトピックです。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
技術相談・設計レビュー
改修方針、設計レビュー、既存資産の扱いを整理するための技術相談です。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- 特性化テスト(characterization test)とは何ですか?
- 「正しい挙動」ではなく「現在の挙動」をそのまま記録するテストです。仕様書が残っていないレガシーコードでは、何が正しいかを確かめる手段がないことが多いため、まず今動いているコードの出力(帳票、CSV、計算結果など)を期待値として保存し、変更の前後で出力が変わっていないことを機械的に確認します。挙動を固定する安全網を張ってから、リファクタリングや機能追加に進むのが基本的な使い方です。
- テストがまったくないレガシーコードは、どこから手を付ければよいですか?
- これから変更する箇所の周辺だけに絞って特性化テストを書くのが現実的です。システム全体にテストを張るのは工数的に成立しないことがほとんどで、その必要もありません。まず変更対象の機能が生成する出力(帳票、CSV、DBへの書き込み内容など)を特定し、代表的な入力での出力をファイルに保存して固定します。その安全網の内側でメソッド抽出などの小さなリファクタリングを行い、ロジックをテスト可能な形に切り出してから本来の変更に着手します。
- リファクタリングと機能追加を同じコミットに混ぜてはいけないのはなぜですか?
- 出力の差分が出たときに、原因を切り分けられなくなるからです。リファクタリングは「挙動が変わらないこと」を、機能追加は「意図した箇所だけ挙動が変わること」を確認する作業で、検証の合格条件が正反対です。混ぜてしまうと、ゴールデンマスターとの差分が「意図した変更」なのか「壊した」のかを判別できません。リファクタリングのコミットでは差分ゼロ、機能追加のコミットでは意図した差分だけ、と分けて確認するのが安全です。
- ゴールデンマスター(期待値ファイル)はいつ更新しますか?
- 意図して挙動を変えたとき、つまり機能追加や不具合修正のコミットのタイミングだけです。更新の際は、変更前後の出力差分を目視でレビューし、意図した変更だけが含まれていることを確認してから新しい出力で期待値を置き換えます。テストが赤くなったからといって機械的に期待値を上書きすると、退行(意図しない挙動変化)をそのまま「正」として取り込んでしまい、安全網としての意味がなくなります。