特征测试(Characterization Tests)是一套面向遗留代码的安全网工作方法。它的核心原则是:这些测试不检查代码“应该”做什么,而是如实记录代码“当前”做了什么,以便在有测试保护的情况下修改代码。如果当前行为本身是缺陷,测试仍会带着注释去断言这个缺陷;修复它是另一项审慎、独立的变更。
一、用途 在尚未被测试覆盖的代码被改动之前,先用测试把它的现有行为——包括怪异行为和已知缺陷——固定下来。这样后续任何不预期的行为变化,都会表现为一个失败的测试,而不是悄悄上线。
二、主要能力与步骤
-
划定边界:选择能覆盖即将修改代码、且能从测试中调用的最高接缝(seam),例如:API 端点(最好能覆盖序列化器、视图、权限、查询集)、服务函数或模型方法、Celery 任务体(直接调用函数,而不是 .delay)、管理命令(call_command)。同时写下重要的输入:用户角色、对象状态、开关/配置、日期。通读代码中的分支(if、try、提前返回),每个分支列一个用例,形成用例清单,并在编写测试前先给开发者确认。
-
捕获当前行为:对每个用例,按项目惯用风格用工厂/夹具搭建状态,调用该接缝,并对所有可观察内容做断言,包括:返回值/响应状态与完整响应体;之后的数据库状态(创建的行、被修改的字段、未被修改的行);副作用(邮件 mailoutbox、排队的任务——在边界处 mock .delay / .apply_async、出站 HTTP——mock 客户端或使用 responses/respx、触发的信号、缓存写入);抛出的错误及其消息;若变更与性能相关,还可断言查询次数(django_assert_num_queries)。不要猜测期望值:先运行代码、查看实际输出,再把结果粘贴进断言。对于较大的载荷,可以使用快照:把 JSON 存入 tests/snapshots/ 后比较,但必须先剥离易变字段(ID、时间戳),或冻结时间。
示例代码(原样保留):
@pytest.mark.django_db def test_void_invoice_already_paid__characterization(api_client, paid_invoice): # Characterization: locks current behavior. Paid invoices can be voided and # NO refund is created. Looks like a bug (see ABC-130); don't fix here. resp = api_client.post(f"/api/invoices/{paid_invoice.id}/void/") assert resp.status_code == 200 assert resp.json() == {"id": paid_invoice.id, "status": "void", "refund": None} assert Refund.objects.count() == 0
让这些测试可被找到:使用 __characterization 后缀或 @pytest.mark.characterization 标记(若添加标记,需在 pytest.ini/pyproject.toml 中注册)。
-
验证安全网有效:所有新测试在未改动的当前代码上必须通过;运行 2-3 次以发现偶发不稳定(顺序、时间、随机性),并立即修复;做一次合理性检查:对被测试代码做一个小而刻意的改动,确认至少有一个测试失败,然后还原改动;若可用 pytest-cov,检查目标文件的覆盖情况:pytest --cov=<module> --cov-report=term-missing <tests>,即将修改区域内未覆盖的分支就是需要补齐的缺口。
-
交接:列出已覆盖与未覆盖的内容,以及被锁定的疑似缺陷(附工单)。在任何行为变更之前单独提交这些测试,这样下一个差异只会显示有意的变更。随后再进入安全变更等后续流程。
三、使用方式 作为一项测试编写与遗留代码保护流程执行,依赖项目既有的测试框架(示例使用 pytest 与 Django 测试工具)、工厂/夹具、mock 工具与快照目录。生成的文件需按步骤 2、3 的要求命名、标记并验证。
四、适用场景 在重构、修复缺陷或改造未测试代码之前建立行为基线;对包含复杂分支、副作用(邮件、任务、HTTP、信号、缓存)或性能敏感的代码路径尤其有用。
五、限制与规则 本步骤中绝不修改生产代码,即使是“显而易见”的修复也不行。当后续重构改变某个特征测试时,这必须是经过评审的刻意决定,并在 PR 中明确说明。其他具体依赖版本、平台支持范围等细节,暂未提供。