第 20 章 · 生产实践:CI 与测试治理
本章目标:把前 19 章的能力组装成一条可靠的 CI 流水线——快速定位失败的调试武器、flaky 测试的发现与治理、GitHub Actions 矩阵构建、覆盖率门禁与测试代码评审清单。
20.1 调试失败的武器库
生产级仓库动辄上千用例,失败后的第一反应决定了排障效率:
$ pytest --lf # last-failed:只重跑上次失败的用例
$ pytest --ff # failed-first:先跑失败用例再跑其余
$ pytest -x # 首个失败立即停止
$ pytest --tb=short # 更短的 traceback;long/auto/line/no 可选
$ pytest -ra # 结尾汇总所有非 passed 用例的原因(推荐加进 addopts)
$ pytest --pdb # 失败时落入 pdb 调试器(可配合 set_trace 断点)
$ pytest test_pay.py::test_refund -vv # 单用例 + 最高详细度--lf 与 --pdb 是黄金组合:先用 --lf 收敛到失败集合,再 --pdb 现场检查变量。--lf 的状态存在 .pytest_cache 里,跨进程可用;若缓存干扰判断可 pytest --cache-clear。
固化到配置里
在 pyproject.toml 写 addopts = "-ra --tb=short",让团队默认输出就带汇总与短栈,省去每个人记忆参数。
20.2 flaky 测试的发现与治理
flaky(间歇性失败)是 CI 可信度的头号杀手。治理分三步:发现 → 归因 → 消除。
第一步:用重试插件止血,但别止步于此。
pip install pytest-rerunfailures
$ pytest --reruns 3 --reruns-delay 1 # 失败最多重试 3 次,间隔 1 秒
$ pytest --reruns 5 --only-rerun AssertionError # 只对特定异常类型重试当前版本(16.x)还提供 --reruns-mode strict|append 控制重跑计数口径、--max-suite-reruns 限制整包级重试。也可以给个别用例打 @pytest.mark.flaky(reruns=5) 精准豁免。
第二步:主动暴露顺序依赖。
很多 flaky 的根因是用例之间共享了可变状态——单跑全绿,合跑就炸。随机顺序插件让这类问题现形:
pip install pytest-random-order # 或功能相近的 pytest-randomly
$ pytest -p random_order # 启用洗牌
$ pytest --random-order-bucket=global # 全局洗牌,最强压力
# bucket 可选 package/module/class/global,粒度越小越保守把它固定进 CI 定期任务,一旦某次洗牌变红,日志里的 seed 能精确复现那次顺序。
第三步:按根因分类消除。 时间依赖(datetime.now()→注入时钟)、随机依赖(未设 seed)、外部依赖(网络/文件系统→第 10 章 mock)、资源泄漏(端口/临时目录未清理→tmp_path)四大类覆盖了绝大多数案例。--reruns 只是麻醉剂,根因不除,重试次数迟早追不上坏运气。
时间与随机两大根因的标准修复写法:
# conftest.py —— 时间与随机的确定性注入
import random
import pytest
@pytest.fixture(autouse=True)
def _deterministic_env(request):
"""默认让所有用例的时间/随机都可控;需要真随机的用例可显式关闭。"""
if "real_random" in request.keywords:
yield # 标记了 real_random 的用例保持原样
return
random.seed(42) # 固定全局随机源
yield
# 被测代码不要直接 datetime.now(),而是从注入点取时间:
class Clock:
def now(self):
from datetime import datetime
return datetime.now()
def test_coupon_expired():
clock = FakeClock(at="2025-06-01T00:00:00") # 假时钟由测试控制
assert coupon_expired(coupon, clock=clock) is True# test_flaky_demo.py —— 用洗牌思路自检顺序依赖
# 运行:pytest -p no:cacheprovider --random-order-bucket=module
_shared_cache = {} # 反面教材:模块级可变状态
def test_write_state():
_shared_cache["token"] = "abc"
def test_read_state():
# 单独运行必挂(没有 token),合跑却可能靠顺序侥幸通过
assert _shared_cache["token"] == "abc"把这类用例提交进仓库并接入洗牌 CI,任何破坏隔离性的新改动都会在流水线上现形。
20.3 GitHub Actions 完整工作流
一个可直接落地的多版本矩阵流水线:
# .github/workflows/tests.yml
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false # 一个版本挂了不影响其他版本的报告
matrix:
python-version: ["3.11", "3.12", "3.13"]
include:
- python-version: "3.13" # 仅最新版跑覆盖率,节省时间
coverage: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip # 缓存 pip 下载,加速安装
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Run tests
run: >
pytest -ra --tb=short
${{ matrix.coverage && '--cov=myapp --cov-report=xml --cov-fail-under=85' || '' }}
- name: Upload coverage artifact
if: matrix.coverage
uses: actions/upload-artifact@v4
with:
name: coverage-xml
path: coverage.xml要点解读:
- 矩阵 +
fail-fast: false:三个 Python 版本并行互不拖累,兼容性问题第一时间暴露; - 覆盖率只在单一 job 跑:避免每个版本重复计算浪费机时;
- 门禁内联:
--cov-fail-under=85直接决定 job 成败,低于阈值 PR 无法合并; - 若用了第 14 章的 pytest-xdist 分片,各分片会产出多个
.coverage.*数据文件,合并步骤为:
- run: coverage combine && coverage report --fail-under=85 && coverage xmlcoverage combine 把分片数据合并成总报告后再执行门禁判断,这是分布式执行的标配收尾。
20.4 把治理规则固化进 conftest
评审清单里可自动化的项目,最好的家就是根 conftest。下面是一个「慢用例巡检 + Hypothesis 分档」的实用片段:
# conftest.py —— 慢用例自动登记
SLOW_THRESHOLD = 0.5 # 超过 0.5s 记录警告
_slow_report = []
@pytest.hookimpl(wrapper=True)
def pytest_runtest_makereport(item, call):
rep = yield
if rep.when == "call" and call.duration > SLOW_THRESHOLD:
_slow_report.append((call.duration, item.nodeid))
return rep
def pytest_terminal_summary(terminalreporter):
if not _slow_report:
return
terminalreporter.write_sep("=", "slow tests top 10")
for dur, nodeid in sorted(_slow_report, reverse=True)[:10]:
terminalreporter.write_line(f"{dur:8.2f}s {nodeid}")# conftest.py —— Hypothesis 本地全量 / CI 快速 两档配置(配合第 18 章)
from hypothesis import settings
settings.register_profile("local", max_examples=200)
settings.register_profile("ci", max_examples=25, deadline=None)
settings.load_profile("local") # CI 中通过环境变量 HYPOTHESIS_PROFILE=ci 切换运行时只需切换环境变量:HYPOTHESIS_PROFILE=ci pytest,同一份测试在本地深度探索、CI 快速回归。
20.5 测试代码评审清单
评审别人的(或三个月后回看自己的)测试代码时,逐项过一遍:
- 断言有效性:有没有"假绿"?每个测试是否真的执行到了关键断言(警惕第 17 章的协程假绿)?
- 隔离性:是否有模块级可变状态、共享 fixture 泄漏?能否以任意顺序通过(20.2 的洗牌验证)?
- 确定性:时间、随机数、文件系统路径是否注入或 mock?是否用了
tmp_path而不是写死/tmp? - 速度预算:单个单元测试应在毫秒级;引入 sleep/真实 IO 必须给出理由。
- 命名与信息量:失败信息能否不看代码就定位问题?(善用断言消息和有意义的 ids)
- 重复度:相似用例是否该收敛成 parametrize/Hypothesis 属性?
- 标记卫生:slow/integration 标记是否注册并正确使用?CI 分层(PR 跑快集、夜间跑全集)是否被遵守?
把清单变成自动化
以上 1–4 条都可以工具化:假绿靠 -W error 类警告升级、顺序依赖靠洗牌 CI、确定性靠 time-machine/faker 固定种子、速度靠 --durations=10 巡检。人工评审留给语义层面。
本章小结
--lf/--ff/--pdb/--tb/-ra组成失败调试的标准动作序列;- flaky 治理 = 重试止血(rerunfailures)+ 洗牌暴露顺序依赖 + 四大根因消除;
- CI 矩阵用
fail-fast: false保证完整信号,覆盖率门禁放在单一代价最低的位置; - xdist 分片场景必须
coverage combine后再做门禁; - 评审清单中可自动化的部分交给工具,人只审语义。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. 只想立刻重跑上一次会话中失败的用例,应该使用哪个命令?
2. 关于 pytest-rerunfailures,下列说法错误的是?
3. pytest-xdist 分片执行后做覆盖率门禁的正确流程是?
4. GitHub Actions 测试矩阵中设置 fail-fast: false 的意义是?
🛠️ 动手实践
- 在你的项目 CI 中加入"每周一次
--random-order-bucket=global洗牌任务",故意构造两个共享模块级状态的用例验证它能抓到。 - 为 20.3 的工作流补一个 nightly job:仅 main 分支每日跑一次全量 slow 标记 + Hypothesis 高探索档位。
- 给团队写一份 10 行以内的《新增测试自查清单》,把本章评审清单裁剪成适合你们仓库的版本。
🎓 恭喜完成 pytest 教程全部 20 章!回到课程导学复盘,或者开始 FastAPI 教程,把你学到的测试技能直接用在 Web 项目上。