Skip to content

第 20 章 · 生产实践:CI 与测试治理

本章目标:把前 19 章的能力组装成一条可靠的 CI 流水线——快速定位失败的调试武器、flaky 测试的发现与治理、GitHub Actions 矩阵构建、覆盖率门禁与测试代码评审清单。

20.1 调试失败的武器库

生产级仓库动辄上千用例,失败后的第一反应决定了排障效率:

bash
$ 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.tomladdopts = "-ra --tb=short",让团队默认输出就带汇总与短栈,省去每个人记忆参数。

20.2 flaky 测试的发现与治理

flaky(间歇性失败)是 CI 可信度的头号杀手。治理分三步:发现 → 归因 → 消除

第一步:用重试插件止血,但别止步于此。

bash
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 的根因是用例之间共享了可变状态——单跑全绿,合跑就炸。随机顺序插件让这类问题现形:

bash
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 只是麻醉剂,根因不除,重试次数迟早追不上坏运气。

时间与随机两大根因的标准修复写法:

python
# 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
python
# 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 完整工作流

一个可直接落地的多版本矩阵流水线:

yaml
# .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.* 数据文件,合并步骤为:
yaml
      - run: coverage combine && coverage report --fail-under=85 && coverage xml

coverage combine 把分片数据合并成总报告后再执行门禁判断,这是分布式执行的标配收尾。

20.4 把治理规则固化进 conftest

评审清单里可自动化的项目,最好的家就是根 conftest。下面是一个「慢用例巡检 + Hypothesis 分档」的实用片段:

python
# 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}")
python
# 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 测试代码评审清单

评审别人的(或三个月后回看自己的)测试代码时,逐项过一遍:

  1. 断言有效性:有没有"假绿"?每个测试是否真的执行到了关键断言(警惕第 17 章的协程假绿)?
  2. 隔离性:是否有模块级可变状态、共享 fixture 泄漏?能否以任意顺序通过(20.2 的洗牌验证)?
  3. 确定性:时间、随机数、文件系统路径是否注入或 mock?是否用了 tmp_path 而不是写死 /tmp
  4. 速度预算:单个单元测试应在毫秒级;引入 sleep/真实 IO 必须给出理由。
  5. 命名与信息量:失败信息能否不看代码就定位问题?(善用断言消息和有意义的 ids)
  6. 重复度:相似用例是否该收敛成 parametrize/Hypothesis 属性?
  7. 标记卫生: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 的意义是?

🛠️ 动手实践

  1. 在你的项目 CI 中加入"每周一次 --random-order-bucket=global 洗牌任务",故意构造两个共享模块级状态的用例验证它能抓到。
  2. 为 20.3 的工作流补一个 nightly job:仅 main 分支每日跑一次全量 slow 标记 + Hypothesis 高探索档位。
  3. 给团队写一份 10 行以内的《新增测试自查清单》,把本章评审清单裁剪成适合你们仓库的版本。

🎓 恭喜完成 pytest 教程全部 20 章!回到课程导学复盘,或者开始 FastAPI 教程,把你学到的测试技能直接用在 Web 项目上。