mcpField
T
testing-python-libraries

testing-python-libraries

github.com/wdm0006测试与质量109127 次安装2 天前更新
代码仓库

关于 testing-python-libraries

面向 Python 库作者的测试技能,介绍 pytest 配置和测试模式,并指导识别假通过。内容还包括变异测试、真实场景夹具,以及区分有效分支与无实际影响的代码。

主要功能

  1. 01pytest 与覆盖率配置
  2. 02参数化、夹具和模拟
  3. 03已安装包的导入配置
  4. 04假通过测试的反模式
  5. 05变异测试与分支分析

适用场景

  1. 01配置 Python 库测试
  2. 02确认测试能检测回归
  3. 03调查未被测试捕获的变异
  4. 04避免只验证手写假设

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/wdm0006/python-skills --skill testing-python-libraries

安装后在 Claude Code 中输入/testing-python-libraries确认已加载。

附带文件FIXTURES.mdHYPOTHESIS.md

详细介绍

Python 库测试

快速入门

uv run pytest                       # Run tests
uv run pytest --cov=my_library      # With coverage
uv run pytest -x                    # Stop on first failure
uv run pytest -k "test_encode"      # Run matching tests

Pytest 配置

# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra -q --cov=my_library --cov-fail-under=85"

[tool.coverage.run]
branch = true
source = ["src/my_library"]
测试已安装的软件包,而非源代码树

pytest 默认的 prepend 导入模式会将第一个不含 __init__.py 的目录放到 sys.path 中;如果项目布局中有一个软件包目录,旁边是编译或生成的部分(例如原生扩展的 python-source 布局,或 tests/ 旁边的仓库根目录软件包),那么 import my_library 就会解析到原始源代码树,而非已安装的 wheel。结果可能是多余的 ModuleNotFoundError(编译后的子模块只存在于 wheel 中),更糟的是,测试全绿,但运行的代码并非实际发布的代码。

在配置中修复此问题,让本地运行、CI 和队友的检出目录行为一致,而不是只修补某个工作流:

[tool.pytest.ini_options]
addopts = "-ra -q --import-mode=importlib"

importlib 模式会阻止 pytest 修改 sys.path,因此导入只会通过环境中已安装的发行包解析。在 CI 中搭配非 editable 安装使用,以便测试构建步骤本身。

测试结构

tests/
├── conftest.py           # Shared fixtures
├── test_encoding.py
└── test_decoding.py

基本模式

基本测试:

def test_encode_valid_input():
    result = encode(37.7749, -122.4194)
    assert isinstance(result, str)
    assert len(result) == 12

参数化:

@pytest.mark.parametrize("lat,lon,expected", [
    (37.7749, -122.4194, "9q8yy"),
    (40.7128, -74.0060, "dr5ru"),
])
def test_known_values(lat, lon, expected):
    assert encode(lat, lon, precision=5) == expected

Fixtures:

@pytest.fixture
def sample_data():
    return [(37.7749, -122.4194), (40.7128, -74.0060)]

def test_batch(sample_data):
    results = batch_encode(sample_data)
    assert len(results) == 2

模拟:

def test_api_call(mocker):
    mocker.patch("my_lib.client.fetch", return_value={"data": []})
    result = my_lib.get_data()
    assert result == []

异常测试:

def test_invalid_raises():
    with pytest.raises(ValueError, match="latitude"):
        encode(91.0, 0.0)

如需了解详细模式,请参阅:

测试原则

原则含义
独立测试之间没有共享状态
确定性每次运行结果相同
快速每个单元测试 < 100ms
聚焦测试行为,而非实现

会说谎的测试:避免假绿

如果代码出错时测试套件不会失败,那么通过的测试毫无价值。代价最高的 bug 会在 CI 全绿时发布。检查以下反模式——它们都可能让“所有测试均通过”掩盖行为故障。

会无条件通过的条件断言。 如果设置过程静默失败,受条件保护的断言就不会运行,而测试仍然会通过。

# BAD — if the Sphinx build fails, no index.html, so nothing is asserted.
def test_build_includes_css():
    if index_html.exists():            # build broke? test passes anyway.
        assert "theme.css" in index_html.read_text()

# GOOD — assert the precondition, then the behavior.
def test_build_includes_css():
    assert index_html.exists(), "build produced no index.html"
    assert "theme.css" in index_html.read_text()

过于宽松的断言。 无法失败的 or,或宽松到会接受错误输出的子字符串匹配。

