pytest 断言输出截断机制解析:大型 diff 的惰性构建与截断预算
【免费下载链接】pytestThe pytest framework makes it easy to write small tests, yet scales to support complex functional testing项目地址: https://gitcode.com/GitHub_Trending/py/pytest
本文基于 pytest 变更日志 changelog/14523.improvement.rst 中记录的断言比较输出改进展开:大型断言比较 diff 现在按截断预算“惰性构建”,不再把整个巨型 diff 完整格式化后再截断;作为附带影响,截断页脚也不再报告被隐藏行的确切数量。读完本文,你能理解 pytest 断言解释(assertion explanation)从生成到截断的完整数据流、truncation_limit_lines/truncation_limit_chars两个 ini 选项的生效条件与默认值,以及页脚文案变化的底层原因。
变更条目说的是什么
该条变更日志原文仅四行,但包含两个可验证的行为变化:
- 大型断言比较 diff 惰性构建并受截断预算约束(lazily built and capped to the truncation budget)——巨型 diff 不再“先完整格式化、再被截断”,即不再做大量注定被丢弃的格式化工作;
- 截断页脚不再报告被隐藏行的确切数量——因为输出流在到达预算上限处就被切断,格式化器根本不知道后面还有多少行。
这一改进直接作用于assert失败时 pytest 生成的比较说明,是 src/_pytest/assertion/truncate.py 所在模块的核心职责范围。
截断何时生效:三个开关与默认预算
截断决策集中在 truncate.py 的_get_truncation_parameters()中,满足下列任一条件即不截断:
- 断言输出冗长度达到 2 级及以上(即
--verbosity=2,等价于-vv),通过config.get_verbosity(Config.VERBOSITY_ASSERTIONS)读取; - 运行在 CI 环境中(通过 src/_pytest/compat.py 的
running_on_ci()检测); truncation_limit_lines与truncation_limit_chars两个 ini 选项同时被显式设为 0。
两项 ini 选项在 src/_pytest/assertion/init.py 中注册,缺省时回落到 TruncationBudget 的默认值:
| ini 选项 | 默认值 | 取值 0 的含义 |
|---|---|---|
truncation_limit_lines | 8 行 | 不按行数截断 |
truncation_limit_chars | 640(即 8 × 80) | 不按字符数截断 |
TruncationBudget是一个不可变 dataclass,max_lines/max_chars两个维度独立生效,达到任一上限即停止输出;NO_TRUNCATION_BUDGET(两个值均为 0)表示无上限,被用作各比较函数的默认参数。官方文档对这两个选项的说明见 doc/en/reference/reference.rst。
惰性构建:materialize_with_truncation
改进的核心是 materialize_with_truncation():断言比较函数现在以惰性迭代器(逐行 yield)的形式产出解释文本,而不是先拼好完整列表。该函数从迭代器中逐行拉取并累计字符数,一旦越过阈值立即break,迭代器剩余部分直接被丢弃、不再消费:
for line in lines: buffered.append(line) char_count += len(line) if line_cap is not None and len(buffered) >= line_cap: break if budget.max_chars > 0 and char_count > tolerable_max_chars: break else: # 迭代器在限额内耗尽——无需截断 return buffered有两个细节值得注意(对应 truncate.py 的模块常量):
- 截断会追加页脚(最后一行加
...、空行、提示消息),共TRUNCATION_FOOTER_LINES = 2行、TRUNCATION_FOOTER_CHARS = len("...") + len(TRUNCATION_MSG)个字符的额外开销。因此判断是否溢出时,允许正文超出原始预算一个“页脚代价”(tolerable_max_chars = budget.max_chars + TRUNCATION_FOOTER_CHARS;行数上限为max_lines + 2),避免“差一点就装得下”的输出被硬生生切开只为腾出页脚空间; - 行数上限取的是
budget.max_lines + 2 + 1,即比保留上限多拉一行以检测溢出,但绝不多物化。
真正需要截断时,交给 _truncate_explanation():先按行数切,再按字符数补切(_truncate_by_char_count),给保留的最后一行追加...,并在末尾加上:
...Full output truncated, use '-vv' to show注意这里就是变更日志提到的第二个行为变化:TRUNCATION_MSG只提示“完整输出已截断,用-vv查看”,不再附带“还有 N 行被隐藏”这类精确计数——因为惰性拉取在越限处就中止了,后方的行数根本无法统计。
预算如何下传:比较器也不再格式化被丢弃的内容
只靠消费端截断还不够,改进的另一半是让上游按预算限量生产。在 pytest_assertrepr_compare() 中,当判定要截断时,会构造一个“带页脚余量的截断预算”(行数上限加 3、字符上限加页脚字符数)并传给util.assertrepr_compare()。这个truncation_budget参数沿调用链继续下传到各个比较实现:
- 文本 diff:compare_text.py 中,
ndiff的输入先经_cap_ndiff_input()裁剪——先按字符数切片(约束单行超长文本,其行内 diff 复杂度是 O(len²)),再按行数切片(约束多行文本)。源码注释明确说明:设置预算时,截断后呈现的头部可能与“无上限完整 diff”的头部不同; - 序列比较:_compare_sequence.py 接收
max_lines/max_chars,只格式化预算内的元素; - 映射比较:_compare_mapping.py 同样按预算限制多余项(extra items)的格式化数量。
也就是说,从源码结构看,整条链路形成了“比较器按预算限量产出 → 物化器按预算限量拉取”的双层约束:巨型断言失败(比如对比两个几千行的字符串或长列表)时,pytest 只为最终会显示的那部分做 saferepr、diff 高亮等格式化工作,而不是像过去那样完整格式化后再丢弃尾部。这正是变更日志中“so a huge diff is no longer formatted in full just to be truncated”的实现依据。
此外,pytest_runtest_protocol 中的 callbinrepr() 对插件自定义的pytest_assertrepr_comparehook 返回值也统一走materialize_with_truncation(),因此第三方插件产出的大型解释同样受同一套惰性截断保护。
验证与实测行为
仓库的测试用例印证了新的页脚格式,例如 testing/test_assertion.py 中预期输出包含:
E ...Full output truncated, use '-vv' to showFull output truncated这一断言在 testing/test_assertion.py 与 testing/python/approx.py 中多处出现,覆盖了默认预算、自定义 ini 预算等场景。
实际操作层面,验证这一行为的完整步骤:
- 写一个对比两个超长序列(或长字符串)的断言,使其失败;
- 默认情况下(不加
-vv、非 CI),失败输出最多约 8 行 / 640 字符,末尾出现...Full output truncated, use '-vv' to show,且不再出现“隐藏 N 行”的计数; - 在
pytest.ini/pyproject.toml的[tool.pytest.ini_options]中设置truncation_limit_lines/truncation_limit_chars调整预算,两者均设为0则完全关闭截断; - 加
-vv或在 CI 环境运行,则不截断,查看完整 diff。
小结
这条改进的本质是把截断从“格式化之后的裁剪”前移为“格式化过程中的限量”:比较器按预算产出、物化器按预算拉取,两处都以 truncate.py 中的预算参数与页脚常量为准。对用户的直接影响只有两条——超大 diff 场景下断言解释的生成开销下降,以及截断页脚从“精确隐藏行数”变为通用的-vv提示。相关实现可沿 src/_pytest/assertion/truncate.py(截断与预算)、src/_pytest/assertion/_typing.py(TruncationBudget定义与默认值)、src/_pytest/assertion/compare_text.py(ndiff 输入裁剪)三个文件继续深入。
【免费下载链接】pytestThe pytest framework makes it easy to write small tests, yet scales to support complex functional testing项目地址: https://gitcode.com/GitHub_Trending/py/pytest
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考