在整个代码库强制执行单一变异分数,是让团队厌恶测试的绝佳方式。

用 PIT 或 Stryker 跑一个典型仓库,你会看到同样的模式:认证模块得 40%,字符串工具类冲到 95%,ORM 层则在 60 多分徘徊。本能反应是设一个全局门槛,比如 70%,堵住所有低于它的 PR。两个冲刺之后,就会有人在 CI 里关掉检查,甩锅给”不稳定的变异器”。

真正的问题不是工具。而是假装所有代码在变异体存活时具有相同的爆炸半径。

变异测试到底在测什么

代码覆盖率告诉你哪些行被执行了。变异测试告诉你,如果这些行被改了,你的测试能不能发现。

变异框架会在源代码中注入小缺陷(mutants)。它可能把 > 翻成 <,删掉一个方法调用,或者改变返回值。如果测试套件抓住了这个变化,变异体就被杀死了(killed)。如果测试照样通过,变异体就存活了(survives)。变异分数就是被杀死的变异体百分比。

密码哈希比对中存活的变异体,是一个潜伏到生产环境的安全漏洞。capitalizeFirstLetter 助手里存活的变异体,最坏也就是个稍微怪一点的 UI 标签。

把它们一视同仁,正是团队犯错的地方。

为什么认证代码配得上 90%+ 的门槛

认证和授权代码有两个特性,使其非常适合激进的变异测试。

首先,逻辑通常是离散的、状态机式的。token 过期了吗?角色在允许集合里吗?签名验证通过了吗?每个分支都有明确的安全含义,每个都应该被测试。

其次,存活变异体的代价是灾难性的。角色检查里一个翻转的布尔值就能暴露 admin 端点。token 验证流程里漏掉一个 not 就会接受伪造的 JWT。这些不是理论上的。CVE 数据库里满是认证绕过漏洞,都是由变异测试本可以捕获的逻辑错误导致的。

在 Sentry,我们对 authn/authz/ 模块里的任何代码强制执行 90% 的变异分数。低于这个值就 CI 失败。没有例外,没有”下个冲刺再修”。这些模块足够小,不需要为每行生产代码写 40 行测试就能达到。

下面是实践中的样子。这是一个简化的 JWT 验证流程:

import time
from typing import Optional

def verify_token(token: dict, expected_aud: str, leeway: int = 30) -> bool:
    now = time.time()

    if token.get("aud") != expected_aud:
        return False

    exp = token.get("exp")
    if exp is not None and now > exp + leeway:
        return False

    return True

变异框架可能会在过期检查里把 > 翻成 >=。如果没有一个测试使用恰好在 now + leeway 时刻过期的 token,那个变异体就会存活。这意味着你的测试实际上并没有验证边界。在 90% 的变异覆盖率下,那个测试是存在的。

工具代码用 60% 就够了

你的 StringUtilsDateHelpersMathExtensions 处在光谱的另一端。

这些模块往往是纯函数、被大量复用、容易推理。truncate(str, maxLen) 里一个把 > 改成 >= 的存活变异体,可能只会多截掉一个字符。那是 UI 小毛病,不是安全事件。

风险收益的账变了。这些模块通常有几十个小函数。追求 90% 的变异覆盖率意味着要为 padLeft 里的每一个 off-by-one 变体写测试。测试变得比它们保护的代码还长,维护负担开始超过价值。

我们为工具模块设了 60% 的底线。这能抓住明显的漏洞(缺少 null 检查、错误的返回值),而不强迫团队穷尽测试每一种字符串切分排列。

关键是要诚实面对 60% 意味着什么。它意味着”我们测试了常见场景和明显的失败”。它不意味着”这段代码不重要”。如果一个工具函数被用在安全敏感的路径上,它就从调用方继承了更高的阈值。

中间地带:业务逻辑

你的大部分代码位于这两个极端之间。支付处理、数据校验、工作流编排。这些模块影响正确性和用户信任,但单个存活的变异体通常不会直接把数据库拱手送给攻击者。

我们使用分层体系:

模块类型变异阈值理由
AuthN / AuthZ90%高爆炸半径,离散逻辑
业务逻辑75%正确性关键,中等复杂度
工具函数 / 助手60%低爆炸半径,高复用,简单函数
生成代码 / 样板代码排除不测试你没写的代码

这不是僵化的规则。支付计算模块可能提升到 85%。一个被广泛使用的 JSON 助手如果被认证代码调用,可能升级到 75%。层级是起点,不是牢笼。

如何实现分层变异门槛

Stryker 和 PIT 都支持按模块配置。下面是我们如何把它接入一个 Python 项目,使用带自定义配置的 mutmut

# mutation_config.py
THRESHOLDS = {
    "src/authn/": 90,
    "src/authz/": 90,
    "src/billing/": 85,
    "src/workflows/": 75,
    "src/utils/": 60,
}

EXCLUDE_PATHS = [
    "src/generated/",
    "src/migrations/",
]

在 CI 中,一个小脚本读取这个配置并按模块运行变异测试器:

#!/usr/bin/env bash
# ci/check-mutation.sh
set -e

python -m mutmut run --paths-to-mutate=src/authn/
python -m mutmut results || true
python -m mutmut run --paths-to-mutate=src/utils/
python -m mutmut results || true

python ci/verify_thresholds.py

验证脚本检查每个模块的分数是否达到其阈值。如果 src/authn/ 得了 87%,构建就会失败,并给出明确信息:authn/ scored 87%, threshold is 90%

对于 Stryker(JavaScript/TypeScript),使用带变异器分组的 stryker.conf.js

// stryker.conf.js
module.exports = {
  thresholds: {
    high: 90,
    low: 75,
    break: null, // we handle this per-module
  },
  mutate: [
    "src/auth/**/*.ts",
    "src/billing/**/*.ts",
    "src/utils/**/*.ts",
  ],
  ignorePatterns: ["src/generated/**"],
};

我们把 Stryker 包在一个脚本里,用不同的路径 glob 跑三次,每次运行后强制执行按目录设定的阈值。有点笨拙,但能用。

追逐 100% 的陷阱

有些团队把变异测试当成一场要赢的游戏。他们写测试只是为了杀死变异体,而不是验证行为。

最糟糕的例子是测试某个异常消息是否包含特定子串,只为了杀死那个改了消息文本的变异体。那个测试毫无价值。它不验证异常是否在正确的时机抛出,也不验证抛出的类型是否正确。它只验证字符串。

如果你发现自己纯粹为了推高百分比而写测试,你就把目标搞反了。变异测试是诊断工具,不是排行榜。分数告诉你该看哪里。它不告诉你什么时候算完成。

我们踩过的坑

我们一开始设了全局 80% 的门槛。一个月内,三个团队已经在特性分支里”临时”禁用了它。其中两个临时禁用变成了永久禁用。

问题不是那个数字。而是 80% 对认证代码来说太低(我们漏掉了一个角色检查 bug,让它溜到了预发布环境),对一个 4000 行的工具模块又太高(团队花了两周为 isValidEmail 的各种变体写测试)。

分层之后,采用率稳定了下来。认证团队接受了 90% 的门槛,因为范围是可控的。平台团队接受了工具代码 60%,因为不用发疯就能达到。分层方法把变异测试从惩罚变成了关于风险的对话。

从哪里开始

如果你要在现有代码库引入变异测试,第一周不要设任何门槛。跑一下工具,看看分数,然后问:哪里存活的变异体造成的伤害最大?

从认证开始。在那里设 90%,把它跑绿,证明价值。等团队信任这个信号后,再扩展到业务逻辑。把工具代码保持在更低的门槛,或者在你养成习惯之前完全排除它们。

记住:60% 的诚实测试分数,胜过 95% 的为了刷变异器而写的测试。目标是抓住真正的 bug,不是给你的指标仪表盘增光。

如果你想自己试试,Python 的 mutmut 和 JavaScript 的 Stryker 都支持上面描述的按目录模式。从小处开始。一个认证模块。一周时间。看看什么能存活。