本节目标:会读覆盖率报告并识破「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 | 快速反馈,拦截低级错误 |
| CI | push / PR | GitHub 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 与依赖管理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。