assert result.returncode == 0 or "html_static_path" in result.stderr  # masks real failures
assert "ui" in todo.tags                                              # also matches "build"

存在性断言无法发现重复项。 assert X in output 只能证明至少存在一个 X,无法判断是否有两个,也无法判断使用者会采用哪一个。这正是生成输出中 bug 的典型形式——例如 HTML 标签、配置键、生成的标头——框架的基础模板和你的覆盖内容各生成一个,且二者并不一致。提取每个匹配项并比较整个列表。

# BAD — passes with one correct tag, and equally with two conflicting ones.
assert '<link rel="canonical"' in html

# GOOD — pins both the count and the values.
canonicals = re.findall(r'<link rel="canonical" href="([^"]*)"', html)
assert canonicals == ["https://example.com/guide/"]

两个生成器输出“相同”标签时,格式往往并不完全相同(一个是自闭合标签,另一个不是),因此应以你关心的属性作为匹配模式的锚点,并在标签闭合前停止匹配——否则,你要查找的重复项会逃过正则表达式,测试看起来一切正常。相同规则也适用于任何可计数的内容:断言 len(rows) == 2 并检查行值,而不是只写 assert rows。

模拟被测对象。 如果你修补 _run_command,只断言 argv 标记,那么测试就固定了一个可能根本不存在的命令——即使它生成的子命令或标记在上游被重命名或移除,测试仍然会通过。应在边界处模拟(subprocess/HTTP 调用),然后断言解析后的结果,而不是传入的参数。

忽略待测参数的伪对象。 某个伪客户端的 list_items 一次调用就返回所有预设行,忽略 after 和分页;这样的伪客户端无法测试这些参数所驱动的分页或增量同步逻辑,因此这些逻辑仍未经验证。应让伪对象遵循那些处理方式正是测试重点的参数。 导入了错误对象的冒烟测试。 python -c "import server" 可能会因为解析到一个空的 server/ 包而输出成功,该包会遮蔽真正的 server.py——于是损坏的 wheel 仍能“导入”。应断言能访问到真正的符号(from server import main; main),而不只是确认导入名称能解析。

忘记 mock → 悄悄发起真实网络调用。 测试漏掉 httpx_mock fixture 时,会请求线上 API:速度慢、不稳定、受速率限制,还会悄悄执行无法确定的操作。添加 --disable-socket(pytest-socket),让任何未 mock 的网络调用都明确失败,而不是“通过”。

不起作用的 CI 门禁。 确认门禁确实运行了测试:

  • go test ./... / pytest 没有任何测试文件时,会空跑并显示绿色。
  • 通过 --ignore 排除的文件,或以“因为不稳定”为由标记为 pytest.mark.skip 的测试,往往会稳定地失败——排除测试掩盖的是真正的故障,而不是不稳定性。
  • 标记过滤器(-m "not integration")可能会取消选择唯一有意义的测试。在相信绿色结果之前,请在本地复现 CI 使用的确切标记表达式。

评估器集合为空不应意味着“一切正常”。 审计器、策略引擎和验证流水线通常会根据已启用的规则计算分数。类别筛选可能会意外禁用所有规则;如果评分代码将 results == [] 映射为 score = 100,那么不受支持的请求就会悄悄完美通过。将筛选后规则集为空视为配置错误状态,或者在某条聚合规则负责评估所有类别时,始终启用该规则。

enabled = [rule for rule in rules if rule.supports(requested_categories)]
if not enabled:
    raise NoApplicableRulesError(requested_categories)
results = evaluate(enabled, subject)

回归测试不能只断言最终分数:应分别请求每个受支持的类别(以及有代表性的组合),断言至少运行了一条规则,并使用已知会失败的对象,避免空操作路径看起来一切正常。

result = audit(known_noncompliant_subject, categories=["bias"])
assert result.rules_evaluated > 0
assert result.issues                 # proves filtering did not bypass evaluation
assert result.overall_score < 100

围绕缺陷编写的测试。 用 try/except RuinError 包裹调用并让测试通过,等于把缺陷记录为可接受行为。应断言正确行为,并让测试保持失败,直到缺陷修复(使用 xfail(strict=True) 跟踪此问题,同时避免 CI 标红)。

验证测试能否失败:突变修复

每个回归测试都隐含着一个断言——这个测试本来能捕获该 bug。 验证这个断言的唯一方法,就是重新引入 bug,观察测试是否变红。 趁你还清楚修复的来龙去脉时做一次;这只需一分钟,却能区分回归测试和摆设。

