
一、引言:重构遗留代码时,你是否踩过这些坑?
“这个函数逻辑太乱了,我重构一下,保证功能不变。”
相信每个开发者都对这句话不陌生。但现实往往是:重构后的代码语法更优雅、结构更清晰,可上线后却触发了一串线上告警——用户反馈核心功能失效,日志里满是从未见过的异常。
你对着重构前后的代码反复比对,最终发现:原来旧代码里藏着一个未被文档记录的"隐含逻辑"——当输入参数为 0 时,会自动触发兼容旧版本接口的适配处理。而你在重构时,误以为这是冗余代码,直接删除了。
类似的场景在遗留系统维护中屡见不鲜:无文档、无测试、逻辑晦涩的代码如同"黑盒",任何修改都像在拆炸弹。有没有一种方法,能给现有代码的行为"拍张快照",确保后续修改不会破坏已有功能?
答案就是特性测试(Characterization Test)(维基链接)。
二、什么是特性测试?
2.1 通俗定义:给代码的"实际行为"拍张可验证的快照
特性测试的核心目标,是捕捉代码当前的实际行为,并将其固化为可自动化执行的测试用例。它不依赖预先定义的需求文档,而是基于代码"真实运行的结果"来构建测试——简单说就是:
先观察代码"实际能做什么",再把这个"能力"记录下来,后续任何修改只要破坏了这个"能力",测试就会立即报警。
用一个生活类比理解:你接手了一辆老旧汽车,不知道它的设计标准,但需要对发动机进行维修。为了避免维修后车辆无法正常行驶,你会先记录下当前车辆的关键状态:怠速时的转速、百公里油耗、最高时速、爬坡能力等。这些"当前状态记录"就相当于特性测试;维修后只要这些状态与记录不符,就说明维修出了问题。
对应到代码:你接手一个无文档的遗留函数,先输入不同的参数,记录下函数的输出结果(包括正常场景、边界场景、异常场景),再把"输入-输出"的对应关系写成测试用例。这些测试用例就是代码的"行为快照",后续重构只要改变了任何一个"输入-输出"对应关系,测试就会失败。
2.2 与传统测试的核心区别:从"验证需求"到"记录行为"
我们最熟悉的传统测试(如单元测试、功能测试),都是**“基于需求”**的——先有明确的需求文档,再根据需求编写测试用例,验证代码是否符合需求。
而特性测试是**“基于实际行为”**的——没有需求文档也能写,核心是"记录现有行为"而非"验证是否符合需求"。
| 对比维度 | 特性测试 | 传统单元测试/功能测试 |
|---|---|---|
| 编写依据 | 代码的实际运行行为 | 预先定义的需求规格文档 |
| 编写时机 | 代码已存在后(常用于遗留系统) | 需求明确后,可与开发同步(如 TDD 模式) |
| 核心目标 | 保护现有行为不被破坏,降低修改风险 | 验证代码是否符合设计需求,确保功能正确 |
| 对"缺陷"的态度 | 暂时记录现有缺陷,避免修改时缺陷扩大 | 识别并验证缺陷是否被修复 |
| 适用场景 | 遗留系统重构、无文档代码维护 | 新功能开发、需求明确的模块验证 |
关键提醒:特性测试不判断现有行为"是否正确",只记录"是什么"。如果现有代码存在已知缺陷,你可以先编写特性测试记录该缺陷(确保重构时不会忽略它),再修复缺陷并更新测试用例。
三、实战案例:用特性测试重构遗留订单计算函数
下面通过一个贴近电商业务的案例,完整演示如何在实际工程中使用特性测试。
案例背景:你接手了一个电商系统的遗留代码,其中 calculate_order_amount 函数负责计算订单总金额,但该函数无注释、无文档,且没有任何测试用例。现在需要重构该函数(优化逻辑冗余),同时确保不影响现有业务。
3.1 步骤 1:分析遗留代码,识别当前结构
首先,我们找到遗留代码的实现:
def calculate_order_amount(products, coupon_code=None):
# 无任何注释,逻辑晦涩
total = 0
for p in products:
if p["type"] == "normal":
total += p["price"] * p["quantity"]
elif p["type"] == "promotion":
if p["price"] < 100:
total += p["price"] * p["quantity"] * 0.9
else:
total += p["price"] * p["quantity"] * 0.85
if coupon_code:
if coupon_code.startswith("FULL"):
threshold = int(coupon_code.split("_")[1])
if total >= threshold:
discount = int(coupon_code.split("_")[2])
total -= discount
elif coupon_code == "NEW_USER_10":
total -= 10
if total < 0:
total = 0
return total
由于没有文档,我们无法直接确定函数的设计意图,只能通过"输入不同参数,观察输出结果"的方式,反向推导并记录它的实际行为。
3.2 步骤 2:探索性运行,收集实际行为
这是特性测试区别于传统测试的关键步骤:我们不是根据需求文档预设输入输出,而是先运行代码、观察结果,再把观察到的结果固化为测试用例。
第一步:设计探索性输入
基于代码结构分析,我们识别出需要探索的输入维度:
- 商品类型:
normal、promotion - 促销商品价格:小于 100、等于 100、大于 100(边界值)
- 优惠券类型:无优惠券、
FULL_*满减券、NEW_USER_10新人券 - 商品组合:单一类型、混合类型
第二步:运行代码,记录实际输出
我们在 Python 交互环境中逐个探索,让代码告诉我们它的行为:
>>> from order_utils import calculate_order_amount
# 探索1:普通商品基础计算
>>> products = [{"type": "normal", "price": 50, "quantity": 2}]
>>> calculate_order_amount(products)
100 # 记录:50*2=100,符合直觉
# 探索2:促销商品,价格 < 100
>>> products = [{"type": "promotion", "price": 80, "quantity": 3}]
>>> calculate_order_amount(products)
216.0 # 记录:80*3*0.9=216,看来是9折
# 探索3:促销商品,价格 = 100(边界值)
>>> products = [{"type": "promotion", "price": 100, "quantity": 2}]
>>> calculate_order_amount(products)
170.0 # 记录:100*2*0.85=170,边界值走85折分支
# 探索4:促销商品,价格 > 100
>>> products = [{"type": "promotion", "price": 200, "quantity": 2}]
>>> calculate_order_amount(products)
340.0 # 记录:200*2*0.85=340,确认≥100走85折
# 探索5:满减优惠券 - 纯普通商品
>>> products = [{"type": "normal", "price": 250, "quantity": 1}]
>>> calculate_order_amount(products, coupon_code="FULL_200_30")
220 # 记录:250-30=220,满减生效
# 探索6:满减优惠券 - 混合商品(这里发现意外行为!)
>>> products = [
... {"type": "normal", "price": 150, "quantity": 1},
... {"type": "promotion", "price": 90, "quantity": 1}
... ]
>>> calculate_order_amount(products, coupon_code="FULL_200_30")
231.0 # ⚠️ 意外!150+81=231,满足>=200,但没有减30!
# 探索7:再次验证 - 纯促销商品 + 满减
>>> products = [{"type": "promotion", "price": 250, "quantity": 1}]
>>> calculate_order_amount(products, coupon_code="FULL_200_30")
212.5 # ⚠️ 250*0.85=212.5,也没减30!满减似乎只对纯普通商品生效?
# 探索8:新人券,正常抵扣
>>> products = [{"type": "normal", "price": 50, "quantity": 1}]
>>> calculate_order_amount(products, coupon_code="NEW_USER_10")
40 # 记录:50-10=40
# 探索9:新人券,抵扣后为负
>>> products = [{"type": "normal", "price": 5, "quantity": 1}]
>>> calculate_order_amount(products, coupon_code="NEW_USER_10")
0 # 记录:5-10=-5,但返回0,有保底逻辑
第三步:分析探索结果,提炼关键发现
通过探索性运行,我们发现了几个非显而易见的行为:
| 发现 | 观察到的行为 | 是否符合直觉 |
|---|---|---|
| 促销折扣边界 | 价格 = 100 时走 85 折(>= 判断应该是 <) | ⚠️ 需确认 |
| 满减券限制 | 只对纯普通商品订单生效,包含促销商品时不生效 | ⚠️ 疑似 Bug |
| 新人券保底 | 抵扣后金额为负时返回 0 | ✅ 合理 |
这些发现正是特性测试的核心价值——它们揭示了代码中隐藏的业务规则或潜在缺陷,而这些在没有文档的情况下,只能通过实际运行来发现。
第四步:将观察结果固化为测试用例
现在,我们把探索过程中收集到的"输入-输出"对转化为自动化测试:
import pytest
from order_utils import calculate_order_amount
class TestCalculateOrderAmountCharacterization:
"""
特性测试:记录 calculate_order_amount 的当前实际行为
这些测试用例来源于对遗留代码的探索性运行,
记录的是"代码实际做什么",而非"代码应该做什么"。
标记 [疑似Bug] 的用例需要后续与业务方确认。
"""
# --- 商品计价行为 ---
def test_normal_product_calculation(self):
"""普通商品:原价计算"""
products = [{"type": "normal", "price": 50, "quantity": 2}]
assert calculate_order_amount(products) == 100
def test_promotion_product_below_100(self):
"""促销商品:单价 < 100 时打 9 折"""
products = [{"type": "promotion", "price": 80, "quantity": 3}]
assert calculate_order_amount(products) == 216.0
def test_promotion_product_at_boundary_100(self):
"""促销商品:单价 = 100 时打 85 折 [边界行为,需确认]"""
products = [{"type": "promotion", "price": 100, "quantity": 2}]
assert calculate_order_amount(products) == 170.0
def test_promotion_product_above_100(self):
"""促销商品:单价 > 100 时打 85 折"""
products = [{"type": "promotion", "price": 200, "quantity": 2}]
assert calculate_order_amount(products) == 340.0
# --- 满减优惠券行为 ---
def test_full_coupon_with_normal_products_only(self):
"""满减券 + 纯普通商品:正常生效"""
products = [{"type": "normal", "price": 250, "quantity": 1}]
assert calculate_order_amount(products, "FULL_200_30") == 220
def test_full_coupon_with_mixed_products(self):
"""满减券 + 混合商品:不生效 [疑似Bug,待确认]"""
products = [
{"type": "normal", "price": 150, "quantity": 1},
{"type": "promotion", "price": 90, "quantity": 1}
]
# 150 + 81 = 231,满足 >= 200,但实际未减 30
assert calculate_order_amount(products, "FULL_200_30") == 231.0
def test_full_coupon_with_promotion_products_only(self):
"""满减券 + 纯促销商品:不生效 [疑似Bug,待确认]"""
products = [{"type": "promotion", "price": 250, "quantity": 1}]
# 212.5 >= 200,但实际未减 30
assert calculate_order_amount(products, "FULL_200_30") == 212.5
# --- 新人优惠券行为 ---
def test_new_user_coupon_normal_deduction(self):
"""新人券:正常抵扣"""
products = [{"type": "normal", "price": 50, "quantity": 1}]
assert calculate_order_amount(products, "NEW_USER_10") == 40
def test_new_user_coupon_floor_to_zero(self):
"""新人券:抵扣后为负时返回 0"""
products = [{"type": "normal", "price": 5, "quantity": 1}]
assert calculate_order_amount(products, "NEW_USER_10") == 0
注意测试用例中的注释:我们用
[疑似Bug,待确认]标记了那些不符合直觉的行为。这些注释是探索过程的重要产出,它们提醒后续开发者:这些行为需要与业务方确认后才能决定是"保留"还是"修复"。
3.3 步骤 3:重构代码,用特性测试保障行为一致
基于特性测试记录的行为,我们开始重构遗留函数——优化逻辑结构、增加注释,让代码更易维护,但必须保证重构后的函数通过所有特性测试。
重构后的代码:
def calculate_order_amount(products, coupon_code=None):
"""
计算订单总金额
商品计价规则:
- 普通商品:原价 × 数量
- 促销商品:单价 < 100 元打 9 折,单价 ≥ 100 元打 85 折
优惠券规则:
- FULL_XXX_YY:满 XXX 减 YY(注意:从特性测试发现,混合订单时不生效,原因待查)
- NEW_USER_10:新用户减 10 元,抵扣后金额 ≤ 0 则取 0
"""
total = _calculate_products_total(products)
total = _apply_coupon(total, coupon_code, products)
return total
def _calculate_products_total(products):
"""计算商品总价"""
total = 0
for p in products:
base_price = p["price"] * p["quantity"]
if p["type"] == "normal":
total += base_price
elif p["type"] == "promotion":
discount_rate = 0.9 if p["price"] < 100 else 0.85
total += base_price * discount_rate
return total
def _apply_coupon(total, coupon_code, products):
"""应用优惠券"""
if not coupon_code:
return total
if coupon_code.startswith("FULL"):
# 注意:保留原有行为——仅纯普通商品订单可用满减(从特性测试发现)
has_promotion = any(p["type"] == "promotion" for p in products)
if not has_promotion:
parts = coupon_code.split("_")
threshold, discount = int(parts[1]), int(parts[2])
if total >= threshold:
total -= discount
elif coupon_code == "NEW_USER_10":
total = max(total - 10, 0)
return total
重构完成后,运行特性测试:
pytest test_order_characterization.py -v
如果所有测试用例都通过,说明重构后的代码保留了原有行为;如果有测试用例失败,说明重构引入了问题,需要针对性修正。
3.4 步骤 4:修复缺陷并更新测试(可选)
经过与业务方确认,"混合订单满减不生效"确实是一个 Bug,需要修复。此时的流程是:
- 先更新特性测试,将相关用例的预期结果修改为正确行为
- 再修改代码实现,移除满减的限制逻辑
- 运行测试,确保修复后的代码通过更新后的测试
def test_full_coupon_with_mixed_products(self):
"""满减券 + 混合商品:正常生效(已修复)"""
products = [
{"type": "normal", "price": 150, "quantity": 1},
{"type": "promotion", "price": 90, "quantity": 1}
]
# 修复后:150 + 81 = 231,满200减30,预期201
assert calculate_order_amount(products, "FULL_200_30") == 201.0
这体现了特性测试的迭代性:既保护现有行为,也支持合理的行为优化。
四、工程实践:规模化应用特性测试的关键问题
在实际大型项目中,应用特性测试会面临两个核心问题:如何确保输入覆盖完整?如何在数百个函数中高效操作?
4.1 如何确保典型输入都被覆盖?
特性测试的价值依赖于输入场景的完整性。若遗漏关键输入,可能导致重构时破坏未覆盖的功能。以下三种方法可保障覆盖率:
方法一:借助代码覆盖率工具
使用覆盖率工具(如 Python 的 coverage、Java 的 JaCoCo)扫描测试执行情况,识别未被覆盖的代码分支。
# 运行测试并生成覆盖率报告
coverage run -m pytest test_order_characterization.py
coverage html
若工具提示"促销商品价格等于 100 元"的分支未被覆盖,则需补充该边界场景的测试用例。
方法二:从业务视角系统梳理输入类型
将输入场景分为三类系统设计:
| 输入类型 | 说明 | 案例中的示例 |
|---|---|---|
| 正常输入 | 业务主流程的典型场景 | 普通商品、促销商品、常规优惠券 |
| 边界输入 | 临界值、极值场景 | 商品数量为 0、价格为 0、金额刚好等于满减阈值 |
| 异常输入 | 非法或意外的输入 | 商品类型字段错误、优惠券格式非法 |
方法三:从生产数据中提炼高频场景
对于已上线系统,可从业务日志中提取真实的输入参数分布,确保高频场景被重点覆盖。例如,从订单日志中统计发现 80% 的订单使用"FULL_200_30"优惠券,则该场景必须纳入测试。
4.2 特性测试的粒度选择:集成级还是函数级?
大型项目包含大量函数,若对每个函数都编写函数级特性测试,工作量会激增。实践中建议采用**“先集成后单元”**的分层策略:
第一层:集成级特性测试(优先)
聚焦核心业务流程,通过端到端的输入输出覆盖多个函数的协同行为。例如,测试"下单 → 计算金额 → 应用优惠"的完整链路,而非单独测试每个函数。
- 优势:用较少的测试用例覆盖核心功能,快速建立安全网
- 适用:业务链路清晰、函数间耦合紧密的模块
第二层:函数级特性测试(按需补充)
对于逻辑复杂、独立性强的关键函数,补充函数级测试以细化行为记录。本案例中的 calculate_order_amount 就属于此类——它是独立的计算单元,逻辑复杂,值得单独测试。
- 优势:精确定位问题,便于理解复杂逻辑
- 适用:核心算法函数、复杂业务规则函数
4.3 提升操作效率的实用技巧
| 技巧 | 说明 |
|---|---|
| 复用测试模板 | 针对同类型函数(如计算类、校验类)提炼通用测试模板,统一输入场景设计和断言格式 |
| 增量式推进 | 不追求一次性覆盖所有函数,按重构优先级逐模块推进,先测试即将重构的模块 |
| 自动化辅助生成 | 对于简单 CRUD 函数,可结合 API 文档自动生成基础测试用例,再人工补充边界场景 |
| 结对编写 | 由熟悉业务的同事提供输入场景,由熟悉测试的同事编写用例,提升效率和准确性 |
五、总结
5.1 特性测试的核心价值
特性测试的本质,是为无文档、无测试的遗留代码建立**“行为契约”**。它解决了"不知道代码实际做什么,不敢修改"的核心痛点:
| 价值维度 | 说明 |
|---|---|
| 降低风险 | 确保修改不会破坏现有功能,避免线上回归缺陷 |
| 辅助理解 | 测试用例本身就是"可执行的文档",帮助开发者反向理解代码逻辑 |
| 支持迭代 | 既保护现有行为,也允许通过更新测试用例来固化优化后的新行为 |
5.2 适用边界
特性测试并非"万能测试",其核心适用场景是:
- ✅ 遗留系统重构
- ✅ 无文档代码维护
- ✅ 接手他人项目时建立安全网
对于新功能开发,更适合使用传统的单元测试或 TDD 模式——因为新功能有明确的需求文档,无需通过"记录实际行为"来建立测试。
5.3 最终建议
如果你经常需要维护遗留代码,建议养成 “先写特性测试,再修改代码” 的习惯:
- 特性测试不需要一开始就覆盖所有场景,先覆盖核心路径
- 关键是锁定"不能动"的核心行为
- 把它当作给代码买的一份"行为保险",让每一次修改都更安心

867

被折叠的 条评论
为什么被折叠?



