[控场AI]
· 10 分钟阅读· 5,415 字

重构前先封印熵:二十次相同运行换来可信基线

重构前先封印熵:二十次相同运行换来可信基线

重构混乱仓库前,先封印时钟、RNG、UUID 和哈希顺序四类熵源,用二十次相同运行证明基线可信。

文章把“获得可信基线”视为混乱仓库重构的第零步。核心论点是:特征化测试比较字节,而挂钟时间、未播种的随机数、uuid4 以及 PYTHONHASHSEED 下的集合迭代顺序这四类熵源会让字节无端变化,使基线沦为噪音,进而诱导开发者反复重新录制基线,而重新录制正是回归上线的通道。作者给出六步流程:用 grep 盘点熵源并评估爆炸半径;用一个 autouse fixture 统一封印四类熵源,并在运行器层面设 PYTHONHASHSEED=0;写键排序、无时间戳的规范化 JSON 快照;运行二十次并以唯一摘要证明稳定;在纯快照提交中录制基线并记录命令与种子;最后以约 40 行的 diff 预算、每个提交一个接缝地做最小安全变更。文末坦承封印会掩盖真实时间依赖、模块级补丁有边界等局限。

你的基准文件(golden files)会腐烂,因为代码不是唯一的输入。时钟、随机种子、UUID 和哈希顺序都会泄漏到输出中。在动手重构一个混乱仓库之前,先封印这四类熵源,再证明测试套件稳定,最后才录制基线。

这篇文章是一个混乱仓库重构的第零步,不讨论重构本身,而是讨论如何获得“信任基线”的资格。

rss source: Twenty Identical Runs, Then One Small Diff: Sealing Entropy Before a Messy Refactor

为什么基线会说谎

特征化测试(characterization test)比较的是字节。如果字节因为与你的编辑无关的原因而变化,测试就是噪音。噪音测试会教你重新录制基线,而重新录制正是回归上线的通道。

大多数混乱仓库都有四个常见的熵源:

  1. 挂钟时间读取(datetime.now、time.time、utcnow)。
  2. 未播种的随机数生成器(random、NumPy 生成器、faker 库)。
  3. uuid4,它读取 os.urandom 且无法播种。
  4. 在随机 PYTHONHASHSEED 下的集合与字典迭代顺序。

在设计任何东西之前先找到它们。一次 grep 就足以开始。

rg -n --glob '!tests/**' \
  'datetime\.now|utcnow|time\.time|random\.|uuid4|secrets\.' \
  src/ | tee entropy-inventory.txt
wc -l entropy-inventory.txt

把这个计数当作你的爆炸半径。十次命中是 fixture 问题;两百次命中是注入问题,你应该为此做预算。

特征化测试(characterization test,由 Michael Feathers 在《修改代码的艺术》中推广)的核心思想是:不预先断言“正确”行为,而是把当前代码的实际输出录制下来作为参照,之后每次运行都与参照比对。它的价值在于为遗留代码建立一道回归护栏——只要字节不变,就说明行为没变。但这也意味着它比较的是字节级输出,任何与业务逻辑无关的变化(时间戳、随机 ID、容器迭代顺序)都会被误判为回归。另一种常见做法是“批准测试”(approval testing),即把输出写入可人工审阅的快照文件,与本篇的规范化 JSON 快照思路一致。

PYTHONHASHSEED 是 CPython 解释器层面的环境变量,控制字符串哈希的随机化。自 Python 3.3 起默认启用哈希随机化,目的是防御哈希碰撞攻击(HashDoS)。副作用是:set 和 dict 的迭代顺序在不同进程间可能不同,因为字符串哈希值变了。这个变量只能在解释器启动前通过环境变量设置,一旦进程运行就无法修改,所以必须在运行器(CI、pytest 启动命令)中设置,而不能在 fixture 里用 monkeypatch。设为 0 即关闭随机化,让迭代顺序在多次运行间保持一致。