# 1. revert the fix (git stash, or hand-edit the guard back to its broken form)
# 2. run ONLY the new test — it must FAIL, and for the right reason
uv run pytest tests/test_paths.py::test_symlink_escape_rejected -q
# 3. restore the fix — it must pass

分别突变复合保护条件的每一部分。 一个既验证输入、又重新检查解析后结果的修复,看起来似乎多余,直到你分别撤销这两部分。

def _resolve_baseline(name: str) -> Path:
    if ".." in name or not NAME_RE.fullmatch(name):   # mutation A
        raise ValueError(name)
    path = (BASE_DIR / f"{name}.json").resolve()
    if not path.is_relative_to(BASE_DIR.resolve()):   # mutation B
        raise ValueError(name)
    return path

撤销 A 会被 ../../etc/passwd 测试捕获。撤销 B 则只有在获准目录内放置一个指向目录外的符号链接时才会被捕获——大多数测试套件都没有这样的测试。如果撤销其中一部分后,测试套件仍然全绿,那说明存在测试缺口,而不是检查冗余:编写一个能固定此行为的测试。

测试原始 bug 和一种可能的错误修复。 还原旧代码能证明测试套件可以检测出该实现,但不能证明修复能处理相近的故障模式。选择不同的测试样例,区分每种候选实现与预期行为。

对于一个为重复标签分配唯一键的函数,还原原来的“标签即键”逻辑后,两个相同标签就能触发测试。一种看似合理的错误修复会添加出现次数计数器,但如果某个字面标签本身已包含生成的后缀,就会发生冲突:

# Original bug: repeated labels overwrite each other.
assert unique_keys(["Notes", "Notes"]) == ["Notes", "Notes (2)"]

# Wrong fix: a blind counter generates an already-used literal key.
assert unique_keys(["Notes", "Notes (2)", "Notes"]) == [
    "Notes", "Notes (2)", "Notes (3)",
]

第二个测试样例要求检查每个生成的候选键是否与已分配的所有键冲突,而不只是统计基础标签的出现次数。也要保留第一个测试样例:每个测试都说明了一种不同的回归。分别突变原始实现和仅使用计数器的实现,记录每种情况下哪个测试失败,然后恢复正确代码并重新运行两项测试。选择能模拟现实错误的突变;任意破坏代码并不能证明覆盖有效。

