Appearance
接手一个没有单元测试的遗留系统,我会从哪里开始?
接手一个已经运行多年、拥有大量历史代码,却几乎没有单元测试的业务系统,修改一段代码以后,怎样确认原来的业务行为没有受到影响?
以前阅读单元测试相关书籍时,我曾看到过类似的遗留系统补测场景,但当时并没有太深的感受。直到后来真正参与一个遗留业务系统的长期维护,才逐渐理解其中的困难。
系统仍然需要持续处理真实业务,新的需求、缺陷修复和技术改造也在不断进行。但面对已有代码,开发人员往往缺少能够快速判断原有行为是否发生变化的验证依据。
在我接触过的项目中,开发人员的功能验证和测试人员的业务测试,通常是软件交付的主要验证手段。单元测试虽然并不陌生,但编写和执行更多取决于开发人员自己的习惯,而不是明确的交付要求。
面对这样的系统,不可能立即为所有历史方法补齐单元测试(Unit Test,简称 UT)。
真正的问题是:应该先保护哪些业务行为,又该怎样在不影响现有生产运行的前提下建立验证能力?
一、开始之前,先明确几个原则
1. 不轻易改变生产行为
补充 UT 不应影响正在运行的业务。发现疑似缺陷时,先了解背景、确认影响,再决定是否修改。
2. 先理解业务,再考虑测试
先了解业务的重要性、使用频率和已知问题,再进入代码寻找值得验证的逻辑。
3. 现有行为不等于正确行为
旧代码的实际输出可以作为回归基线,但不能直接作为业务正确性的证明。
4. 正常结果与异常行为都需要验证
既要检查正常输入的结果,也要确认非法输入和边界条件是否得到预期处理。
5. 修复问题后,尽量留下可重复的验证
对于适合自动验证的历史问题,尽量留下回归测试,减少相同缺陷再次出现的可能性。
二、为什么已经有功能测试,还需要 UT?
功能测试能够验证业务流程是否符合预期,但一条流程能够正常完成,并不代表其中所有计算、转换和异常处理都正确。
尤其对于金额计算、协议转换等逻辑,一些错误可能不会导致接口失败,而是产生一个看起来正常、实际上不符合预期的结果。
2.1 程序返回了数字,不代表数字正确
例如,一个方法负责计算金额、汇率、比例或统计指标,经过多次计算后返回一个 BigDecimal。
原本应该得到 0.01,结果却变成了 0.1。两者的类型和格式可能完全相同,问题出在数值本身。
在自己的 ETF 数据分析和回测系统中,我也会为历史行情百分比、区间统计等确定性计算保留 UT,通过固定样本验证计算结果。
对于这类计算逻辑,UT 能够直接验证特定输入与预期结果,并在失败时缩小排查范围。
2.2 对象中的字段有值,不代表最终请求正确
另一个实际遇到的问题,来自第三方 API 调用。
开发人员从其他代码中复制了带有 Jackson 注解的 DTO 字段,以为注解能够控制 JSON 字段名称,但项目实际调用链使用的是 Fastjson。
结果,Java 对象中的字段已经赋值,最终发送给第三方的 JSON 字段名却不符合对方的协议。
第三方返回缺少参数的错误,开发人员认为自己明明已经传入了数据。直到检查实际请求报文,才发现问题出在序列化后的字段名称。
这类问题可以使用项目实际采用的序列化逻辑,在本地将 DTO 转换为 JSON,再断言字段名称和值是否符合接口协议。
如此一来,原本可能在第三方联调阶段才暴露的问题,就有机会在更早的代码验证阶段被发现。
2.3 异常输入,也应该得到预期处理
业务系统中有一个 Excel 导入订单的功能。
由于订单号可能是一串很长的数字,Excel 在处理时可能将其表示为科学计数法。如果这类内容进入后续字段转换逻辑,就可能产生用户难以理解的类型转换异常。
后来,我们增加了订单号格式校验,对不符合要求的科学计数法形式提前拒绝,并给出明确的错误提示。
这个改动本身并不复杂,但它对应着一项值得长期保留的业务行为。
UT 可以分别验证:
- 合法订单号能够通过校验;
- 科学计数法形式的订单号会被拒绝;
- 非法输入得到约定的业务错误,而不是任由底层转换异常继续传播。
这样,未来即使调整 Excel 解析或字段转换逻辑,也能够通过这些测试检查原有校验是否仍然有效。
业务校验解决当前问题,UT 保护这项校验行为不在后续修改中退化。
明确了为什么需要 UT,接下来才是更现实的问题:面对大量历史代码,应该先为哪些业务补测试?
三、面对大量遗留代码,我不会先从代码开始
有限的开发时间,应该优先投入到更有业务价值的地方,而不是按照 Java 类和方法的数量平均分配测试工作。
3.1 先了解系统是怎样被使用的
实际接手系统时,开发人员通常首先接触项目相关人员或产品经理,再逐渐了解业务流程和真实使用情况。
对于内部系统,可以找实际业务使用人员了解哪些功能使用频繁、哪些流程容易出问题,以及日常使用中的困难。
对于面向外部客户的系统,则需要结合客户反馈、产品经理提供的信息,并尽可能从真实用户的角度体验系统。
外部依赖较多的业务也值得关注,例如订单创建、支付渠道调用和汇率获取等。
这些业务认识是在日常维护中逐渐积累的,不一定能在接手系统时一次完成。
3.2 再从业务链路进入代码
了解业务后,再定位对应的 Controller、Service、Manager、Component 等实现,寻找可以独立验证的计算、判断、协议转换和文件解析逻辑。
例如,订单 Excel 导入本身是一条完整业务流程。
但真正适合先补 UT 的,可能是其中的订单号校验、字段转换、金额解析等逻辑,而不是立即对整个导入流程建立一套依赖数据库的测试。
业务决定先关注哪里,代码结构决定适合怎样验证。
3.3 结合风险、时间和验证成本决定顺序
补测顺序通常需要综合考虑:
- 业务的重要性和使用频率;
- 是否出现过问题,或者存在已知风险;
- 是否正在修改、重构或扩展相关代码;
- 是否能够以较低成本建立稳定的测试;
- 当前能够安排多少开发时间。
例如,近期正在维护订单导入功能,又发现了长订单号的格式问题,那么相关解析和校验逻辑就值得优先补测。相比之下,没有直接关联当前业务问题的工具方法,即使更容易编写 UT,也不一定需要先处理。
在实际维护中,我主要是在正常需求开发和系统维护之外,利用可以安排出来的时间逐步补充 UT,而不是长期专职从事测试建设。
时间相对充足时,可以主动梳理重点业务,集中补充测试;时间有限时,则结合当前开发和已知问题逐步推进。
对于输入输出明确、依赖较少的代码,也可以让 AI 协助分析、生成测试和补充边界样本。但业务预期和测试断言仍然需要开发人员审查,不能直接把 AI 根据旧代码推导出的结果当作正确答案。
四、第一批 UT,应该怎样建立?
明确了业务范围,接下来才是为具体行为建立可重复的验证。
4.1 优先考虑可离线验证的逻辑
例如:
- 金额、比例、费率及统计计算;
- 不依赖外部环境的状态判断;
- JSON 序列化与反序列化;
- 签名、验签、加密与解密;
- 使用固定 Excel 文件或协议样本的解析逻辑;
- 可以独立验证的工具类。
这些代码通常具有相对明确的输入和输出,也更容易在本地稳定、重复地执行。
例如,可以将经过确认并脱敏的第三方响应样本放在 src/test/resources 中,再通过实际解析代码将 JSON 转换为强类型对象,断言关键字段与结构符合预期。
固定样本、明确的预期结果和稳定的执行环境,使同一项行为能够在后续修改中反复验证。
至于数据库事务和真实 HTTP 调用,并不是不值得测试,而是可能需要选择集成测试(IT)或面向已部署系统的冒烟测试(SmokeIT)。
关于三类测试的执行边界,可以参阅此前的文章:
别把所有测试都叫 Test:我如何用 UT、IT 和 SmokeIT 划分执行边界
4.2 先记录现有行为
一个运行多年的方法,可能没有完整的规则文档,也没有任何可执行测试。
这时可以先构造输入,执行当前代码,观察输出,并将结果固定下来。
这类做法在遗留系统维护中通常称为 Characterization Testing(特征测试)。Michael Feathers 在《修改代码的艺术》(Working Effectively with Legacy Code)中讨论过相关思路。
它首先要了解的是:现有代码实际上做了什么?
对于这类测试,可以在命名或注释中明确说明:
此测试记录当前实现行为,相关业务预期尚待确认。
这样可以避免维护人员在以后将未经业务确认的结果直接视为正确规则。
4.3 再补充边界与异常场景
对于计算方法,可以补充零值、负值、精度和舍入边界。
对于 JSON 解析,可以补充字段缺失、空值、对象与集合等不同输入结构。
对于文件导入,可以补充空单元格、非法格式和字段转换失败等情况。
不需要一次穷举全部输入,更重要的是优先覆盖真实出现过的问题、风险较高的业务边界和容易导致结果明显偏差的情况。
五、谁来证明 UT 的预期结果是正确的?
这是遗留系统补测过程中必须面对的问题。
假设某个历史计算方法接收金额 100.00 和比例 0.01,当前返回 1.00。
开发人员可以先将这个结果固定为测试,以便以后观察行为是否发生变化。
但只凭这个结果,仍然不能证明当前实现一定符合业务规则。
因此,需要区分两种目的。
5.1 记录现有行为
这类测试主要回答:
修改以后,原来的行为有没有发生变化?
它适合作为重构前的行为基线,帮助开发人员识别非预期变化。
但测试中的预期结果只是来自旧实现,尚未经过独立确认。这也是为什么第四章建议明确标注行为基线的性质。
5.2 验证业务正确性
这类测试回答的是另一个问题:
按照已经确认的业务规则,正确结果究竟是什么?
继续使用前面的例子。
假设已经确认的业务规则是:结果等于金额乘以比例。
那么:
100.00 × 0.01 = 1.00
此时,预期值 1.00 就不仅来自旧代码的输出,还能够通过独立的计算规则确认。
java
BigDecimal result = calculator.calculate(
new BigDecimal("100.00"),
new BigDecimal("0.01")
);
assertEquals(
0,
new BigDecimal("1.00").compareTo(result)
);这里使用 compareTo 比较数值,是为了避免 BigDecimal.equals 对小数位数的严格比较:例如 1.0 和 1.00 数值相等,但 equals 返回 false。
如果业务规则要求结果必须保留两位小数,那么还应另行断言精度或输出格式,不能只验证数值相等。
对于更复杂的计算,预期值也可以来自已经确认的公式、人工推导、业务方提供的对账样本,或者独立实现的计算过程。
关键是预期结果需要有独立于被测实现的可信依据。
即使旧代码的输出与最终确认的业务结果一致,也不能省略业务规则的确认。结果相同,不代表验证依据相同。
现有行为基线帮助我们发现变化,业务规则验证帮助我们判断结果是否正确。两者可以同时存在,但不能相互替代。
六、补测试时发现旧代码有问题,怎么办?
为遗留系统补充 UT 时,可能会发现一些过去没有注意到的问题。
例如,某个边界输入处理不合理、计算逻辑存在疑问,或者特殊数据结构没有得到正确支持。
发现问题之后,很容易产生一个想法:既然看到了,为什么不顺手修掉?
但对于正在生产运行的系统,我不会仅仅因为自己认为某段代码有问题,就直接修改它。
6.1 先记录,再了解问题
首先记录异常行为、触发条件和可能的影响,然后向相关人员了解业务规则、历史背景以及现有使用情况。
有些代码看起来不合理,可能确实存在缺陷,也可能承担了以前形成的特殊处理或兼容要求。
如果没有弄清楚这些条件,就直接改变它,可能影响原来正常运行的业务。
因此,发现疑似错误,并不意味着已经具备修改依据。
6.2 确认后再修复
如果确认问题确实需要修复,再根据影响范围安排修改、验证和发布。
补充测试与修改生产业务逻辑,应该是两项能够明确区分的工作。
尤其不能为了让新写的 UT 通过,就顺手改变自己尚未充分理解的历史实现。
对于严重的资金安全或数据完整性风险,则需要及时进入相应的紧急处理流程,同时控制修改范围并完成必要验证。
6.3 修复以后,留下适当的回归验证
我曾遇到过一个 JSON 集合处理问题。
当时,单个对象能够正常处理,但涉及 List 的场景出现异常。后来发现,相关处理逻辑缺少针对数组或集合结构的分支。
问题在测试环境中的 HTTP 调用过程中暴露。修复后,我补充了对应的 SmokeIT,用于验证实际接口链路。
但回头看,当时还应该再补一条底层 UT:直接构造对象和集合,经过实际使用的转换逻辑,断言结果符合预期。
这样即使不启动完整的 HTTP 调用链,也能够验证当初发生问题的那段代码。
这个案例说明,修复缺陷以后,不仅要确认当前功能恢复,还值得考虑在哪一个测试层次留下最直接的回归验证。
同时,保留测试和分享经验并不是同一件事。
UT 可以防止已覆盖的代码行为发生回归;经验分享则能让其他开发人员理解问题原因,减少在其他模块中重复遇到类似问题的可能性。
七、单元测试不是写完就结束
测试代码还需要随着业务变化持续维护。新增功能、调整计算规则或修改接口协议时,都可能需要同步更新测试,否则测试本身也可能逐渐失去意义。
另外,在我接触过的一些项目中,UT 并不是代码提交或上线前的强制环节。即使已经存在不少测试,能否持续执行仍然取决于开发人员是否主动运行。
测试代码已经存在,与测试是否持续参与验证,是两个不同的问题。
结语
接手一个没有单元测试的遗留系统,我不会立即扫描所有 Java 类,也不会要求为每个历史方法补一条测试。
更实际的做法,是先了解业务,识别值得保护的行为,再进入代码寻找适合建立独立验证的逻辑。
对于历史实现,可以先建立现有行为基线,但不能把它直接视为正确性证明。发现疑似缺陷时,也需要先了解业务背景、确认影响,再决定如何修改,避免给正在运行的生产业务引入新的风险。
对于重要计算、协议转换、异常校验和已经修复的问题,则尽量留下稳定、可重复执行的验证。
这些工作的目的,是让后续维护人员在修改代码时,能够更清楚地回答:
原来的行为是什么,正确的结果应该是什么,这次修改改变了什么。
至于如何让这些 UT 持续执行,以及为什么已经建立的定时验证机制后来又停止了,留到下一篇《单元测试跑起来了,为什么后来又停了?》再讨论。