第一步:在一个 fixture 中封印四类熵源

下面的测试骨架是示意性的,我没有在你的仓库上运行过它。把它当作模板,然后根据你的调用点调整补丁位置。


`monkeypatch` 是 pytest 内置的 fixture,用于在测试期间临时替换属性、字典项或环境变量,测试结束后自动还原,避免污染其他测试。它的 `setattr` 方法可以替换模块级属性,但有边界:如果被测代码写的是 `from datetime import datetime`,那么模块里已经绑定了原始的 `datetime` 类,替换 `datetime.datetime` 模块属性不会影响那个已绑定的名字。这正是文末“模块级补丁会破坏 `from datetime import datetime` 的库”这条局限性的根源。

`autouse=True` 让 fixture 对所在 conftest 作用域内的每个测试自动生效,无需显式声明。把它放在 `tests/characterization/conftest.py` 中,就把封印范围限定在特征化目录,而不波及整个测试套件——这与第四步“限定在特征化目录”的成本控制策略相呼应。`uuid.UUID(int=...)` 用整数构造 UUID,能生成格式合法但完全确定的 128 位标识符,形状与真实 `uuid4` 一致,从而骗过那些对 UUID 做格式校验的代码。
# tests/characterization/conftest.py
import datetime as dt
import random
import time
import uuid

import pytest

FROZEN = dt.datetime(2026, 1, 1, 12, 0, 0, tzinfo=dt.timezone.utc)


@pytest.fixture(autouse=True)
def seal_entropy(monkeypatch):
    # 1. 时钟:同时冻结 naive 和 aware 调用点。
    class FrozenDateTime(dt.datetime):
        @classmethod
        def now(cls, tz=None):
            return FROZEN if tz else FROZEN.replace(tzinfo=None)

        @classmethod
        def utcnow(cls):
            return FROZEN.replace(tzinfo=None)

    monkeypatch.setattr(dt, "datetime", FrozenDateTime)
    monkeypatch.setattr(time, "time", lambda: FROZEN.timestamp())

    # 2. RNG:每个测试重新播种,而非每个会话。
    random.seed(20260101)

    # 3. uuid4:确定性计数器,形状与真实 UUID 相同。
    counter = iter(range(10_000))
    monkeypatch.setattr(uuid, "uuid4", lambda: uuid.UUID(int=next(counter)))

    yield

有两个细节比代码本身更重要。第一,每个测试重新播种。会话级种子会让测试顺序成为基线的一部分。第二,在运行器中设置 PYTHONHASHSEED=0,而不是在 fixture 中——解释器启动后无法更改它。

第二步:写规范化快照,而不是 stdout

不要对 pytest 输出做哈希,它包含合法变化的耗时和顺序。哈希测试产生的工件。

def emit(tmp_path, name, payload):
    """Write one canonical JSON file per characterization case."""
    import json
    path = tmp_path / f"{name}.json"
    path.write_text(json.dumps(payload, sort_keys=True, indent=2) + "\n")
    return path

键排序、一个尾部换行、文件中不含时间戳。如果快照需要时间,从冻结时钟读取,使其保持恒定。

第三步:用二十次运行证明稳定性

一次通过的运行证明不了什么。运行特征化目录二十次,每次对拼接后的快照做哈希。你必须只看到一个唯一摘要。

for i in $(seq 1 20); do
  rm -rf /tmp/snap && mkdir -p /tmp/snap
  PYTHONHASHSEED=0 pytest -q tests/characterization \
    --snap-dir=/tmp/snap >/dev/null 2>&1
  find /tmp/snap -name '*.json' -print0 \
    | sort -z | xargs -0 cat | sha256sum
done | sort -u | tee digests.txt
wc -l < digests.txt   # 必须打印 1

如果打印多于一个,说明你还没有封印所有熵源。按测试文件逐个运行来二分定位。这个阶段的大多数失败是第三方库内部的未播种 RNG,而不是你的代码。