了解你所选突变会呈现怎样的“红”。 重新引入的 bug 不一定会表现为清晰的断言失败。

  • 移除重试/迭代上限后,测试套件会卡住,而不是失败。使用外部超时运行突变后的测试套件——timeout 60 uv run pytest -q 且没有任何输出,就是复现结果。让上限变得实际上无法达到(> 10**9)才是诚实的突变;将 > 改为 >= 这种差一错误会明确报错,适合日常低成本重跑。
  • 在异步代码中删掉一个 await 或 try,运行器可能会因未处理的拒绝而崩溃,工作进程也会随之崩溃,而不是报告具体的测试失败。它仍然是红的——在认定测试没有触发前,先阅读输出。
  • 如果突变后的运行在收集测试时就报错,那就根本没有运行任何测试,也就没有得到关于测试的任何信息。
  • 在编译型语言中,突变可能破坏的是构建,而不是测试:用 if false { 弱化保护条件可能导致某个导入变成未使用项;运行时会输出编译错误,且没有任何 FAIL 行,因此 grep -c FAIL 会读出 0,看起来就像突变存活了。保持对依赖项的引用(if false && (cond) {),并确认输出中指出的是测试失败,而不是编译器错误。
  • 确认突变确实生效。在 macOS 上,BSD sed -i 's/a/b/' file 会把下一个参数当作备份后缀,可能失败或什么也不做,因此“突变后”的测试套件实际上运行的是未突变代码,并且通过了。使用 perl -i -pe(两种操作系统上都相同),并在查看任何结果前确认 git diff --stat 显示了改动。

手写的测试样例本身就是对现实的突变。 当同一个人既编写解析器,又编写它的所有测试样例时,两者就会编码相同的假设;即使保护条件在生产环境中根本不会触发,测试套件也会全绿。要求 token 之间只有一个空格的正则表达式,面对手动输入的单空格测试样例永远都能通过,但实际文档会把这些 token 渲染在带缩进的多行中。至少从真实来源中截取一个测试样例,将其提交,并让保护条件的测试使用它。

存活的变异并不总是测试缺口

删除一个分支,运行测试套件,结果全绿——第一反应往往是为它编写测试。 但首先要问,这个分支是否真的会改变行为。等价变异体是指移除后无法观察到影响的代码,没有测试能够(也不应该)将它固定下来。

def get_count(self, user, day):
    inner = self.counts.get(user)
    if inner is None:          # looks like a guard; is it?
        return 0
    return inner.get(day, 0)   # `self.counts.get(user, {}).get(day, 0)` is identical

常见情况:提前返回,但顺着执行也会得到相同的值(读取时对空映射或缺失映射进行索引会返回默认值);更安全的惯用写法(str.endswith)使 len(x) < n or 短路判断变得多余;防御性初始化只在一种构造路径下才有作用。

  • 先分类,再编写测试。 删除这一行,如果测试套件仍然全绿,就尝试构造一个结果会不同的输入。做不到?那它就是无效代码——删除或保留都可以,但不要编写一个看起来像是在防护它的测试。名为“缺失用户返回零”的测试,即使删除分支后仍能通过,也是在声称自己覆盖了实际并未覆盖的行为。
  • 有些分支只在某条路径上才有效。 移除初始化后,延迟初始化的嵌套容器可能会触发 panic——但只会在直接手动构造的对象上发生。由加载器或工厂生成的 fixture 已经规范化,因此永远不会触发该初始化。应按照失败路径的构造方式来构建 fixture。
  • 短路判断可能因某个原因而有效,而重写会掩盖这一点。 原始切片 name[-11:] 需要长度判断来避免索引错误;endswith 不需要长度判断也会返回相同结果。只变异判断条件(而不是整个函数),就能看出你面对的是哪种情况;边界输入(刚好等于后缀,或短一个字符)则是能够证明这一点的 fixture。
  • 在 PR 中说明。 “分支 X 在行为上无效(变异后 N 个测试仍全部通过;不存在能区分结果的输入)”是一个发现,不是失败。只报告那些模拟了合理回归,且会使某个具名测试失败的变异。

双向变异:守卫条件也可能触发过于频繁

添加条件的修复——例如对不完整数据发出警告、添加验证检查,或添加一个用于抑制断言的标志——有两种合理的错误实现,而不止一种:它永不触发(原始 bug),或它总是触发(范围过宽)。你自然会编写的回归测试只覆盖第一种。请双向变异。

# The fix: warn when a sub-fetch failed, so a partial total isn't read as complete.
if failed_sources:
    log.warning("counts UNDERCOUNT: no data from %s", ", ".join(failed_sources))

# Mutation 1 — the original bug. Delete the tracking so the list is always empty.
# Mutation 2 — over-broad. Drop the condition, or write `if not sources:`.

捕获变异 2 的对照测试看起来毫无意义。 断言一次完全成功的运行不会发出任何警告的测试,在修复前的代码上本来就会通过,因此看起来好像什么都没证明,在评审中也容易被删掉。但只有这个测试会在警告变成无条件触发时失败。请保留它,并在 PR 正文中明确说明这两个测试的配对关系,避免评审者删掉一半覆盖:

变异会失败的测试
移除跟踪逻辑(永不发出警告)test_failed_source_warns
移除条件(总是发出警告)test_healthy_run_emits_no_warning

真值判断会让守卫条件的适用范围过宽。 过度触发的变异很少是有意改动——它往往是写成了 if not x:,而实际意图是“此值不存在”。None 表示无法测量/获取失败;[]、0、0.0 和 "" 都是合理的健康结果,真值检查会悄然将它们归为同一类。

# BAD — fires on a resource that genuinely has no referrers, or a real count of 0.
if not referrers:
    warn_incomplete(name)

# GOOD — only the sentinel means "we don't know".
if referrers is None:
    warn_incomplete(name)

发现自己犯了这个错误的一个有效迹象是:原本传入空集合的现有 fixture 开始触发新警告。如果添加守卫条件后,无关测试也变红,应将这些失败视为过宽变异自行暴露,而不是认为 fixture 需要更新。

让每个变异恰好对应一个测试。 修复包含多个部分时,逐一撤销,并记录每次是哪个测试失败。“只删除 bool 短路判断就会使且仅使一个测试失败”是经过验证的陈述;“测试套件覆盖了这一点”则不是。移除后测试套件仍全绿的部分,就是一个缺口。

未修复代码也能通过的边界情况测试

当修复为退化输入(空查询、空白文本、零长度序列)添加显式保护时,自然的测试——“空输入返回 0”——往往在原始错误代码中也能通过,因为其他某一层已经产生了相同的答案:list.count("") 是 0,基于字符的估算器对空白返回 0.0,sum([]) 是 0。这类测试修复前后都能通过,因此看起来像是覆盖了保护逻辑,实际上对这个 bug 毫无防护作用。

# Passes on the buggy matcher AND the fixed one — the old path did `tokens.count("")`.
def test_empty_keyword_has_zero_density():
    assert density("some text", "") == 0

# Only these fail on the original: they exercise the behavior the fix changed.
def test_phrase_requires_contiguous_tokens():
    assert density("artificial methods improve intelligence", "artificial intelligence") == 0
  • 让每个新测试都在修复前的代码上运行(还原修复,或将基础提交提取到临时目录),并记录哪些测试会失败。只有这些才是回归测试;其他测试是对边界行为的说明,应按此命名。
  • 将显式保护逻辑与核心更改分开做变异测试。 仅删除 if not tokens: return 0 这一保护逻辑,是空输入测试唯一能捕捉的变异——而且前提是核心更改本身不再让这里返回 0。
  • 这个 bug 和看似合理的错误修复需要不同的测试夹具。 恢复为只匹配首个词,可用仅包含短语首词的文本捕捉;改写成“所有词都在任意位置出现”则只能用包含两个词、但彼此分开的文本捕捉。一组测试夹具很少能同时覆盖这两种情况。

选择能区分 bug 与修复的测试夹具值

测试最常因其数据无法区分两种实现而在变异后仍然通过。编写断言之前,先推算错误版本会根据测试夹具得出什么结果。如果结果相同,问题出在测试夹具——应更换夹具,而不是断言。

顺序敏感的读取需要首尾值不同的测试夹具。 对已排序数据帧打印最新值的摘要,在降序排序时读取 .iloc[0],升序排序时读取 .iloc[-1];两者只差一个字符,而且只有一个是正确的。单行或数值平坦的测试夹具会让两者结果相同。

# BAD — every row carries the same total, so first and last agree and the
# assertion holds against either reading.
rows = [{"day": "2024-01-01", "cumulative": 5}]

# GOOD — strictly increasing across three days: 2, then 5, then 9. Only the
# correct reading yields 9.
rows = [
    {"day": "2024-01-01", "cumulative": 2},
    {"day": "2024-01-02", "cumulative": 5},
    {"day": "2024-01-03", "cumulative": 9},
]
assert "Final cumulative stars: 9" in captured.messages

对于按组计算的派生列,两种读取方式必须在每一行都产生不同结果。 按组计算的间隔/差值/排名列,与在全局排序表上计算的同一列往往在大多数行上相同;仅交错两个组还不够——结果值必须不同。每组两项,并通过时间设置让组内间隔与全局间隔不同,这是能明确区分两种情况的最小测试夹具:

groupdatesper-group gapsglobal-sort gaps
A2024-01-01, 2024-01-1110.05.0, 5.0, 10.0
B2024-01-06, 2024-01-2115.0(one blank)

每组有两个空白值,间隔分别为 10.0 和 15.0;全局只有一个空白值,而且没有任何数值相同。断言应同时检查确切数值和空白值数量。

复现 bug 中魔法常数的测试夹具毫无证明力。 如果缺陷是多余地固定等待 60 秒,而正常代码路径在缺少标头时也会回退到 60 秒,那么省略标头的测试夹具在两种情况下都会通过。应提供能让正常结果有所区别的值——例如设置一个五秒后重置的标头——这样额外等待才会显现。

记录调用序列,而不只是是否调用。 空操作桩只回答“是否休眠过”,除此之外什么也说明不了。应收集参数并断言完整的调用序列;这才能捕捉中间多出的一次调用。

# BAD — passes whether the code sleeps once or twice.
monkeypatch.setattr("mypkg.client.time.sleep", lambda _s: None)

# GOOD — the sequence is the assertion.
sleeps: list[float] = []
monkeypatch.setattr("mypkg.client.time.sleep", sleeps.append)
...
assert sleeps == [reset_wait, 0.1]   # reset wait, then the post-success pause

修改现有测试已断言的行为

更改任何输出之前,先阅读测试套件对该输出已有的断言。以下三种情况在测试全绿时看起来完全相同:

  1. 只有形状断言 — isinstance(x, float)、>= 0、"minutes" in result。此行为未指定:修复前测试会通过,修复后也会通过,几乎任何输出都能通过。应将其视为没有覆盖,而不是必须保留的覆盖。
  2. 正确值已被固定。 你的“修复”才是回归。停止。
  3. 错误值已被固定。 对错误输出进行自信的精确匹配断言,会让缺陷看起来像是经过测试且符合预期。

测试全绿并不能证明意图,测试也无法告诉你属于情况 2 还是 3——只有文档、文档字符串或问题历史记录能说明。两种情况可能出现在同一个文件中:一个会有意移除列表标记的渲染器,其测试固定了不带标记的输出;而两个函数之后,标题文本意外地与下一个块融合这一缺陷,也有一个同样自信的固定值测试。如果意图从未写下来,那么把它写下来也是修复的一部分。

搜索所有固定旧值的断言——不要只找问题单提到的那个。 问题单会指出显而易见的单一测试用例;相同的错误输出通常也嵌在某个类似 test_mixed_content 的用例中的多行 expected 字符串里,不容易看出它就是你要修改的内容。

# search for a distinctive slice of the OLD output, not for the test's name
rg -l 'HeadingBody text' tests/

优先使用差异断言,而不是魔法常量。 如果数值来自你无法控制的第三方计算——可读性评分器、分词器、格式化工具——精确浮点数会让测试绑定到该库的版本。如果依赖声明未固定("textstat",没有版本限定符),一个补丁版本就可能改变数值,导致代码未变而测试失败。应断言修复所建立的关系:

# BAD — version-brittle, and it only says "some number came out".
assert readability(MARKDOWN)["flesch"] == 42.80

# GOOD — the property the fix creates: markup must not affect the score.
assert readability(MARKDOWN)["flesch"] == readability(PLAIN_EQUIVALENT)["flesch"]
assert strip_markup(MARKDOWN) == PLAIN_EQUIVALENT      # exact, no library constant
assert len(strip_markup(MARKDOWN).split()) == 27

差异形式完全不需要依赖项的常量,而且只要剥离器发生回归,测试仍会立即失败。

如果要固定某个数值,就先测量它——绝不要估算。 派生指标并不直观,而只有足够严格才有意义的容差,也会严格到让一个看似合理的猜测在首次 CI 运行时就失败。先计算,再粘贴结果:

uv run python -c "from mypkg import readability; \
  print(readability(open('tests/data/doc.md').read()))"

容差应根据缺陷本身造成的差距来设定,而不是根据你的信心:如果有缺陷和修复后的路径相差约 29 个点,pytest.approx(42.80, abs=1.0) 既能断言真实数值,也能容忍依赖项升级。

极小的非零结果需要相对容差

固定的绝对容差可能掩盖完全下溢:期望值为 1e-200,错误结果为 0.0,两者差值仍小于 1e-6。即使是 pytest.approx 的默认绝对容差,在这个量级下也会接受零。非零结果必须保留其数量级时,应禁用绝对容差:

@pytest.mark.parametrize("magnitude", [1e-200, 1e200])
def test_power_norm_preserves_single_value(magnitude):
    # For a one-element vector, the norm is |x| at either exponent.
    for exponent in (3, 4):
        result = power_norm([magnitude], exponent)
        assert math.isfinite(result)
        assert result == pytest.approx(magnitude, rel=1e-12, abs=0.0)

这些测试样例可区分未缩放幂和中的两种失败:微小项下溢为零,而大项会溢出,即使最终范数仍可表示。保留一个单独的精确零值用例;当期望值为零时,相对误差没有定义。只有当绝对容差反映了零附近有意设定的误差预算时,才使用它。

要验证此回归测试,请临时恢复未缩放累加,并确认某个具名测试会失败。还要检查将结果替换为零是否会导致微小值断言失败;如果该断言仍通过,说明容差已经抹去了测试声称要保护的行为。

要在测试注释中引用“修改前”的数值,请直接加载旧模块——无需检出旧版本的代码树:

git show main:mypkg/parser.py > /tmp/old_parser.py
spec = importlib.util.spec_from_file_location("old_parser", "/tmp/old_parser.py")
old = importlib.util.module_from_spec(spec)
spec.loader.exec_module(old)
print(readability(old.strip_markup(MARKDOWN)))   # what the buggy path used to yield

断言输出去向:在测试函数体中重定向

检查日志或 CLI 输出是否写入 stderr(而非 stdout)的测试,通常会在 fixture 中创建 StringIO,并替换 sys.stderr,然后在测试中创建处理器。这样会导致令人困惑的失败:pytest 的捕获管理器会在 setup 阶段和 call 阶段之间恢复自己的捕获对象,因此在测试中创建的处理器会绑定 到 pytest 的流,StringIO 仍为空,而“Captured stderr call”部分却显示了你要查找的记录。同一个测试在 -s 下却能通过,因此看起来像是偶发失败。

import contextlib, io, logging

def test_records_go_to_stderr_not_stdout():
    err, out = io.StringIO(), io.StringIO()
    with contextlib.redirect_stderr(err), contextlib.redirect_stdout(out):
        configure_logging({"level": "INFO"})     # builds the handler *inside*
        logging.getLogger("app").info("hello")
    assert "hello" in err.getvalue()
    assert out.getvalue() == ""                  # protocol stream stays clean
  • 在测试函数体中重定向,并在函数体中创建处理器;这样在有无 -s 的情况下,行为都一致。
  • 若要断言记录了什么(而非输出去了哪里),请使用 caplog 和记录器名称。
  • 如果被测代码调用 logging.basicConfig(force=True),请在 fixture 的 teardown 中恢复 root 记录器的处理器和级别,否则之后的每个测试都会继承它们。

环境状态:只在你的机器上通过的测试

读取了自己从未设置过的状态(环境变量、模块级缓存、工作目录、时钟)的测试,测的不只是代码,也在测机器。这类测试在本地通过、在 CI 中失败,或者会根据先运行的是哪个测试而时过时败。固定代码读取的每一项输入。

固定所有影响查找的变量,而不只是你知道的那个。 配置目录解析是典型陷阱:设置 HOME 看起来足够了,但在 Linux 上,XDG 变量的设置与 HOME 相互独立,因此查找会忽略临时目录,而所有测试都会共用同一个真实配置目录。一个测试中的损坏夹具随后会泄漏到下一个测试,运行顺序就决定了哪个测试失败。

# BAD — HOME alone. macOS ignores XDG entirely, so this passes locally forever
# and only ever fails on Linux CI.
monkeypatch.setenv("HOME", str(tmp_path))

# GOOD — pin every input to the resolution.
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))

