结论先行(3 条):
- 正则漏掉的那一层,用 AST 规则 + 谓词分类可以覆盖 80%+。核心思路不是"扫断言形式",而是判定「断言目标(哪个字段)× 断言谓词(约束多紧)」的区分度——你列的四类问题本质上是"谓词太弱"或"目标选错",AST 能精确区分。
- 变异门禁不需要全量跑。按「断言密度 × 变更频率」分层抽样,1200 个抽 300 个,mutmut 15 min 内跑完,CI 可接受。
- 断言区分度 = Σ(被断言字段约束宽度) / 响应总字段数,0~1 分值直接进治理看板,比"存活率"更直观、更可解释。
一、AST 规则清单(直接进 CI,Python ast 模块)
你列的四类恒真断言,AST 特征各不相同,可以逐条写成 ast.NodeVisitor:
| 编号 | 你的例子 | AST 特征 | 判定规则 |
|------|---------|---------|---------|
| R1 | assert data["orderList"] | Assert.test 是 Name/Subscript/Attribute/Call(非 Compare) | 裸真值断言:非空即过,无值域约束 |
| R2 | assert code in (0, 200, "0") | Assert → Compare(In) → Tuple,元素 ≥ 3 且混合类型 | IN 候选集过宽:候选数 > 业务枚举实际值的 50% |
| R3 | assert set(keys) >= {"id","amount"} | Assert → Compare(GtE),左侧是 set() 或 .keys() | 仅断键存在,零值约束 |
| R4 | assert True | Assert.test 是 Constant(value=True) | 恒真,无歧义 |
核心扫描代码(可直接进 pre-commit 或 pipeline step):
import ast, sys
from pathlib import Path
class TautologyChecker(ast.NodeVisitor):
def __init__(self):
self.issues = []
def visit_Assert(self, node):
test = node.test
# R4: assert True
if isinstance(test, ast.Constant) and test.value is True:
self._report(node, "R4", "恒真断言,对响应内容零约束")
# R1: bare truthiness (assert x / assert data["key"] / assert fn())
elif isinstance(test, (ast.Name, ast.Subscript, ast.Attribute, ast.Call)):
self._report(node, "R1", "裸真值断言:非空即过,无值域约束")
# R2 + R3: Compare 表达式
elif isinstance(test, ast.Compare) and len(test.ops) == 1:
op = test.ops[0]
# R2: IN with wide tuple
if isinstance(op, ast.In):
comp = test.comparators[0]
if isinstance(comp, (ast.Tuple, ast.List)):
n = len(comp.elts)
if n >= 3:
self._report(node, "R2", f"IN 候选集 {n} 值,区分度可能不足")
# R3: set superset (keys only)
if isinstance(op, (ast.GtE, ast.Gt)) and self._is_set_expr(test.left):
self._report(node, "R3", "仅断键存在,无值约束")
self.generic_visit(node)
def _is_set_expr(self, node):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == 'set':
return True
if isinstance(node, ast.Attribute) and node.attr in ('keys',):
return True
return False
def _report(self, node, rule, msg):
self.issues.append({"rule": rule, "line": node.lineno, "msg": msg})
# CI 入口:只扫变更文件(git diff --name-only)
changed = sys.argv[1:] or [str(p) for p in Path("tests").rglob("test_*.py")]
exit_code = 0
for path in changed:
try:
tree = ast.parse(Path(path).read_text())
except SyntaxError:
continue
checker = TautologyChecker()
checker.visit(tree)
for iss in checker.issues:
print(f"⚠️ {path}:{iss['line']} [{iss['rule']}] {iss['msg']}")
exit_code = 1 # 新增文件 fail,存量文件可配白名单降级为 warn
sys.exit(exit_code)
误报控制:
- R1 排除
assert x is not None(有区分度,弱但有效)——AST 里is not None是Compare(Is, Not)不是裸表达式,天然不命中 - R2 维护一个
ENUM_REGISTRY(模块级 dict),CI 对比候选集 vs 注册表,候选集 ⊇ 注册表全量 → flag;候选集 < 50% 注册表 → 不 flag - 存量文件走
# noqa: tautology注释豁免,新增文件硬 fail
二、采样变异门禁方案
全量 1200 × mutmut = 40+ min,CI 跑不起。分层抽样:
分层:
- Layer A(必跑):最近 30 天 git log 有变更的用例 + P0/P1 接口标记用例 → 约 150~200 个
- Layer B(抽样):断言密度 < 2 assert/用例 的用例(AST 统计)→ 按密度升序取前 100
- Layer C(不跑变异):其余,只跑 AST 规则
执行策略: | 触发 | 范围 | 预计耗时 | |------|------|---------| | PR 触发 | 变更文件(通常 < 20 用例) | 2~5 min | | 每日凌晨 | Layer A + B(≤ 300 用例) | 12~15 min | | 每周 | 全量 AST 扫描 + 存活率趋势 | 5 min |
存活率阈值:
- 单用例:mutmut 生成 N 个 mutant,存活 K 个 → 存活率 = K/N
- 新增用例:存活率 > 30% → CI fail(新增必须断言充分)
- 存量用例:存活率 > 40% → CI warn(进治理看板,不 block)
- 治理目标:一个月内整体存活率从 41% 降到 25%
mutmut 配置:
# mutmut.ini
[mutmut]
paths_to_mutate = tests/
backup = False
no_progress_bar = True
runner = python -m pytest --tb=no -q {mutant_file}
三、断言区分度评分(可解释的量化指标)
比"存活率"更直观——存活率是"变异能不能杀",区分度是"断言到底约束了多少":
区分度 = Σ(每个被断言字段的约束宽度) / 响应 schema 总字段数
约束宽度定义:
| 断言形式 | 宽度 | 示例 |
|---------|------|------|
| assert x == 5 | 1.0 | 精确值 |
| assert x in (0, 1) | 0.5 | 2 选 1 |
| assert x > 0 | 0.4 | 半开区间 |
| assert x is not None | 0.2 | 排除 null |
| assert x(bare) | 0.1 | 仅非空 |
| 未断言 | 0 | — |
算例:响应 10 个字段,断言了 3 个(一个 == 200 宽度 1.0,一个 is not None 宽度 0.2,一个 bare 宽度 0.1)→ 区分度 = (1.0+0.2+0.1)/10 = 0.13。治理优先级:区分度 < 0.2 的用例先改。
这个分数可以做成 pytest fixture 或 conftest 插件,跑完用例后自动计算并写入 JUnit XML 的 <properties>,CI 看板直接读。
四、落地节奏
- 本周:AST 4 条规则进 CI(上面代码直接可用),存量 1200 个跑一遍出报告 → 预期命中 120~180 个
- 下周:mutmut 分层抽样跑通,出首次存活率基线(你已有 41% 的全量数据,分层后应该更细)
- 两周内:区分度评分插件上线,治理看板按分数排序
- 持续:每周存量存活率降 5%,一个月 41% → 25%
一个容易忽略的坑:mutmut 变异的是被测代码(tests/ 目录下的业务逻辑),不是断言本身。如果你的用例结构是 test_xxx() 里写 assert,mutmut 会变异 assert 右边的表达式(如 == 变 !=、+ 变 -),这正好能检测"断言是否真的在约束"。但如果你把断言抽到了 helper 函数里(assert_response(data, expected)),mutmut 会变异 helper 而不是调用点——断言抽到 helper 会系统性降低变异覆盖率。所以治理时优先把 helper 里的断言"内联"回用例,或者给 helper 单独跑变异。