这个循环的成本是测试套件运行时间的二十倍。把它限定在特征化目录,而非整个测试套件。

sort -z 与 xargs -0 配合处理以 NUL 分隔的文件名,是为了防止路径中含空格或换行时 xargs 错误拆分参数;find -print0 输出的就是 NUL 分隔列表。sha256sum 对整个拼接后的字节流计算摘要,sort -u 去重后 wc -l 统计唯一摘要数量,结果必须为 1。

为什么是二十次而不是两次?这是一个经验阈值:单次运行无法区分“确定性”与“碰巧一致”,而多数熵源(哈希种子、按秒取整的时间、重置的 RNG)在少量运行中未必暴露。二十次能让常见的不稳定性以较高概率显形,同时把成本控制在可接受范围。需要强调,这个循环验证的是同一台机器、同一解释器版本下的稳定性;跨平台或跨依赖版本的差异仍需另外处理。若打印出多个摘要,二分定位的具体做法是:先单独运行每个测试文件,找出摘要不稳定的文件,再在该文件内逐测试排查,通常问题出在第三方库内部的全局 RNG 或缓存。

第四步:现在才录制基线

在二十个相同摘要之后,把快照复制进仓库。用一个只做这一件事的一行 diff 提交它们。那个提交就是你在后续重构中的参照点。

基线提交的规则:

  1. 同一提交中不做源码修改。
  2. 在提交信息中记录确切的命令和种子。
  3. 注明哪些检查被跳过,以及原因。

稳定的基线仍然可能是错的。确定性并不意味着正确性,它只意味着下一个 diff 是可归因的。

第五步:做最小安全变更

现在开始重构。每个提交一个接缝。特征化摘要必须在变更前后字节完全相同。如果不是,要么变更没有保持行为,要么基线泄漏了。

候选变更涉及行数门禁结论
给一个函数添加时钟或 RNG 参数~10摘要相同先做
把一个调用点移到接口后面~30摘要相同第二做
跨模块用 dataclass 替换 dict~150摘要相同拆成三个提交
重命名被 40 个调用点使用的公共函数~90摘要相同 + 导入检查仅当调用者都在仓库内
重排一个函数内部的副作用调用任意摘要相同 + 调用日志 diff需要第二道门禁

使用 diff 预算。如果一个提交超过大约 40 行源码变更,停下来拆分它。大提交会隐藏破坏摘要的那一行。

第六步:免费模型和免费服务器适合放在哪里

候选最小 diff 生成起来便宜,信任起来昂贵。我使用 MonkeyCode 的免费模型访问来为单个函数起草两到三个候选接缝。决定权在确定性循环,而不是模型。免费服务器选项是我运行二十次循环的地方,这样重复成本不会落在我的笔记本上。

披露:本文是作为 MonkeyCode 产品推广的一部分准备的。

这种分工是刻意的。模型提议,摘要门禁处置。任何无法通过二十次相同运行的候选,无论建议读起来多么干净,都不是候选。

局限性与谁应该跳过

  • 封印会隐藏真实的时间依赖。保留窗口和过期逻辑需要单独的、不冻结的集成套件。
  • 模块级补丁 datetime 会破坏那些 from datetime import datetime 的库。在能添加的地方优先使用显式注入。
  • 二十次运行能捕获不稳定性,但不能捕获错误期望。手动审查一次快照。
  • 循环会成倍增加运行时间。在慢速套件上,限制为十次并在提交信息中说明。
  • 如果行为合法地不确定且未测试,跳过此方法。封印你能封印的,隔离其余的。

如果你的测试套件无法承受二十次相同运行,重构就无法通过审查。先修复基线,再切出一个小 diff。

#确定性测试#特征化测试#重构基线#熵源封印#Python测试#PYTHONHASHSEED#uuid4确定性#最小安全变更
分享:

相关推荐