测试与追踪
一、你的代码与 AI 的代码失败方式相同
- 这一篇对应官方 Lecture 37(Software Testing)与 Lecture 38(Software Tracing):前半段讲怎么证明「它对」,后半段讲当它不对时怎么找到原因。
AI 生成的代码常常「读起来对、边界常错」。人写的代码同样如此——失败往往不是语法错,而是边界错。
「让所有 doctest 通过」之所以有价值,是因为它把「我认为正确」换成了「一组客观用例判定它正确」。这一篇讨论如何编写这些用例。
二、doctest:把例子当测试
Python 的标准库 doctest 会把 docstring 里的交互式例子真的跑一遍:
1 | import doctest |
输出:
1 | 失败数: 0 用例数: 3 |
三个例子全部通过。docstring 不再只是注释,而是合同。
合同被违反时,它真的会报:
1 | import doctest |
输出:
1 | 失败数: 1 用例数: 1 |
3 * 3 = 9,文档里写的是 10——立刻暴露。这就是为什么要先写例子:例子就是对「正确」的定义。
一个必须知道的细节:doctest 的期望输出要逐字符一致,多一个空格、少一个引号都算失败。这不是刁难,是它必须机械可判。
三、assert:把「预期是这样的」写进代码
assert 是代码内部的契约,不成立就当场抛 AssertionError:
1 | def average(nums): |
输出:
1 | 2.0 |
| 用法 | 位置 | 作用 |
|---|---|---|
| 前置条件 | 函数开头 | 参数必须满足什么 |
| 后置条件 | return 之前 |
结果必须满足什么 |
assert 比注释强在「它会停」。注释里的「这里不该为空」没人管;assert 里的,一旦为空程序立刻停在那一行。
四、边界值:四类,逐项列清
Lecture 17 的四步流程里,第 ① 步就是「列边界」。具体列什么:
| 类型 | 例子 | 为什么必测 |
|---|---|---|
| 零 | 0、[]、空字符串 |
除零、max([]) 的老家 |
| 单元素 | [x] |
循环/递归只跑一次,最容易漏 |
| 空容器 | []、空树、Link.empty |
递归基的唯一入口 |
| 极值 | 负数、极大值 | 符号与溢出 |
1 | def first_or_none(xs): |
输出:
1 | [] → None |
覆盖率不等于测得好。 可以让每行代码都被执行到,却一次都没测过空列表——因为空列表那条路径恰好被前面的 if 挡住了。
五、异常:控制流的一种
try / except / finally 是控制流,不是「容错开关」:
1 | def divide(n, d): |
输出:
1 | finally 一定会跑 |
finally 里的那句话在两条路径上都打印了——无论是否出错,它都执行。
1 | def f(): |
输出:
1 | 2 |
finally 里的 return 会覆盖 try 里的 return。 这一行为反直觉,也很容易被忽略。原因是 return 1 只是「准备返回」,而 finally 必须执行完才能真正返回——它一并把返回值改掉了。
多个 except 的顺序也有讲究:具体的在前,宽泛的在后。
1 | class MyError(Exception): |
输出:
1 | 1 抓到 MyError |
Python 从上往下试 except,第一个匹配的赢。所以具体异常必须写在前面,否则会被宽泛的(比如 except Exception)先截胡。
六、最坏的调试习惯:裸 except
1 | def buggy(): |
输出:
1 | 被吞掉了,程序看起来一切正常 |
1 + "a" 是一个真实的 TypeError——代码里的 bug。但裸 except 把它直接吞掉,程序看似正常运行,错误无声无息。
这类 bug 最难排查,因为它不留痕迹。只会看到最终结果不对,却不知道错在哪里。
| 写法 | 问题 |
|---|---|
except: |
吞掉一切,包括自己的逻辑 bug |
except Exception: |
略好,但仍然掩盖了预期之外的错误 |
except ValueError: |
✓ 只捕获明确预期的那种 |
判据:except 后面写具体异常名。写不具体,说明还没有想清楚这里可能出什么错。
七、读懂 traceback:从下往上看
报错时那一大坨 traceback,很多人的第一反应是从上往下读——错了。
1 | Traceback (most recent call last): |
| 位置 | 是什么 |
|---|---|
| 最上面 | 调用链的起点(<module>) |
| 中间几层 | 一路调下来的过程 |
| 最下面 | 真正抛出的那一行 ← 先看这里 |
| 最后一行 | 异常类型 + 消息 |
most recent call last 这个提示已经明说了:最近的一次调用在最后。所以先读最下面,找到出错的那一行,再往上回溯「是谁调用了它」。
先看最下面那一行。
八、小结
| 概念 | 一句话 | 证据 |
|---|---|---|
| doctest | docstring 里的例子会真跑 | 失败数: 0 用例数: 3 |
| doctest 严格 | 期望输出逐字符一致 | 写 10 立刻失败 |
assert |
契约,不成立就停 | AssertionError: average of empty sequence |
| 边界四类 | 零 / 单元素 / 空容器 / 极值 | [] → None、[7] → 7 |
finally |
无论如何都执行 | 两条路径都打印 |
finally + return |
覆盖 try 的返回值 |
f() → 2 |
except 顺序 |
具体的在前 | MyError 先被抓住 |
裸 except |
吞掉自己的 bug | 1 + "a" 被静默吞掉 |
| traceback | 从下往上看 | 最下面是真正的抛出点 |
三条能带走的:
- docstring 里的例子就是合同。 先写例子再写实现,测试与文档一次完成;而且它机械可判,不留下主观判断的空间。
except后面写具体异常名。 裸except会把自己的 bug 一起吞掉,这类错误最难排查——因为它不留痕迹。- traceback 从下往上看。 最下面那一行才是真正抛出的地方,上面只是「是谁调过来的」。

