《Python编程入门》14.3 覆盖率、ruff/mypy 与 CI 门禁

从 pytest-cov 的实跑报告讲起,说明 100% 行覆盖不等于测到边界,用 --cov-branch 对比数字;再实跑 ruff check/format 与 mypy --strict,给出 pyproject 配置段,最后落到 pre-commit 与 GitHub Actions,把质量门禁分成本地、CI、发布前三层。

本节目标:会读覆盖率报告并识破「100% 覆盖」的假象,会用 ruff 与 mypy 做静态检查,并把 pytest、ruff、mypy 串成一道自动门禁。
适用版本:Python 3.12+(实测 3.14.6)

14.3 覆盖率、ruff/mypy 与 CI 门禁

14.2 节我们学会了把测试写工程化。但「写好了测试」和「测试一直有人跑」是两码事。本节把测试、静态检查、覆盖率三样接成门禁:代码不达标,机器就不放行。先看覆盖率怎么测、怎么读、怎么被误读。

14.3.1 pytest-cov 实跑

覆盖率用 pytest-cov(本机 7.1.0,底层是 coverage 7.16.2)。给一个 src/ 布局的小包:

# src/shop/orders.py
def clean_prices(prices: list) -> list:
    """去掉列表里的 None,只保留有效价格。"""
    result = []
    for p in prices:
        if p is not None:
            result.append(p)
    return result

def total(prices: list, member: bool) -> float:
    if member:
        return round(sum(prices) * 0.9, 2)
    return round(sum(prices), 2)

三条测试:clean_prices([1.0, 2.0])、total([100, 50], True)、total([100, 50], False)。跑:

$ pytest --cov=shop --cov-report=term-missing -q
Name                   Stmts   Miss  Cover   Missing
----------------------------------------------------
src/shop/__init__.py       0      0   100%
src/shop/orders.py        10      0   100%
----------------------------------------------------
TOTAL                     10      0   100%
3 passed in 0.14s

--cov=shop 指定被测包,--cov-report=term-missing 在终端打印报表,Missing 列列出没被覆盖的行号。行覆盖率 100%——看起来无懈可击。

14.3.2 覆盖率不是质量指标

那 100% 是不是就说明测试够好了?不是。注意 clean_prices 的职责是「去掉 None」,可我们的测试只喂了非 None 的输入——过滤逻辑本身压根没被验证过。覆盖率统计的是「哪些行被执行」,而不是「哪些行为被断言」。一行代码被执行过,不等于它是对的。

为了看清这一点,打开分支覆盖:

$ pytest --cov=shop --cov-branch --cov-report=term-missing -q
Name                   Stmts   Miss Branch BrPart  Cover   Missing
------------------------------------------------------------------
src/shop/__init__.py       0      0      0      0   100%
src/shop/orders.py        10      0      6      1    94%   5->4
------------------------------------------------------------------
TOTAL                     10      0      6      1    94%
3 passed in 0.12s

行覆盖仍是 100%,分支覆盖只有 94%,Missing 里的 5->4 表示「第 5 行跳回第 4 行」这条弧从未走过——正是 if p is not None 为假时直接回到循环的那条路。换句话说,clean_prices 里「真的遇到 None」的分支,一次都没测过。

14.3.3 line 覆盖与 branch 覆盖的差别

口径统计对象漏报场景
line(默认)每行是否被执行条件只走一条路,另一条路所在行恰好也被别的路径执行过
branch(--cov-branch)每个判断的两条出路无(能暴露「条件永远只成立」这类问题)

line 覆盖是「代码有没有被碰到」,branch 覆盖是「每个 if/while 的两条路有没有都走过」。上例就是典型:所有行都被执行,但一个关键分支从未进入。至少要开 --cov-branch,才能发现这类「假 100%」。

补一条喂 None 的测试后,clean_prices([None, 1.0]) 会让 5->4 那条弧被走到,分支覆盖回到 100%。覆盖率的正确用法是「找没测到的地方」,而不是「把数字刷到 100」——后者会诱使你写一堆只调用了函数、却不做任何断言的垃圾测试。

14.3.4 用 –cov-fail-under 设门槛

