你写的 average 函数,[2, 4, 6] 应该得 4,屏幕上却是 3.0。最朴素的做法是盯着代码看,直到看出问题;这在十行代码里有用,在一万行代码里没用。调试是一门有方法的手艺:先读机器已经告诉你的信息,再一步步缩小范围。修好之后还有第二个问题:怎么保证它下个月不会以另一种方式再坏一次。这就是测试、代码质量和 CI 要做的事。
前置:函数与环境模型(调用栈与 frame)、Shell 与命令行环境(退出码、环境变量)。Python 输出来自 3.12(pytest 9.0 在 3.13 下运行),文件路径用
...省略。
先读机器已经告诉你的
程序崩溃时打印的 traceback 是最便宜的线索,但很多人看到一大屏红字就直接跳过。下面的程序给每个学生算平均分:
def average(xs):
total = sum(xs)
return total / len(xs)
def summarize(scores):
return {name: average(vals) for name, vals in scores.items()}
data = {"alice": [90, 85], "bob": []}
print(summarize(data))Traceback (most recent call last):
File ".../report.py", line 9, in <module>
print(summarize(data))
^^^^^^^^^^^^^^^
File ".../report.py", line 6, in summarize
return {name: average(vals) for name, vals in scores.items()}
^^^^^^^^^^^^^
File ".../report.py", line 3, in average
return total / len(xs)
~~~~~~^~~~~~~~~
ZeroDivisionError: division by zero读法有固定顺序。先看最后一行:错误类型和信息,这里是除以零。再看最下面一块:错误真正发生的位置,average 的第 3 行。需要时再往上追:是谁用什么参数调用了它。往上一层是 summarize,再往上是顶层 <module>,于是知道是 bob 的空列表一路传了进来。
最深的调用在最下面(most recent call last),因为异常没被 try/except 接住时,会结束当前 frame、交给调用者,一层层向外传播,每穿过一层记一条。
traceback 告诉你在哪里坏了,哪里该改(average 处理空列表,还是 summarize 不该收到它)是设计问题。
有时会看到两段 traceback 连在一起:
Traceback (most recent call last):
File ".../cfg.py", line 3, in load
return cfg["rate"]
~~~^^^^^^^^
KeyError: 'rate'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File ".../cfg.py", line 6, in <module>
load({})
File ".../cfg.py", line 5, in load
raise ValueError("config missing 'rate'")
ValueError: config missing 'rate'在 except 块里又抛出了新异常,Python 把原始异常挂在新异常上一起打印,原始的在上面。根因通常在第一段。有意翻译底层错误时写 raise ... from e,提示语会变成 direct cause,表明这是故意的。
上面 ZeroDivisionError 的 traceback 中,第一块 line 9, in <module> 告诉了我们什么?
traceback 从最外层调用者打印到最深处,最后一块才是出错的位置(average 第 3 行)。第一块只说明调用链从文件顶层开始。
print(summarize(data)) 本身是合法调用;行号是代码位置,不是执行行数。
缩小范围:print、日志与调试器
没有崩溃、只是结果不对时,就没有 traceback 可读了。方法是二分:在中间某处检查状态,判断 bug 在它之前还是之后,再对那一半重复。最直接的工具是 print,问题是修完后它们要么被删掉,要么被遗忘在代码里。
日志(logging)是有纪律的 print:每条消息有级别(DEBUG < INFO < WARNING < ERROR),运行时按级别过滤,不用改代码就能打开或关掉。
import logging, os
logging.basicConfig(level=os.environ.get("LOGLEVEL", "WARNING"),
format="%(levelname)s %(name)s: %(message)s")
log = logging.getLogger("loader")
def load(path):
log.debug("opening %s", path)
log.info("loaded 3 rows from %s", path)
log.warning("row 2 has empty field")
load("data.csv")$ python lg.py
WARNING loader: row 2 has empty field
$ LOGLEVEL=DEBUG python lg.py
DEBUG loader: opening data.csv
INFO loader: loaded 3 rows from data.csv
WARNING loader: row 2 has empty field同一份代码平时安静,出问题时设一个环境变量就能看到细节。日志会被保存和转发,不要往里写密码和个人数据。
不知道该打印什么、或者程序要跑很久才到出问题的地方时,用调试器:在某行停下,查看任意变量,单步执行,看调用栈。回到开头那个返回 3.0 的 average:
def average(xs):
total = 0
for x in xs:
total += x
return total / len(xs) - 1
print(average([2, 4, 6]))$ python -m pdb avg.py
(Pdb) b 5
Breakpoint 1 at .../avg.py:5
(Pdb) c
> .../avg.py(5)average()
-> return total / len(xs) - 1
(Pdb) p total, len(xs)
(12, 3)
(Pdb) p total / len(xs)
4.0在 return 前停下,total 和 len(xs) 都对,total / len(xs) 也是 4.0,那么多出来的 - 1 就是 bug。常用命令:b 断点、c 继续、n 下一行、s 进入函数、p 打印、w 调用栈。代码里写一行 breakpoint() 也能在那里进入 pdb。
bug 是某次改动引入的、但不知道是哪次时,git bisect 在提交历史上二分(见 Git 的数据模型)。程序不是错而是慢时,不要猜热点,用 profiler 测出来,见 Python 并发与性能。
让错误早点出现:assert 与异常
调试最难的情况是:错误发生的地方离它表现出来的地方很远。一个负数在第 3 层函数里悄悄混进来,第 20 层才因为结果离谱被发现。对策是 fail fast:在假设被破坏的那一刻就停下。
def scale(xs, k):
assert k > 0, f"k must be positive, got {k}"
return [x * k for x in xs]
print(scale([1, 2], -1))$ python as.py
AssertionError: k must be positive, got -1
$ python -O as.py
[-1, -2]assert 在条件为假时立刻抛错,并带着你写的说明,traceback 直接指向假设被打破的那一行。但用 -O 运行时,Python 编译阶段就把所有 assert 删掉了,错误的结果被安静地算了出来。这决定了 assert 的用途:它表达程序员的内部假设(“走到这里 k 不可能是负数,如果是,说明我代码有 bug”),而不是程序正常运行所依赖的检查。
区分两类情况:
- 外部造成的、可以预期的异常情况:用户输错路径、网络超时、配置缺字段。用
if检查并raise一个说明清楚的异常(FileNotFoundError、ValueError)。这类检查不能被关掉。 - 不该发生的内部状态:循环不变量、函数的前置/后置条件、“这个列表此时一定非空”。用 assert。
两个反模式:用 assert 校验用户输入(-O 一开就失效);写空的 except: pass 吞掉异常,把错误推到更远、更难查的地方。
一个命令行工具读用户给的文件路径,下面哪种写法最合适?
用户输错路径属于可以预期的外部情况,应该用异常或错误提示处理,而且这个检查不能被关掉。
assert 表达内部假设,python -O 会删除它,检查就消失了;放任后面报错会让信息离原因更远;空 except 把问题藏了起来,后续代码会在缺数据的状态下继续运行。
测试:把”我试过了”写成代码
修好 bug 后你会手动跑几个例子确认。测试就是把这些例子写下来,让机器每次都替你重跑。一个测试用例只有三步:给输入,拿到实际结果,和预期比较。
用 pytest 测一个判断月份是否合法的函数。文件叫 test_*.py,函数叫 test_*,里面写普通的 assert:
# month.py
def is_valid_month(m):
return 1 <= m < 12# test_month.py
import pytest
from month import is_valid_month
def test_typical():
assert is_valid_month(6)
@pytest.mark.parametrize("m, ok", [(0, False), (1, True), (2, True),
(11, True), (12, True), (13, False)])
def test_boundaries(m, ok):
assert is_valid_month(m) == ok$ pytest -q
.....F. [100%]
___________________________ test_boundaries[12-True] ___________________________
def test_boundaries(m, ok):
> assert is_valid_month(m) == ok
E assert False == True
E + where False = is_valid_month(12)
FAILED test_month.py::test_boundaries[12-True] - assert False == True
1 failed, 6 passed in 0.04stest_typical 用 6 测,通过了;边界测试抓到了 < 12 这个差一错误。改成 <= 12 后 7 passed。pytest 会展开失败 assert 两边的值。
为什么偏偏挑了 0, 1, 2, 11, 12, 13?穷举测试不可能,好的测试要用尽量少的用例抓到尽量多的 bug,有两个系统的方法:
等价类划分:把程序会以相同方式处理的输入归为一类,每类取一个代表就够了。月份的输入分三类:≤0、1..12、≥13。随手写 20 个合法月份,覆盖的都是同一个类。
边界值分析:bug 扎堆出现在类与类的交界处,< 和 <= 只差一个字符。所以对每个边界测边界本身、刚低于、刚高于:下边界 0, 1, 2,上边界 11, 12, 13。非数值的边界同样存在:空列表、一个元素、恰好满容量。
另外两条习惯。回归测试:每修一个 bug,先写一个能复现它的测试,看着它失败,再修,看着它通过;之后这个 bug 不可能悄悄回来。覆盖率(coverage run -m pytest)告诉你测试执行到了哪些行,能提示”这个分支从来没被测过”;但它只说明代码跑到了,不说明结果被检查了,一行被执行却没有任何断言也算覆盖。单独的 test_typical 就让有 bug 的函数达到 100% 行覆盖。
关于测试设计,下面哪些说法正确?
差一错误发生在边界上,3, 5, 7, 9 全在同一个等价类内部,只相当于测了一次。回归测试把"修过的 bug"变成永久的检查。
上文的 test_typical 就让有 bug 的函数达到了 100% 行覆盖,覆盖率只说明执行过;同一等价类内的重复用例增加成本,不增加发现 bug 的能力。
可测试性是设计出来的
上面的函数是纯函数,给输入拿输出,最好测。真实代码往往依赖文件、数据库、网络。想测”待办列表能正确过滤已完成项”,难道每次测试都要准备一个真实文件?
关键在于依赖从哪里来。如果 TodoList 在构造函数里自己写死 self.storage = FileStorage("todo.txt"),测试就只能去碰真实的磁盘。把依赖改成从外面传进来:
class FileStorage:
def __init__(self, path):
self.path = path
def load(self):
with open(self.path) as f:
return [line.strip() for line in f if line.strip()]
class TodoList:
def __init__(self, storage):
self.storage = storage # 从外面传进来,测试时才能换成替身
def pending(self):
return [t for t in self.storage.load() if not t.startswith("[x]")]# test_todo.py
from todo import TodoList
class FakeStorage:
def load(self):
return ["[x] buy milk", "read book", "write tests"]
def test_pending_skips_done():
assert TodoList(FakeStorage()).pending() == ["read book", "write tests"]$ pytest -q
1 passed in 0.01sTodoList 只要求 storage 有一个 load() 方法,测试就能传一个替身进去。测试快且确定,生产代码里传真正的 FileStorage。这类替身统称 test double:stub/fake 返回预设数据(上面的 FakeStorage),mock 还记录”被怎样调用了”供事后检查(unittest.mock)。
教训是:难测的代码通常是设计有问题的代码。依赖藏在内部、读全局状态、一个函数既算又读文件又打印,既难测也难改;依赖注入、把纯计算和 I/O 分开,恰好就是好设计。
Report 类在 __init__ 里写着 self.db = PostgresClient("prod-host")。想给它写不连数据库的单元测试,最根本的改法是?
依赖从外部注入,测试才能替换它,这是让代码可测试的根本手段,同时也降低了 Report 和具体数据库的耦合。
启动真实数据库是在做集成测试,单元测试会变慢且依赖环境;测试专用分支污染产品代码,测试走的也不是真实路径;放弃测试不解决问题。
代码是写给人读的
测试保证代码现在是对的,代码质量决定它下次被修改时还能不能保持正确。代码被读的次数远多于被写的次数,六个月后的你也是一个没有上下文的读者。
最机械的一层交给工具。formatter(Python 用 ruff format 或 Black)统一排版;linter(ruff check)不运行代码,静态分析出可疑的写法。两者解决不同的问题:
$ ruff check messy.py --output-format concise
messy.py:1:1: E401 [*] Multiple imports on one line
messy.py:1:8: F401 [*] `os` imported but unused
messy.py:1:12: F401 [*] `sys` imported but unused
Found 3 errors.
[*] 3 fixable with the `--fix` option.
$ ruff format m2.py && cat m2.py
1 file reformatted
import os, sys
def check(a, b):
if a:
if b:
return "both"
return Noneformatter 改了空格、引号和空行,import os, sys 却原样保留:它保证不改语义。未使用的 import 是 linter 发现的。
工具管不到的是结构和命名,有几条经验在任何语言里都成立:
- 让主路径靠左。先用守卫语句(guard clause)处理特殊情况并提前返回,正常逻辑就不必埋在三层
if里。上面的check可以写成if not (a and b): return None,再return "both"。 - 名字说明用途。
remove_blank_lines()比process()好;魔法数字换成命名常量。 - 注释写为什么。
i += 1 # i 加 1毫无价值;# API 偶尔返回空页,重试一次而不是报错解释了代码无法自述的原因。 在不改变外部行为的前提下小步改善结构,叫重构。有测试兜底,重构才敢做:每改一小步跑一次测试,红了就知道是哪一步出的问题。
项目已经在用 ruff format 统一格式,还需要 ruff check 吗?
实测中格式化后 import os, sys 原样保留,os、sys 未使用是 ruff check 报出来的。formatter 的契约是不改变程序含义,所以它不会删代码。
两个子命令职责不同;lint、测试互相补充,各自抓不同类型的问题。
让别人的机器也能跑
“在我机器上能跑”是工程里最常见的一句话。程序除了代码,还依赖解释器版本和一堆第三方包,任何一项不同都可能让行为不同。
第一层隔离是虚拟环境,否则 A 要 numpy<2、B 要 numpy>=2 就冲突了。python -m venv .venv 给项目一套独立的 site-packages;source .venv/bin/activate 做的事情只有一件:把 .venv/bin 插到 PATH 最前面(PATH 查找规则见 Shell 与命令行环境),于是 python 和 pip 先找到项目自己的那份。不激活也行,直接写 .venv/bin/python script.py。
第二层是依赖声明与锁定。pyproject.toml 里写的是范围,例如 pandas>=2.0,同一份声明今天和三个月后解析出来的具体版本可能不同。锁文件(uv.lock、poetry.lock)把解析结果钉死:每个包、包括间接依赖的确切版本和 hash。规则是:库在 pyproject.toml 里写宽松范围,免得和使用者的其他依赖冲突;应用提交锁文件,保证同事、CI、生产环境装的完全一样。
同事说"我这能跑",你用同一个 pyproject.toml(写的是 pandas>=2.0)新装环境后却报错。最根本的修复是?
范围在不同时间会解析出不同版本,同事几个月前装到的版本和你今天装到的可能不同。锁文件固定解析结果,是应用的标准做法。
放宽范围只会让结果更不确定;系统 Python 引入更多差异;删掉声明等于放弃可复现。
CI:每次改动都自动检查一遍
测试、lint、格式检查都有了,最后一个问题是:谁来保证每个人每次都跑了它们?靠自觉不可靠。
第一道关在本地:Git hook 是 Git 在特定时机自动执行的脚本,.git/hooks/pre-commit 在每次提交前运行,退出码非 0 就阻止提交。但 hook 装在各人本地,新人可能没装,git commit --no-verify 也能跳过。
第二道关是 CI(持续集成):每次 push、每个 PR,在一台干净的机器上从头装依赖、跑全部检查。最小的 GitHub Actions 配置 .github/workflows/ci.yml:
name: ci
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- run: uv sync --locked # 按锁文件装,CI 和你本地是同一套版本
- run: uv run ruff format --check .
- run: uv run ruff check .
- run: uv run pytest -q每个 run 都靠退出码说话,任何一步非 0,整个构建失败,PR 页面上显示红叉。CI 里的工具用”只检查”模式(--check),发现问题就失败,而不是悄悄替你改文件。干净机器能暴露本地碰巧装过、却没写进依赖声明的包。
CI 的价值来自小批量、频繁地集成:每次合入一小块,红了马上知道是哪次提交;各自开发几周再合并,问题同时爆发,分不清原因。CI 还能定时运行、在多个系统和 Python 版本上做矩阵构建;通过后自动发布,就是 CD(持续部署)。
已经有 pre-commit hook 在本地跑 lint 和测试了,为什么还需要 CI?
hook 是本地的、可绕过的,环境也因人而异;CI 是团队共享的关卡,还能发现没写进依赖声明的包。
hook 能运行任意命令;CI 通常用只检查模式,失败就报错而不是改代码。
记住这三件事
- 读 traceback 先看最后一行、再看最下面一块;结果不对时用日志和调试器二分缩小范围,而不是盯着代码猜。
- assert 表达内部假设(
-O会删掉它),外部可预期的错误用异常;测试用等价类和边界值挑用例,每修一个 bug 先补一个失败的测试。 - 依赖从外部注入让代码可测,锁文件让环境可复现,CI 在干净机器上对每次改动强制跑一遍检查。
延伸阅读
- MIT Missing Semester: Debugging and Profiling / Code Quality
- pytest 文档(parametrize、fixture、monkeypatch)
- Python logging HOWTO 与 pdb 文档
- CS2113T Software Engineering & OOP(测试设计、代码质量、CI 章节)
- Ruff 与 uv 文档