如果某个平台差异决定了是否会读取某个变量,那么不完整的测试版本就不是“基本正确”,而是在 CI 运行的平台上完全没有经过测试。

导入时配置会导致收集失败,而非测试失败。 在模块作用域构建的设置对象(settings = Settings())会在导入时求值,因此缺少变量会导致收集失败——这发生在任何夹具运行之前,所以没有夹具能修复它。在收集阶段就能看到的位置声明这些变量(pytest 配置或根目录下的 conftest.py),并与 CI 使用的 env: 块保持一致。更好的做法是:在工厂函数中构建配置,让测试可以传入覆盖值调用,这样导入模块就不会产生副作用。

模块级全局变量的生命周期会超过填充它的测试。 像 _analyzers = None 这样的缓存会让一个测试的模拟对象在后续所有测试中继续生效。前后都要重置——末尾的重置可以阻止文件中的最后一个测试将状态泄漏到下一个文件。

@pytest.fixture(autouse=True)
def reset_analyzer_cache():
    app._analyzers = None
    yield
    app._analyzers = None

重置模拟对象也有同样的陷阱:reset_mock() 会清除记录的调用,但会保留 return_value 和 side_effect,因此一个测试中设置的替身仍会在下一个测试中响应。

m.reset_mock()                                       # calls cleared; stub still returns 42
m.reset_mock(return_value=True, side_effect=True)    # actually resets the stub