CI 里要让覆盖率不达标就失败,加门槛:

$ pytest --cov=shop --cov-branch --cov-fail-under=90 -q
...
Required test coverage of 90% reached. Total coverage: 93.75%
3 passed in 0.14s

把门槛调到 100,即使测试全绿,pytest 也会以失败退出:

TOTAL                     10      0      6      1    94%
FAIL Required test coverage of 100% not reached. Total coverage: 93.75%
3 passed in 0.14s

注意最后一行仍是 3 passed——失败来自覆盖率门槛,不是测试本身。门槛值要务实:核心业务逻辑可以定 90,工具脚本 60 就够,一刀切 100 只会逼人造假。

14.3.5 ruff check:替代 flake8 + isort

ruff(本机 0.16.10)是一个用 Rust 写的极快 linter,把 flake8(代码检查)+ isort(导入排序)+ black(格式化) 三件事合并成一个工具。写一个满是问题的文件:

import os
import sys

def compute( x,y ):
    unused = 42
    result=x+y
    return result

ruff check 一口气报出全部:

I001 [*] Import block is un-sorted or un-formatted
 --> messy.py:1:1
F401 [*] `os` imported but unused
 --> messy.py:1:8
F401 [*] `sys` imported but unused
 --> messy.py:2:8
F841 Local variable `unused` is assigned to but never used
 --> messy.py:5:5
Found 5 errors. [*] 3 fixable with the `--fix` option

I001 是导入排序(isort 的活),F401/F841 是未使用(flake8 的活)。带 [*] 的可以自动修:ruff check --fix 会删掉多余的 import、排好导入顺序。一条命令覆盖过去要装三个工具的事,这就是 ruff 迅速流行的原因。

14.3.6 ruff format:替代 black

格式化交给 ruff format,先看它想改什么:

$ ruff format --diff messy.py
-def compute( x,y ):
+def compute(x, y):
-    result=x+y
+    result = x + y
-def fetch(url,timeout=30):
-    l = [1,2,3]
+def fetch(url, timeout=30):
+    l = [1, 2, 3]

1 file would be reformatted

它和 black 一样几乎不给你商量余地:空格、引号、换行全部标准化,团队从此不用在代码风格上吵架。

14.3.7 ruff 配置段

在 pyproject.toml 里配置行宽与启用的规则集:

[tool.ruff]
line-length = 88
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]

select 里:E/F 是 pycodestyle 与 pyflakes 的经典检查,I 是 isort,UP 是 pyupgrade(把旧写法升级成新语法),B 是 flake8-bugbear(常见陷阱)。配上之后,UP006(List[int] 该写成 list[int])、UP045(Optional[int] 该写成 int | None)、E711(== None 该写成 is None)这些「新语法建议」都会报出来——它们和 9.1、9.2 节讲的类型注解语法是同一件事,只不过由工具自动把关。ruff 与依赖管理、构建工具的完整配置可参考专题 Python 现代工具链 。

14.3.8 mypy:静态类型检查

ruff 管的是风格和明显错误,类型正确性要靠 mypy(本机 2.4.0)。9.1 节介绍过类型注解,这里看它怎么在工程里落地。给一段类型不符的代码:

def add(a: int, b: int) -> int:
    return a + b

def greet(name: str) -> str:
    return "Hello, " + name

result = add(1, "two")
print(greet(42))
$ mypy typed_demo.py
typed_demo.py:10: error: Argument 2 to "add" has incompatible type "str"; expected "int"  [arg-type]
typed_demo.py:11: error: Argument 1 to "greet" has incompatible type "int"; expected "str"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)

每条错误给出行号、错误类型([arg-type])与期望。mypy 不运行代码,纯靠分析就能拦下这类「把字符串当整数传」的 bug——这正是类型注解在团队协作里的回报。

14.3.9 –strict:把「没写注解」也当错误

默认模式只检查写了注解的地方。若函数根本没写注解,mypy 会放行:

import json

def parse(text):
    return json.loads(text)

def find(users, name=None):
    ...
$ mypy loose.py
Success: no issues found in 1 source file

