如何安全地为没有测试的遗留业务应用做修改 ── 特性化测试与重构实战

· 更新日期: · · 遗留技术, 现有资产活用, 重构, 测试设计, 特性化测试, C#, .NET, 维护, 判断表, 技术咨询

「明明知道该改哪里,可是一想到会不会因为动了这里而把别的地方弄坏,就下不去手」──这是从接手了没有测试的业务应用的人那里,经常听到的一句话。

许多用 VB6、.NET Framework、Access 编写的业务应用,都没有自动化测试。规格文档也早已停止更新,处于「代码就是唯一规格文档」的状态。尽管如此,业务仍在继续运转,消费税率变更、报表版式修正、新增交易对象之类的改造需求,可不会等你准备好。

本博客此前在「VB6应用还能撑多久 ── 现实的.NET迁移推进方式」中,介绍过把旧系统当作「活的规格文档」、通过比对输出来推进迁移的思路。这篇文章要把同样的思路,应用到不是迁移,而是「直接在正在运行的代码上动手」的场景中。核心工具是特性化测试(characterization test)。即便代码没有测试,只要先用测试把接下来可能被破坏的行为固定下来,无论是重构还是功能追加,安全性都会显著提升。

1. 先说结论

  • 不要贸然直接修改,先用测试固定现状的行为。即使没有规格文档,此刻正在运行的代码的输出本身就是规格。
  • 为此所使用的工具就是特性化测试。它不是验证「正确行为」,而是记录「当前行为」的测试:把报表、CSV、计算结果等输出原样保存为期望值,在改动前后进行差异比较(黄金母版法)。
  • 对于无法插入测试的结构(逻辑直接写在 UI 事件处理程序中、直接引用 DateTime.Now 或文件路径),用提取方法与接口注入这类最小限度的改动来创建「接缝(seam)」。不需要大动干戈。
  • 不要把重构和功能追加混在同一个提交里。重构的合格条件是「零差异」,功能追加的合格条件是「只有预期中的差异」,混在一起就无法判断差异到底意味着什么。
  • 测试要建设到什么程度,由改造规模 × 系统剩余寿命 × 故障时的影响三者共同决定。给所有代码都铺上单元测试并不总是正确答案,也有「只做特性化测试」「不要动它」才是正确答案的场景。
  • 即使没有 CI 也能开始。只要有一个测试项目和一个存放期望值文件的文件夹,哪怕只是在本地手动运行,安全性也会大不相同。

2. 为什么遗留代码「一碰就坏」

改造遗留代码之所以令人害怕,并不是因为代码老旧,而是因为没有办法确认改动的结果是否正确

Michael Feathers 在其著作《修改代码的艺术》(Working Effectively with Legacy Code)中,把遗留代码定义为不是「单纯老旧的代码」,而是「没有测试的代码」1 理由是:如果没有测试,就没有办法在每次改动时快速确认代码究竟是变好了还是变坏了。按照这个定义,即便是昨天刚写的代码,只要没有测试,也是遗留代码。

没有测试的代码,会开始陷入下面这种恶性循环。

  1. 因为没有测试,不知道改动的影响范围,令人害怕
  2. 因为害怕,就不去修正现有结构,用最小限度的复制粘贴加条件分支来应付
  3. 这种权宜之计的修补不断累积,代码变得更难读、更容易坏
  4. 更容易坏了,就变得更加害怕(回到第 1 步)

打破这个恶性循环的入口,并不是「鼓起勇气进行大规模重构」。顺序恰恰相反:先张好安全网(测试),先消除害怕的根源,然后再动手修改。不过这里存在一个鸡生蛋、蛋生鸡的问题。要写测试,需要有可测试的结构;而要做成可测试的结构,又必须改动(重构)代码。结果就变成了要在没有测试的情况下,去改动没有测试的代码。

为了解开这个矛盾,遗留代码的改造要按下面的顺序推进。1

  1. 只针对要改动的部分周边,从外部把当前的行为固定下来(特性化测试)
  2. 在这张安全网内部,进行破坏风险极低的最小限度改动(如提取方法),做出可以插入测试的接口
  3. 一旦结构变得可以编写细粒度的测试,就着手进行真正想做的改动(重构、功能追加)

接下来的章节,将具体展开第 1 步与第 2 步。

3. 特性化测试 ── 记录「当前行为」

3.1 与普通测试的区别

普通的测试验证的是「按规格应该如此」的正确行为。特性化测试不同,它记录的是现在的代码实际是如何运行的,而把是否正确的判断先搁置一边。