工作目录是一项输入。 调用子进程的代码会继承进程的 CWD。如果测试自身的 CWD 已经是一个有效项目,那么断言“针对我传入的路径运行”就毫无意义——无论路径有没有被一路传递,测试都会通过。先切换到其他目录,这样回退行为才会真正失败。

def test_runs_against_given_path(tmp_path, monkeypatch, project):
    monkeypatch.chdir(tmp_path)          # empty: a CWD fallback errors here
    assert not run_tool(project_path=str(project)).startswith("Error")

冻结时钟,并保持冻结。 在测试中途恢复真实时钟(为了等待某些操作),会悄无声息地把墙上时钟交还给之后运行的日期逻辑。于是营业时间分支会根据运行器恰好采用的本地时间执行,测试套件白天通过、夜间失败。在冻结时钟的测试中,不要睡眠,而应以确定性的方式驱动待处理工作。

固定时区,并测试那些不是 24 小时的日子。 按本地日历日分组、计算“今天的午夜”或增加 24 * 3600 秒的代码,在大多数日子里都正确,但在 DST 切换时(23 或 25 小时的一天)就会出错。冻结时钟无济于事:同一时刻会因运行器的时区不同而落在不同的本地日期,因此测试在 UTC CI 机器上和开发者笔记本上会因不同原因通过。选择一个有 DST 的时区和一个切换日期,并断言每天的确切值。

