「明明知道该改哪里,可是一想到会不会因为动了这里而把别的地方弄坏,就下不去手」──这是从接手了没有测试的业务应用的人那里,经常听到的一句话。
许多用 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 步)
打破这个恶性循环的入口,并不是「鼓起勇气进行大规模重构」。顺序恰恰相反:先张好安全网(测试),先消除害怕的根源,然后再动手修改。不过这里存在一个鸡生蛋、蛋生鸡的问题。要写测试,需要有可测试的结构;而要做成可测试的结构,又必须改动(重构)代码。结果就变成了要在没有测试的情况下,去改动没有测试的代码。
为了解开这个矛盾,遗留代码的改造要按下面的顺序推进。1
- 只针对要改动的部分周边,从外部把当前的行为固定下来(特性化测试)
- 在这张安全网内部,进行破坏风险极低的最小限度改动(如提取方法),做出可以插入测试的接口
- 一旦结构变得可以编写细粒度的测试,就着手进行真正想做的改动(重构、功能追加)
接下来的章节,将具体展开第 1 步与第 2 步。
3. 特性化测试 ── 记录「当前行为」
3.1 与普通测试的区别
普通的测试验证的是「按规格应该如此」的正确行为。特性化测试不同,它记录的是现在的代码实际是如何运行的,而把是否正确的判断先搁置一边。
举例来说,假设小数尾数处理是四舍五入还是直接舍去,规格文档里并没有写明。如果现行代码是按舍去来运行的,而业务已经这样运转了十年,那么至少「是舍去处理」这一点,就是事实上的规格。特性化测试会把这一点原样以「当前的输出是某某」的形式固定下来。即便这其实是个 bug,也先固定下来。改变行为(修复 bug)这件事,要等安全网建好之后,再作为一次刻意的改动单独进行。
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 等差异比较工具与期望值进行对比,从而加快排查速度。
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 更新期望值要遵循「审查差异 → 覆盖」的顺序
一旦刻意改变了行为,就要同时更新黄金母版。请固定下面的步骤。
- 生成改动后的输出,目视审查它与当前期望值之间的差异
- 确认差异只包含预期中的改动(哪怕只有一行不在预期之内也要去调查)
- 用新的输出覆盖期望值文件,并纳入与代码相同的提交中留存到历史记录里
危险的运营方式是「因为测试变红了,所以覆盖期望值让它变绿」。一旦这样做,退化就会原样被记录为「正确」,安全网也就不再是安全网了。
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,只要团队约定好在本地跑测试,安全性也会大不相同。
相关文章
- 如何划定单元测试与集成测试的边界
- VB6 应用能用到什么时候 ── 运行时支持现状与务实的 .NET 迁移做法
- VB6 / Access 业务应用的续用与迁移 ── 保留、封装、替换的判断表
- 自建日志组件的最低要求与集成测试清单
- Windows 桌面应用的 UI 自动化测试 ── UI Automation 原理与用 FlaUI 打造不易损坏的测试
相关咨询领域
合同会社小村软件(合同会社小村ソフト)提供针对没有测试的现有业务应用引入特性化测试、向可测试结构分阶段重构,以及帮助梳理改造与迁移之间该如何投入的判断支持。
参考链接
-
Michael C. Feathers, “Working Effectively with Legacy Code” (Prentice Hall, 2004). 中文译本《修改代码的艺术》。介绍了把遗留代码定义为「没有测试的代码」、用特性化测试(characterization test)先记录当前行为再着手改动的步骤,以及用于插入测试的接缝(seam)这一概念。 ↩ ↩2 ↩3 ↩4
-
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
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
VB6 应用能用到什么时候 ── 运行时支持现状与务实的 .NET 迁移做法
VB6 应用程序到底能用到什么时候?本文整理 VB6 运行时的支持政策(Windows 11 也在支持范围内)与 IDE 支持早已终止这一不对称现状,并以实务指南的形式说明全面重写、自动转换、分阶段迁移的判断表、迁移前的资产盘点、VB6 与 .NET 的不兼容之处,以及 C...
DLL・COM 接口的向后兼容性 ── 判断哪些改动会破坏调用方的对照表
DLL 或 COM 组件的哪些改动会破坏调用方?本文整理二进制兼容、源代码兼容、行为兼容这三层概念,给出按改动类型划分的判断表、COM 接口不可变的铁律,以及 semver 的实务运用方法,作为一份实务指南。
业务应用数据库架构的版本管理 ── 防止「每个客户数据库都不一样」的迁移实践
一份为分散在各客户处的业务应用数据库架构做版本管理的实践指南。整理了 PRAGMA user_version 与前向迁移的 C# 实现、EF Core Migrations・DbUp・自行实现的判断表,以及两阶段发布策略。
接手了没有源代码也没有文档的系统 ── 在不中断运行的前提下开展运维保守的实务步骤
本文整理在没有源代码、也没有文档的业务系统上开始运维与保守工作的实务步骤。涵盖对运行环境的保全与备份、可执行文件与数据库的盘点、从行为中还原规格,以及续用、封装、重建之间的判断。
Windows 的进程间通信该怎么选 ── 命名管道 / TCP / gRPC / 共享内存 / COM 判断表
整理 Windows 应用程序之间应该如何选择通信方式。用判断表整理命名管道、本地 TCP、gRPC、共享内存、文件对接、COM 各自的优势与陷阱,并从实务角度说明 UI+服务分离・32bit/64bit 桥接・权限边界等常见架构,以及命名管道的实现示例。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
ActiveX 迁移
整理保留、包装或替换 COM / ActiveX / OCX 资产的阶段性判断的主题页面。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
支持包含常驻处理、设备联动、运行日志与可维护结构的 Windows 桌面应用程序。
技术咨询 & 设计评审
协助梳理设计方向、架构边界、生命周期责任,以及既有 Windows 资产的处理方式。
常见问题
汇总了咨询这一主题时常见的问题。
- 什么是特性化测试(characterization test)?
- 这是一种如实记录「现在的行为」而非「正确的行为」的测试。在没有留下规格文档的遗留代码中,往往没有办法确认什么才是正确的,因此首先要把现在运行中的代码所产生的输出(报表、CSV、计算结果等)保存为期望值,并在改动前后机械化地确认输出没有发生变化。基本用法是先张好这张固定行为的安全网,再进入重构或功能追加。
- 完全没有测试的遗留代码,应该从哪里入手?
- 现实的做法是,只针对接下来要改动的部分周边编写特性化测试。给整个系统铺测试,在工时上大多站不住脚,也没有这个必要。首先确定要改动的功能会生成什么输出(报表、CSV、写入数据库的内容等),用具有代表性的输入取得输出并保存到文件中固定下来。在这张安全网内部,进行提取方法等小规模重构,把逻辑切分成可测试的形态,然后再着手真正想做的改动。
- 为什么不能把重构和功能追加混在同一个提交里?
- 因为一旦输出出现差异,就无法区分原因。重构要确认的是「行为没有变化」,功能追加要确认的是「只有预期的地方行为发生了变化」,两者的合格条件正好相反。如果混在一起,就无法判断与黄金母版之间的差异究竟是「预期中的改动」还是「改坏了」。比较安全的做法是:重构提交要求零差异,功能追加提交只允许出现预期中的差异,分开确认。
- 黄金母版(期望值文件)应该在什么时候更新?
- 只应该在刻意改变行为的时候更新,也就是功能追加或缺陷修复的提交时机。更新时,应目视审查改动前后的输出差异,确认其中只包含预期的改动,然后再用新的输出替换期望值。如果因为测试变红就机械式地覆盖期望值,就会把退化(非预期的行为变化)原样当作「正确」纳入,安全网也就失去了意义。
作者简介
本文作者的个人简介页面。
Go Komura
小村软件有限公司 代表
以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。