默认模式下这文件「没问题」。加上 --strict:

$ mypy --strict loose.py
loose.py:3: error: Function is missing a type annotation  [no-untyped-def]
loose.py:6: error: Function is missing a type annotation  [no-untyped-def]
Found 2 errors in 1 file (checked 1 source file)

--strict 开启了一整套更严格的开关(disallow-untyped-defs、disallow-any-generics、warn-return-any 等),逼你给每个函数写注解。新项目建议直接上 strict,老项目可以逐步收紧。

14.3.10 mypy 配置段

把严格模式固化进 pyproject.toml,从此裸跑 mypy 即等同 --strict:

[tool.mypy]
python_version = "3.12"
strict = true
warn_unused_ignores = true

配上后,loose.py 那两处 no-untyped-def 不用加任何命令行参数就会报出来。修好注解后 Success: no issues found。把检查配置写进仓库,是让「每个人的本地检查口径一致」的前提。

14.3.11 pre-commit:提交前就跑

pre-commit 是一套钩子管理器:在 git commit 前自动跑 ruff、mypy 等检查,不通过就拒绝提交。配置文件 .pre-commit-config.yaml:

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.16.10
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v2.4.0
    hooks:
      - id: mypy

装一次 pre-commit install 之后,每次提交都会自动检查改动的文件。本节不真跑这个钩子——它需要联网克隆仓库、改动 git 钩子目录,不适合在临时目录里演示;但配置文件本身是标准写法,可直接用。

14.3.12 GitHub Actions:CI 上的门禁

本地钩子可以 --no-verify 绕过,真正的硬门禁在 CI。一个最小工作流 .github/workflows/ci.yml:

name: CI

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.12", "3.13", "3.14"]
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: pip

      - name: Install
        run: |
          python -m pip install --upgrade pip
          pip install -e ".[dev]"

      - name: Lint
        run: ruff check .

      - name: Type check
        run: mypy src

      - name: Test
        run: pytest --cov=shop --cov-branch --cov-fail-under=90

几个要点:matrix.python-version 让同一套测试在 3.12 / 3.13 / 3.14 上各跑一遍,提前发现版本差异;cache: pip 缓存依赖避免每次重装;三步依次是 lint → 类型 → 测试,任何一步非零退出整个 job 就失败,PR 无法合并;pip install -e ".[dev]" 安装项目本身及其 dev 依赖(15.1 节会讲这套声明)。

14.3.13 质量门禁的三个层次

把本节串起来,一道可靠的 Python 项目应当有三层门禁:

层次触发时机工具作用
本地git commit 前pre-commit + ruff/mypy快速反馈,拦截低级错误
CIpush / PRGitHub Actions 跑全套权威判定,不可绕过
发布前打 tag / 发版构建 + 全量测试保证制品可用、可复现

三层的分工是「越早越好,越快越好」:本地钩子秒级反馈、CI 分钟级把关、发布前做最重的检查。日常开发里绝大多数问题在本地就被拦下,CI 只兜底。

小结

  • pytest-cov 用 --cov=包名 --cov-report=term-missing 出报告;行覆盖 100% 不代表测到边界——上例 clean_prices 的 None 分支从没被验证。
  • 开 --cov-branch 能暴露「条件只走一条路」的假 100%(实测行 100% / 分支 94%);--cov-fail-under 把门槛变成 CI 的硬约束。
  • ruff 一体化替代 flake8 + isort + black,ruff check / ruff format 分别管检查与格式化,配置在 [tool.ruff] / [tool.ruff.lint]。
  • mypy 做静态类型检查,--strict 连「没写注解」也报错,配置在 [tool.mypy]。
  • 门禁分三层:本地 pre-commit、CI(GitHub Actions)、发布前检查。

到这里,第 14 章「测试与质量」就完整了:14.1 写测试、14.2 组织测试、14.3 把测试与静态检查接成门禁。下一章我们回到项目本身——如何用 pyproject.toml 声明依赖、构建 wheel 并发布到 PyPI,把今天写的代码真正交付出去。

阅读导航:上一节:fixture、参数化与 mock · 下一节:pyproject.toml 与依赖管理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时