在许多运行时(Node、C 库的 localtime)中,进程时区会在启动时读取一次,因此在测试中更改环境变量已经太迟。应在设置好时区的子进程中运行被测代码:

def test_daily_totals_on_spring_forward_day():
    env = {**os.environ, "TZ": "America/New_York"}   # 2025-03-09 has 23 hours
    out = subprocess.run(
        ["node", "summarize.js", "fixtures/dst_week.json"],
        env=env, capture_output=True, text=True, check=True,
    ).stdout
    assert json.loads(out)["2025-03-09"] == 7        # exact, not "> 0"

在纯 Python 中,优先向函数显式传入 ZoneInfo;如果代码使用环境时区,则在夹具中设置 TZ 并调用 time.tzset()(仅限 POSIX),之后再恢复。还要编写午夜场景:恰好发生在本地时间 00:00 的事件属于新的一天,而未来事件不得计入今天的总数。

证明异步完成顺序

立即完成的 mock 可能会掩盖缺少 await 的问题:无论如何,副作用都会在测试检查响应前完成。调用次数只能证明某个操作已启动,不能证明它已完成。让操作保持挂起,断言成功仍处于等待状态,然后释放操作并检查结果。

为成功所承诺的最后一个操作设置闸门。如果重新安排任务时先清除旧安排,再创建新安排,那么只对 clear 设置闸门并不能证明 create 已被等待。同样,存储写入已完成,并不能证明后续通知已送达。

