Python错误处理、调试与测试:异常、logging、pdb、unittest 与 doctest
写程序时,最理想的情况当然是:代码一次写对,运行就成功。
但真实情况往往不是这样。程序可能会遇到很多问题:
- 代码本身写错了;
- 用户输入了非法数据;
- 文件不存在;
- 网络请求失败;
- 某个值超出了预期范围;
- 程序运行流程和我们想象的不一致。
所以,真正会写 Python,不只是会写“正常路径”的代码,还要学会:
- 出错时如何处理;
- 出错后如何定位问题;
- 修好之后如何验证程序真的没问题。
这就是“错误处理、调试与测试”这一部分要解决的核心问题。
本文会围绕下面几个主题展开:
- 什么是异常,为什么程序会报错;
- 如何使用
try ... except ... finally处理错误; - 如何主动抛出异常;
- 如何用
logging、assert和pdb调试程序; - 如何编写单元测试;
- 如何用
doctest让文档示例也变成可执行测试。
理解这一章之后,你写出来的 Python 程序就不再只是“能跑”,而会开始朝着“更稳、更容易排错、更容易维护”的方向前进。
一、程序为什么会出错?
在 Python 中,程序运行时出现的问题,很多都会以“异常(Exception)”的形式表现出来。
例如:
1 | print(10 / 0) |
这会触发:
1 | ZeroDivisionError |
再比如:
1 | int('abc') |
会触发:
1 | ValueError |
还有:
1 | open('not_exist.txt') |
可能触发文件相关异常。
1.1 常见错误来源
程序里的错误大体可以分成几类:
- 语法错误:代码写法本身不合法,程序连运行都开始不了;
- 逻辑错误:代码能运行,但结果不对;
- 运行时异常:程序执行过程中遇到了无法正常继续的问题。
这篇主要讨论的是第三类,也就是运行时异常。
1.2 Python 遇到异常时会发生什么?
如果异常没有被处理,Python 会:
- 停止当前执行流程;
- 沿着调用链向上抛出异常;
- 最终打印错误栈信息;
- 程序结束。
例如:
1 | def foo(s): |
这里 foo() 会抛出异常,这个异常会继续传到 bar(),再传到外层,最后导致整个程序退出。
这说明一个重要事实:
异常不是“局部问题”,它会沿调用链传播,直到被捕获或者让程序崩掉。
二、try ... except ... finally:捕获并处理异常
2.1 最基本的异常捕获
Python 提供了内置的异常处理机制:
1 | try: |
执行时:
try中先执行可能出错的代码;- 如果发生
ZeroDivisionError,就跳到对应的except; - 异常被处理后,程序可以继续往下执行。
2.2 捕获多种异常
实际开发里,一个逻辑可能抛出不止一种异常。
1 | try: |
这里:
int('abc')会先触发ValueError;- 所以后面的除法不会执行;
- 匹配到的
except会被执行。
2.3 使用 except Exception as e
如果你不确定会遇到哪种异常,也可以先统一捕获:
1 | try: |
这样写很常见,但要注意:
- 它适合在“外层统一兜底”;
- 不适合到处滥用;
- 如果什么都一把抓,可能会掩盖真实问题。
所以通常更推荐:
- 能精确捕获时,优先写具体异常类型;
- 在程序入口层再做统一兜底。
2.4 else:没出错时再执行
异常处理中还有一个不常被重视但很好用的结构:else。
1 | try: |
else 的含义是:
- 只有
try中完全没有异常时,才执行这里。
它适合用来表达“成功路径”,让代码结构更清晰。
2.5 finally:无论是否异常都执行
有些操作无论是否报错,都必须执行,例如:
- 关闭文件;
- 关闭数据库连接;
- 释放锁;
- 清理临时资源。
这时就要用 finally:
1 | try: |
更典型一点的写法是:
1 | f = None |
finally 的关键点是:
不管
try中有没有异常,它都会执行。
2.6 一个完整结构示例
1 | try: |
这几部分职责可以这样记:
try:放可能出错的代码;except:放异常处理逻辑;else:放成功路径逻辑;finally:放必须执行的收尾逻辑。
三、异常会跨多层调用传播
3.1 不需要每一层都捕获异常
很多初学者一看到可能报错,就想在每个函数里都写 try ... except。
其实没必要。
看这个例子:
1 | def foo(s): |
这里即使 foo() 出错,也可以在 main() 统一处理。
这说明:
异常可以跨函数传播,所以你只需要在“合适的层次”捕获它。
3.2 什么时候在底层处理,什么时候往上抛?
可以这样理解:
- 底层函数 更适合专注业务逻辑;
- 上层函数 更适合决定怎么向用户展示错误、是否重试、是否记录日志。
例如:
- 工具函数里发现参数非法,可以直接抛异常;
- 应用入口层决定是提示用户、记日志,还是终止程序。
这是很重要的分层思路。
四、主动抛出异常:raise
4.1 为什么要自己抛异常?
不是所有异常都来自 Python 内部。
很多时候,我们自己的业务规则也会认为某些情况“不合法”,这时就应该主动抛出异常。
例如:
1 | def divide(x, y): |
这里 y == 0 时,虽然你也可以等 Python 自己抛 ZeroDivisionError,但主动检查并抛出更明确的异常,往往更符合业务语义。
4.2 尽量优先使用合适的内置异常
很多情况下,其实不需要自定义异常类,直接使用 Python 内置异常就够了,例如:
ValueErrorTypeErrorKeyErrorIndexErrorFileNotFoundError
例如:
1 | def set_age(age): |
这样别人一看异常类型,就能大致知道问题类别。
4.3 自定义异常类
如果你的业务里某类错误特别明确,也可以自定义异常:
1 | class ScoreError(ValueError): |
通常只有在错误类型需要明确表达业务语义时,才有必要这么做。
4.4 捕获后再抛出:raise
有时你想在捕获异常后做一点处理,但仍然希望异常继续往外抛。
1 | def foo(s): |
这里:
bar()捕获了异常;- 打印了一点提示;
- 然后用裸
raise再次抛出原异常。
这个技巧常用于:
- 增加日志;
- 补充上下文;
- 但又不吞掉异常。
五、调试的第一步:看懂错误信息
很多人调试时最容易忽略的一件事,其实是:
先认真看报错信息和调用栈。
例如报错里通常会包含:
- 异常类型;
- 异常消息;
- 出错文件;
- 出错行号;
- 调用路径。
这些信息往往已经足够定位很多问题。
比如下面这种错误:
1 | ZeroDivisionError: division by zero |
你至少应该第一时间想到:
- 某个除数变成了 0;
- 需要回头检查这个值是怎么传进来的;
- 不是立刻“瞎改代码”。
调试能力很大一部分,其实就是:根据异常信息倒推程序当时的状态。
六、用 print()、logging 和 assert 调试程序
6.1 最直接的方法:print()
最朴素、最直接的调试方式,就是打印变量。
1 | def foo(s): |
这种方式的优点是:
- 简单;
- 立刻可用;
- 对初学者很友好。
但问题也很明显:
- 输出容易满天飞;
- 不方便按级别控制;
- 经常调完还要删掉。
所以它适合作为快速排查的小工具,但不适合长期替代正式日志方案。
6.2 更正规的方式:logging
Python 内置了 logging 模块,用来记录程序运行信息。
1 | import logging |
这里的好处是:
- 可以分级别输出:
debug、info、warning、error等; - 不需要像
print()那样到处手动删; - 可以通过配置决定输出到控制台、文件或其他位置。
6.3 为什么工程里更推荐 logging?
因为 logging 更适合真实项目。
例如:
- 开发环境输出更多细节;
- 生产环境只保留警告和错误;
- 出问题后可以回看日志文件;
- 多模块程序也能统一管理日志。
这比简单 print() 更可维护。
6.4 assert:断言某个条件必须成立
有些时候,我们认为某个条件在逻辑上“绝对应该成立”,如果不成立,就说明程序哪里出了问题。
这时可以使用 assert:
1 | def foo(s): |
如果 n == 0,程序会抛出:
1 | AssertionError |
6.5 如何理解 assert 的定位?
assert 更适合表达:
- 这是开发阶段对程序状态的检查;
- 一旦不满足,说明逻辑出了问题;
- 它不是给普通用户做输入校验的主要方式。
也就是说:
- 用户输入错误,通常应该用
if+raise; - 程序内部不变量检查,才更适合
assert。
七、使用 pdb:进入 Python 调试器
7.1 为什么需要调试器?
当问题比较复杂时,仅靠 print() 和日志可能不够。
因为你可能想:
- 一步一步看程序怎么执行;
- 在运行中查看变量值;
- 在某个位置停下来再继续。
这时就需要调试器。Python 自带的调试器就是 pdb。
7.2 直接用 pdb 启动程序
假设有一个文件 err.py:
1 | s = '0' |
可以这样启动:
1 | python -m pdb err.py |
启动后,程序会进入调试状态。常见命令有:
l:查看代码;n:执行下一步;p 变量名:打印变量值;q:退出调试。
7.3 用 pdb.set_trace() 设置断点
还有一种更常用的方式,是在代码里手动放一个断点:
1 | import pdb |
程序运行到 pdb.set_trace() 时会自动停下,然后进入调试环境。
这时你可以:
- 查看变量;
- 单步执行;
- 判断接下来为什么会报错。
7.4 pdb 的使用价值
pdb 的核心价值不是“炫技”,而是:
当你不知道程序实际走到了哪里时,让程序把执行过程摊开给你看。
对理解复杂分支、循环、递归和异常传播特别有帮助。
八、单元测试:用代码验证代码
8.1 为什么要写测试?
很多人写完功能后,会手动跑几次看看结果对不对。
这样当然可以,但问题是:
- 靠肉眼检查不稳定;
- 一改代码就要重新手测;
- 容易漏掉边界情况;
- 项目一大就根本测不过来。
所以更可靠的方式是:
把“程序应该如何工作”也写成代码。
这就是测试。
8.2 什么是单元测试?
单元测试通常是针对某个小而明确的单元来验证行为,例如:
- 一个函数;
- 一个类的方法;
- 一个工具模块中的某个逻辑。
Python 标准库自带 unittest 模块,可以直接使用。
8.3 一个简单的被测类
先写一个示例类:
1 | class Student: |
8.4 编写 unittest 测试类
1 | import unittest |
8.5 如何运行测试
最简单的方式之一是:
1 | if __name__ == '__main__': |
然后直接运行这个测试文件。
也可以用命令行方式:
1 | python -m unittest test_student |
8.6 测试里最常见的断言方法
在 unittest 中,经常会用到这些断言:
self.assertEqual(a, b):断言两个值相等;self.assertTrue(x):断言条件为真;self.assertFalse(x):断言条件为假;self.assertRaises(...):断言某段代码会抛出指定异常。
这些断言本质上是在表达:
- 程序“应该输出什么”;
- 程序“应该满足什么条件”;
- 程序“在错误输入下应该如何失败”。
8.7 setUp() 和 tearDown()
如果每个测试前后都要做一些重复工作,可以用:
setUp():每个测试方法执行前调用;tearDown():每个测试方法执行后调用。
例如:
1 | class TestSomething(unittest.TestCase): |
实际项目里,它们常用于:
- 初始化测试数据;
- 建立临时连接;
- 清理测试环境。
8.8 单元测试真正带来的价值
单元测试最重要的价值不是“看起来专业”,而是:
- 帮助你验证边界条件;
- 修改代码后能快速回归检查;
- 让逻辑预期变得明确、可重复、可自动执行。
这对后期维护非常重要。
九、文档测试:doctest
9.1 什么是 doctest?
有些函数我们喜欢在文档字符串里直接写使用示例。
例如:
1 | def add(x, y): |
这里文档字符串中的:
>>> add(1, 2)3
既是示例,也是可验证的测试。
这就是 doctest 的思路。
9.2 doctest 的运行方式
例如:
1 | def fact(n): |
运行这个文件时,doctest 会自动检查文档示例和真实输出是否一致。
9.3 doctest 的优点和适用场景
它很适合:
- 小函数;
- 教学示例;
- 工具函数的使用说明;
- 文档和代码需要同步校验的场景。
不过它通常不能完全替代正式单元测试,因为复杂逻辑、复杂状态、复杂断言还是 unittest 更适合。
可以把它理解为:
doctest更偏“文档示例即测试”;unittest更偏“系统化测试用例”。
十、写异常处理和测试时的常见误区
10.1 不要用空的 except 随便吞掉错误
例如:
1 | try: |
这种写法非常危险,因为:
- 真正的问题被悄悄吞掉了;
- 你可能以为程序正常,其实只是错误被隐藏了;
- 后面会更难排查。
更好的方式是:
- 捕获明确异常;
- 至少记录日志;
- 不要无声失败。
10.2 不要把所有代码都塞进一个巨大的 try 里
try 的范围应该尽量只包住“可能抛出特定异常的核心语句”。
否则你会很难判断:
- 到底是哪里出的错;
- 捕获到的异常是不是你真正想处理的那个。
10.3 不要把 assert 当作普通输入校验
assert 更适合内部逻辑检查,而不是面向用户的输入控制。
用户输入校验更推荐:
if判断;- 明确抛出
ValueError、TypeError等。
10.4 测试不要只测“正常情况”
很多人写测试时只测:
- 输入正常;
- 输出正常。
但更容易出问题的往往是边界条件,例如:
- 0
- 空字符串
- 空列表
- 超范围值
- 错误类型
- 缺失字段
真正高价值的测试,往往正是这些边界测试。
十一、综合示例:把错误处理、日志和测试串起来
下面用一个小例子把前面的思路串起来。
1 | import logging |
这个小例子体现了几件事:
- 类方法里做输入合法性检查;
- 出错时抛出明确异常;
- 外层逻辑用日志记录错误;
- 测试既覆盖正常情况,也覆盖非法情况。
这就是比较完整、比较健康的一套编程习惯。
十二、学习建议:这一章最值得优先掌握什么?
12.1 先真正理解异常传播机制
只要你理解了:
- 异常会沿调用链往上抛;
- 不一定每层都捕获;
- 要在合适层次处理;
你的错误处理思路就会比很多初学者清晰很多。
12.2 先熟练掌握 try / except / finally
这是最基础也最常用的部分。
尤其要搞清楚:
except是干什么的;else什么时候执行;finally为什么无论如何都会执行。
12.3 调试时不要只会“改代码试试”
更好的习惯是:
- 先读报错;
- 再看变量;
- 必要时加日志;
- 复杂情况再上调试器。
这比盲改代码效率高得多。
12.4 从现在开始建立测试意识
即使是很小的程序,也可以尝试问自己:
- 这个函数的正常输入是什么?
- 边界输入是什么?
- 错误输入应该怎样表现?
你一旦开始这样思考,代码质量就会明显提升。
十三、总结
这一篇我们系统梳理了 Python 中“错误处理、调试与测试”的核心内容:
- 异常:程序运行时的问题通常以异常形式出现;
try ... except ... finally:用于捕获、处理和收尾;- 异常传播:错误会沿调用链向上抛出;
raise:用于主动抛出异常,表达业务规则;logging:比print()更适合真实项目中的调试和问题记录;assert:用于内部逻辑断言;pdb:帮助你单步查看程序执行状态;unittest:用代码验证代码,让功能可重复检查;doctest:让文档示例也能变成测试。
如果说前面的面向对象章节是在学习“如何更好地组织代码”,那么这一篇就是在学习:
当代码出错时,如何优雅地处理;当逻辑复杂时,如何高效地定位;当功能完成后,如何系统地验证。
掌握这部分之后,你写 Python 的方式会从“只关注功能实现”,逐步过渡到“同时关注稳定性、可调试性和可维护性”。
继续往后写时,比较自然的衔接方向通常有两个:
- 常用内建模块与标准库实战:比如
datetime、collections、pathlib、json、re; - 文件读写与数据处理:进一步进入更贴近实际开发的内容。