你写的 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> 告诉了我们什么?

缩小范围: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 吞掉异常,把错误推到更远、更难查的地方。

停一下,检查理解单选

一个命令行工具读用户给的文件路径,下面哪种写法最合适?

测试:把”我试过了”写成代码

修好 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.04s

test_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% 行覆盖。

停一下,检查理解多选

关于测试设计,下面哪些说法正确?

可测试性是设计出来的

上面的函数是纯函数,给输入拿输出,最好测。真实代码往往依赖文件、数据库、网络。想测”待办列表能正确过滤已完成项”,难道每次测试都要准备一个真实文件?

关键在于依赖从哪里来。如果 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.01s

TodoList 只要求 storage 有一个 load() 方法,测试就能传一个替身进去。测试快且确定,生产代码里传真正的 FileStorage。这类替身统称 test double:stub/fake 返回预设数据(上面的 FakeStorage),mock 还记录”被怎样调用了”供事后检查(unittest.mock)。

教训是:难测的代码通常是设计有问题的代码。依赖藏在内部、读全局状态、一个函数既算又读文件又打印,既难测也难改;依赖注入、把纯计算和 I/O 分开,恰好就是好设计。

停一下,检查理解单选

Report 类在 __init__ 里写着 self.db = PostgresClient("prod-host")。想给它写不连数据库的单元测试,最根本的改法是?

代码是写给人读的

测试保证代码现在是对的,代码质量决定它下次被修改时还能不能保持正确。代码被读的次数远多于被写的次数,六个月后的你也是一个没有上下文的读者。

最机械的一层交给工具。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 None

formatter 改了空格、引号和空行,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 吗?

让别人的机器也能跑

“在我机器上能跑”是工程里最常见的一句话。程序除了代码,还依赖解释器版本和一堆第三方包,任何一项不同都可能让行为不同。

第一层隔离是虚拟环境,否则 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)新装环境后却报错。最根本的修复是?

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?

记住这三件事

  1. 读 traceback 先看最后一行、再看最下面一块;结果不对时用日志和调试器二分缩小范围,而不是盯着代码猜。
  2. assert 表达内部假设(-O 会删掉它),外部可预期的错误用异常;测试用等价类和边界值挑用例,每修一个 bug 先补一个失败的测试。
  3. 依赖从外部注入让代码可测,锁文件让环境可复现,CI 在干净机器上对每次改动强制跑一遍检查。

延伸阅读