举例来说,假设小数尾数处理是四舍五入还是直接舍去,规格文档里并没有写明。如果现行代码是按舍去来运行的,而业务已经这样运转了十年,那么至少「是舍去处理」这一点,就是事实上的规格。特性化测试会把这一点原样以「当前的输出是某某」的形式固定下来。即便这其实是个 bug,也先固定下来。改变行为(修复 bug)这件事,要等安全网建好之后,再作为一次刻意的改动单独进行。

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 等格式
写入数据库的内容 在写入之后 SELECT 表内容,转换为 CSV 再比较
画面显示本身 如果能落地为字符串就可行。画面操作的自动化需要另一套工具,相关内容见「Windows桌面应用的UI自动化测试
向外部系统发送 需要一个能在发送前一刻捕获数据的接缝(下一章介绍)

4. 构建可以插入测试的「接缝(seam)」

一旦着手编写黄金母版测试,很多遗留代码就会碰壁:逻辑直接写在 UI 事件处理程序里,不启动画面就没法执行。这时候需要的,就是能够从测试代码替换、观测行为的位置──也就是 Feathers 所说的接缝(seam)1

4.1 用提取方法把逻辑从 UI 中剥离出来

典型的「改动前」状态是这样的:计算、数据库访问、对时间的依赖、画面更新,全都挤在同一个事件处理程序里。

// 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");                  // 画面へ直接反映
}

照这样下去,要测试当月汇总逻辑,就需要画面、数据库以及「今天的日期」这三样东西。以最小改动使其变得可测试的定式做法是:只把计算部分提取成方法,把外部依赖(数据库结果和当前时间)改成参数。使用 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");
}

事件处理程序这边变成了「读取 → 计算 → 显示」这三行,而被提取出来的方法可以用任意的行数据和任意的日期来测试。像月末、月初、闰年这类与时间相关的边界情形,只需要传入 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」的默认构造函数,让调用方逐步迁移。文件路径或数据库连接字符串的硬编码,也可以用同样的思路,包进一个只暴露「读写」操作的小接口里。

另外,Visual Studio 内置了从既有类提取接口的重构功能(Extract Interface),可以机械化地完成这类改动。2

创建接缝时要守住的原则只有一条:创建接缝的改动本身,行为一毫米都不能改变。提取方法和接口注入,都是可以借助编译器和 IDE 支持机械化完成、对行为保持性极高的操作。这个阶段很容易忍不住想「顺便」把逻辑也改一改,但那是安全网张好之后的工作。

5. 做到什么程度的判断表

特性化测试和创建接缝同样需要花费工时。给所有遗留代码都建设同一水准的测试,在中小规模的现场并不现实,也没有这个必要。判断的坐标轴有三条。

  • 改造的规模:是几行代码的缺陷修复、功能追加,还是伴随结构变化的改动
  • 系统的剩余寿命:是预计再有 1~2 年就要迁移或停用,还是要继续使用 5 年以上
  • 故障时的影响:只是报表外观走样这种程度,还是会算错账单金额或库存数量
改造规模 剩余寿命 故障时的影响 推荐的水准
轻微(几行代码・配置值变更) 短(~2年) 小(显示走样程度) 只做特性化测试。固定对应的输出后改动,确认差异后即可结束
轻微~中等 大(涉及金额・库存) 只做特性化测试,但要做得更厚实。增加包含边界情形在内的输入模式
中等(功能追加・逻辑变更) 长(5年以上) 小~中 特性化测试 + 只针对改动部分周边建设单元测试(创建接缝)
中~大 特性化测试 + 单元测试建设 + 把发布单位细分
大(需要结构翻新) 不要动它。不做改造,改为运营层面规避,把工时投入到迁移・替换上
─(本来就没有改造需求) 不要动它。不对正在运行的代码做预防性重构

最下面两行的「不要动它」,不是消极的选择,而是积极的判断。给剩余寿命很短的系统投资内部质量,是收不回成本的。这部分工时,应该用在「VB6 / Access 业务应用的续用与迁移 ── 保留、封装、替换的判断表」中整理过的迁移判断,以及迁移目标的设计上。

另外,即便进展到「建设单元测试」这一步,也需要划清什么写进单元测试、什么留给集成测试(使用真实数据库、真实文件的测试)。这条界线已经在「如何划定单元测试与集成测试的边界」中以判断表的形式整理过,请一并参考。基于单元测试应当具备 fast / isolated / repeatable 这些性质,3 稳妥的做法是把涉及数据库或文件的特性化测试,与单元测试分放到不同的项目、不同的执行单位中。