# Illustrative contract: handle_save() awaits publish() before returning success.
def test_success_waits_for_publish(monkeypatch):
    import asyncio
    from my_library import service

    async def scenario():
        entered = asyncio.Event()
        release = asyncio.Event()

        async def gated_publish(*args, **kwargs):
            entered.set()
            await release.wait()

        monkeypatch.setattr(service, "publish", gated_publish)
        task = asyncio.create_task(service.handle_save())
        try:
            # Bound the wait so a missing call fails instead of hanging.
            await asyncio.wait_for(entered.wait(), timeout=1)
            await asyncio.sleep(0)  # yield to runnable tasks; no wall-clock delay
            assert not task.done(), "success returned before publish completed"
            release.set()
            assert await asyncio.wait_for(task, timeout=1) == {"success": True}
        finally:
            release.set()
            if not task.done():
                task.cancel()
            await asyncio.gather(task, return_exceptions=True)

    asyncio.run(scenario())

保留真实的处理程序,仅替换其外部边界。如果完成意味着状态已持久化,就让受闸门控制的 fake 在释放后再应用写入,而不是在等待前修改其内存存储。在同一边界添加一个异常情况:完成失败时不得返回成功,并且必须按照公开的错误约定处理异常。验证在未等待最后一个操作、而是直接调度该操作时,顺序测试会失败;撤销对较早操作的 await 测试的是另一项断言。

检查清单

Testing:
- [ ] Tests exist for public API
- [ ] Edge cases covered (empty, boundary, error)
- [ ] No external service dependencies (mock them)
- [ ] Each regression test verified red against the reverted fix
- [ ] A surviving mutation was classified (inert code vs. real gap) before any test was written
- [ ] New guards mutated both ways (never fires / always fires), each mutation
      attributed to one test; control test for the healthy case kept
- [ ] Absence checked with `is None`, not truthiness (`[]`/`0`/`""` are real values)
- [ ] Fixture values make the buggy and correct readings produce different results
- [ ] Existing assertions on changed output classified (shape-only / pins correct
      value / pins the bug), and every file embedding the old value updated
- [ ] Values from third-party computations asserted differentially, or measured
      and pinned with a tolerance sized to the bug's gap — never estimated
- [ ] No ambient state read unpinned (env vars, module globals, CWD, clock, timezone)
- [ ] Local-day logic tested on a DST-transition day under a pinned `TZ`
- [ ] Coverage > 85%
- [ ] Tests run in CI

了解更多

此技能基于 Will McGinnis 所著 《高质量 Python 库开发指南》中的代码质量章节。以下文章提供了更深入的介绍:

评论

支持 Markdown