6. 运营规则 ── 不要弄坏这张安全网

特性化测试一旦写完之后运营方式出错,很容易就流于形式。这里把最低限度的规则收敛为三条。

6.1 不要把重构和功能追加混在同一个提交里

所谓重构,是指在不改变行为的前提下,让代码变得更易理解、更易维护的改动。4 也就是说,它的合格条件是与黄金母版之间的差异为零。而功能追加、缺陷修复的合格条件,则是只出现预期中的差异。把这两者混进同一个提交里,一旦出现差异,就无法判断这究竟是「预期中的改动」还是「改坏了」。

改动的种类 对黄金母版的处理 合格条件
重构(结构变化) 不更新 差异为零
缺陷修复・功能追加(行为变化) 差异审查之后更新 只允许预期中的差异
创建接缝(提取方法・接口注入) 不更新 差异为零
期望值的规范化规则变更 重新生成 在提交信息中写明变更理由

在发布单位这个层面也是一样。「只有重构的发布」理应不改变行为,因此一旦出了故障,就可以立刻怀疑是重构造成的。而一旦混在一起,这种切分手段就失效了。

6.2 更新期望值要遵循「审查差异 → 覆盖」的顺序

一旦刻意改变了行为,就要同时更新黄金母版。请固定下面的步骤。

  1. 生成改动后的输出,目视审查它与当前期望值之间的差异
  2. 确认差异只包含预期中的改动(哪怕只有一行不在预期之内也要去调查)
  3. 用新的输出覆盖期望值文件,并纳入与代码相同的提交中留存到历史记录里

危险的运营方式是「因为测试变红了,所以覆盖期望值让它变绿」。一旦这样做,退化就会原样被记录为「正确」,安全网也就不再是安全网了。

6.3 即使没有 CI,也要搭建能在本地运行的最小配置

即便现场没有 CI 服务器,只要有下面这样的最小配置,今天就能开始。

  • 在解决方案中新增一个测试项目(即便仍停留在 .NET Framework,MSTest / NUnit / xUnit 都能用)
  • 把期望值文件和输入数据放在 TestData 文件夹里,与代码一起纳入版本管理
  • 把提交前手动运行 dotnet test(或 Visual Studio 的测试资源管理器)定为团队的约定
  • 为了不忘记确认执行结果,在发布流程文档里加上一行「执行测试并确认差异为零」

在比对测试执行结果与应用侧输出时,日志建设得越完善,排查原因的速度就越快。日志里应该保留哪些内容,在「自制日志器的最低要求与集成测试检查清单」中有详细说明。

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

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

什么是特性化测试(characterization test)?
这是一种如实记录「现在的行为」而非「正确的行为」的测试。在没有留下规格文档的遗留代码中,往往没有办法确认什么才是正确的,因此首先要把现在运行中的代码所产生的输出(报表、CSV、计算结果等)保存为期望值,并在改动前后机械化地确认输出没有发生变化。基本用法是先张好这张固定行为的安全网,再进入重构或功能追加。
完全没有测试的遗留代码,应该从哪里入手?
现实的做法是,只针对接下来要改动的部分周边编写特性化测试。给整个系统铺测试,在工时上大多站不住脚,也没有这个必要。首先确定要改动的功能会生成什么输出(报表、CSV、写入数据库的内容等),用具有代表性的输入取得输出并保存到文件中固定下来。在这张安全网内部,进行提取方法等小规模重构,把逻辑切分成可测试的形态,然后再着手真正想做的改动。
为什么不能把重构和功能追加混在同一个提交里?
因为一旦输出出现差异,就无法区分原因。重构要确认的是「行为没有变化」,功能追加要确认的是「只有预期的地方行为发生了变化」,两者的合格条件正好相反。如果混在一起,就无法判断与黄金母版之间的差异究竟是「预期中的改动」还是「改坏了」。比较安全的做法是:重构提交要求零差异,功能追加提交只允许出现预期中的差异,分开确认。
黄金母版(期望值文件)应该在什么时候更新?
只应该在刻意改变行为的时候更新,也就是功能追加或缺陷修复的提交时机。更新时,应目视审查改动前后的输出差异,确认其中只包含预期的改动,然后再用新的输出替换期望值。如果因为测试变红就机械式地覆盖期望值,就会把退化(非预期的行为变化)原样当作「正确」纳入,安全网也就失去了意义。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表