Python错误处理、调试与测试:异常、loggingpdbunittestdoctest

写程序时,最理想的情况当然是:代码一次写对,运行就成功。

但真实情况往往不是这样。程序可能会遇到很多问题:

  • 代码本身写错了;
  • 用户输入了非法数据;
  • 文件不存在;
  • 网络请求失败;
  • 某个值超出了预期范围;
  • 程序运行流程和我们想象的不一致。

所以,真正会写 Python,不只是会写“正常路径”的代码,还要学会:

  • 出错时如何处理;
  • 出错后如何定位问题;
  • 修好之后如何验证程序真的没问题。

这就是“错误处理、调试与测试”这一部分要解决的核心问题。

本文会围绕下面几个主题展开:

  • 什么是异常,为什么程序会报错;
  • 如何使用 try ... except ... finally 处理错误;
  • 如何主动抛出异常;
  • 如何用 loggingassertpdb 调试程序;
  • 如何编写单元测试;
  • 如何用 doctest 让文档示例也变成可执行测试。

理解这一章之后,你写出来的 Python 程序就不再只是“能跑”,而会开始朝着“更稳、更容易排错、更容易维护”的方向前进。

一、程序为什么会出错?

在 Python 中,程序运行时出现的问题,很多都会以“异常(Exception)”的形式表现出来。

例如:

1
print(10 / 0)

这会触发:

1
ZeroDivisionError

再比如:

1
int('abc')

会触发:

1
ValueError

还有:

1
open('not_exist.txt')

可能触发文件相关异常。

1.1 常见错误来源

程序里的错误大体可以分成几类:

  1. 语法错误:代码写法本身不合法,程序连运行都开始不了;
  2. 逻辑错误:代码能运行,但结果不对;
  3. 运行时异常:程序执行过程中遇到了无法正常继续的问题。

这篇主要讨论的是第三类,也就是运行时异常。

1.2 Python 遇到异常时会发生什么?

如果异常没有被处理,Python 会:

  • 停止当前执行流程;
  • 沿着调用链向上抛出异常;
  • 最终打印错误栈信息;
  • 程序结束。

例如:

1
2
3
4
5
6
7
8
9
def foo(s):
return 10 / int(s)


def bar(s):
return foo(s) * 2


bar('0')

这里 foo() 会抛出异常,这个异常会继续传到 bar(),再传到外层,最后导致整个程序退出。

这说明一个重要事实:

异常不是“局部问题”,它会沿调用链传播,直到被捕获或者让程序崩掉。


二、try ... except ... finally:捕获并处理异常

2.1 最基本的异常捕获

Python 提供了内置的异常处理机制:

1
2
3
4
5
6
try:
print('try...')
result = 10 / int('0')
print('result:', result)
except ZeroDivisionError as e:
print('except:', e)

执行时:

  • try 中先执行可能出错的代码;
  • 如果发生 ZeroDivisionError,就跳到对应的 except
  • 异常被处理后,程序可以继续往下执行。
2.2 捕获多种异常

实际开发里,一个逻辑可能抛出不止一种异常。

1
2
3
4
5
6
7
try:
value = int('abc')
result = 10 / value
except ValueError as e:
print('ValueError:', e)
except ZeroDivisionError as e:
print('ZeroDivisionError:', e)

这里:

  • int('abc') 会先触发 ValueError
  • 所以后面的除法不会执行;
  • 匹配到的 except 会被执行。
2.3 使用 except Exception as e

如果你不确定会遇到哪种异常,也可以先统一捕获:

1
2
3
4
try:
do_something()
except Exception as e:
print('发生异常:', e)

这样写很常见,但要注意:

  • 它适合在“外层统一兜底”;
  • 不适合到处滥用;
  • 如果什么都一把抓,可能会掩盖真实问题。

所以通常更推荐:

  • 能精确捕获时,优先写具体异常类型;
  • 在程序入口层再做统一兜底。
2.4 else:没出错时再执行

异常处理中还有一个不常被重视但很好用的结构:else

1
2
3
4
5
6
try:
value = int('123')
except ValueError as e:
print('转换失败:', e)
else:
print('转换成功:', value)

else 的含义是:

  • 只有 try 中完全没有异常时,才执行这里。

它适合用来表达“成功路径”,让代码结构更清晰。

2.5 finally:无论是否异常都执行

有些操作无论是否报错,都必须执行,例如:

  • 关闭文件;
  • 关闭数据库连接;
  • 释放锁;
  • 清理临时资源。

这时就要用 finally

1
2
3
4
5
6
7
try:
f = open('data.txt', 'r', encoding='utf-8')
content = f.read()
except FileNotFoundError as e:
print('文件不存在:', e)
finally:
print('finally...')

更典型一点的写法是:

1
2
3
4
5
6
7
f = None
try:
f = open('data.txt', 'r', encoding='utf-8')
print(f.read())
finally:
if f:
f.close()

finally 的关键点是:

不管 try 中有没有异常,它都会执行。

2.6 一个完整结构示例
1
2
3
4
5
6
7
8
9
10
11
12
try:
print('try...')
value = int('10')
result = 10 / value
except ValueError as e:
print('ValueError:', e)
except ZeroDivisionError as e:
print('ZeroDivisionError:', e)
else:
print('result:', result)
finally:
print('END')

这几部分职责可以这样记:

  • try:放可能出错的代码;
  • except:放异常处理逻辑;
  • else:放成功路径逻辑;
  • finally:放必须执行的收尾逻辑。

三、异常会跨多层调用传播

3.1 不需要每一层都捕获异常

很多初学者一看到可能报错,就想在每个函数里都写 try ... except

其实没必要。

看这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
def foo(s):
return 10 / int(s)


def bar(s):
return foo(s) * 2


def main():
try:
return bar(s)
except Exception as e:
print('error:', e)

这里即使 foo() 出错,也可以在 main() 统一处理。

这说明:

异常可以跨函数传播,所以你只需要在“合适的层次”捕获它。

3.2 什么时候在底层处理,什么时候往上抛?

可以这样理解:

  • 底层函数 更适合专注业务逻辑;
  • 上层函数 更适合决定怎么向用户展示错误、是否重试、是否记录日志。

例如:

  • 工具函数里发现参数非法,可以直接抛异常;
  • 应用入口层决定是提示用户、记日志,还是终止程序。

这是很重要的分层思路。


四、主动抛出异常:raise

4.1 为什么要自己抛异常?

不是所有异常都来自 Python 内部。

很多时候,我们自己的业务规则也会认为某些情况“不合法”,这时就应该主动抛出异常。

例如:

1
2
3
4
def divide(x, y):
if y == 0:
raise ValueError('除数不能为 0')
return x / y

这里 y == 0 时,虽然你也可以等 Python 自己抛 ZeroDivisionError,但主动检查并抛出更明确的异常,往往更符合业务语义。

4.2 尽量优先使用合适的内置异常

很多情况下,其实不需要自定义异常类,直接使用 Python 内置异常就够了,例如:

  • ValueError
  • TypeError
  • KeyError
  • IndexError
  • FileNotFoundError

例如:

1
2
3
4
5
def set_age(age):
if not isinstance(age, int):
raise TypeError('age 必须是整数')
if age < 0:
raise ValueError('age 不能小于 0')

这样别人一看异常类型,就能大致知道问题类别。

4.3 自定义异常类

如果你的业务里某类错误特别明确,也可以自定义异常:

1
2
3
4
5
6
7
class ScoreError(ValueError):
pass


def set_score(score):
if score < 0 or score > 100:
raise ScoreError('score 必须在 0 到 100 之间')

通常只有在错误类型需要明确表达业务语义时,才有必要这么做。

4.4 捕获后再抛出:raise

有时你想在捕获异常后做一点处理,但仍然希望异常继续往外抛。

1
2
3
4
5
6
7
8
9
10
11
12
13
def foo(s):
n = int(s)
if n == 0:
raise ValueError(f'invalid value: {s}')
return 10 / n


def bar():
try:
foo('0')
except ValueError as e:
print('ValueError!')
raise

这里:

  • bar() 捕获了异常;
  • 打印了一点提示;
  • 然后用裸 raise 再次抛出原异常。

这个技巧常用于:

  • 增加日志;
  • 补充上下文;
  • 但又不吞掉异常。

五、调试的第一步:看懂错误信息

很多人调试时最容易忽略的一件事,其实是:

先认真看报错信息和调用栈。

例如报错里通常会包含:

  • 异常类型;
  • 异常消息;
  • 出错文件;
  • 出错行号;
  • 调用路径。

这些信息往往已经足够定位很多问题。

比如下面这种错误:

1
ZeroDivisionError: division by zero

你至少应该第一时间想到:

  • 某个除数变成了 0;
  • 需要回头检查这个值是怎么传进来的;
  • 不是立刻“瞎改代码”。

调试能力很大一部分,其实就是:根据异常信息倒推程序当时的状态。


六、用 print()loggingassert 调试程序

6.1 最直接的方法:print()

最朴素、最直接的调试方式,就是打印变量。

1
2
3
4
5
6
7
def foo(s):
n = int(s)
print('>>> n =', n)
return 10 / n


foo('0')

这种方式的优点是:

  • 简单;
  • 立刻可用;
  • 对初学者很友好。

但问题也很明显:

  • 输出容易满天飞;
  • 不方便按级别控制;
  • 经常调完还要删掉。

所以它适合作为快速排查的小工具,但不适合长期替代正式日志方案。

6.2 更正规的方式:logging

Python 内置了 logging 模块,用来记录程序运行信息。

1
2
3
4
5
6
7
8
import logging

logging.basicConfig(level=logging.INFO)

s = '0'
n = int(s)
logging.info('n = %d', n)
print(10 / n)

这里的好处是:

  1. 可以分级别输出debuginfowarningerror 等;
  2. 不需要像 print() 那样到处手动删
  3. 可以通过配置决定输出到控制台、文件或其他位置。
6.3 为什么工程里更推荐 logging

因为 logging 更适合真实项目。

例如:

  • 开发环境输出更多细节;
  • 生产环境只保留警告和错误;
  • 出问题后可以回看日志文件;
  • 多模块程序也能统一管理日志。

这比简单 print() 更可维护。

6.4 assert:断言某个条件必须成立

有些时候,我们认为某个条件在逻辑上“绝对应该成立”,如果不成立,就说明程序哪里出了问题。

这时可以使用 assert

1
2
3
4
def foo(s):
n = int(s)
assert n != 0, 'n is zero!'
return 10 / n

如果 n == 0,程序会抛出:

1
AssertionError
6.5 如何理解 assert 的定位?

assert 更适合表达:

  • 这是开发阶段对程序状态的检查;
  • 一旦不满足,说明逻辑出了问题;
  • 它不是给普通用户做输入校验的主要方式。

也就是说:

  • 用户输入错误,通常应该用 if + raise
  • 程序内部不变量检查,才更适合 assert

七、使用 pdb:进入 Python 调试器

7.1 为什么需要调试器?

当问题比较复杂时,仅靠 print() 和日志可能不够。

因为你可能想:

  • 一步一步看程序怎么执行;
  • 在运行中查看变量值;
  • 在某个位置停下来再继续。

这时就需要调试器。Python 自带的调试器就是 pdb

7.2 直接用 pdb 启动程序

假设有一个文件 err.py

1
2
3
s = '0'
n = int(s)
print(10 / n)

可以这样启动:

1
python -m pdb err.py

启动后,程序会进入调试状态。常见命令有:

  • l:查看代码;
  • n:执行下一步;
  • p 变量名:打印变量值;
  • q:退出调试。
7.3 用 pdb.set_trace() 设置断点

还有一种更常用的方式,是在代码里手动放一个断点:

1
2
3
4
5
6
import pdb

s = '0'
n = int(s)
pdb.set_trace()
print(10 / n)

程序运行到 pdb.set_trace() 时会自动停下,然后进入调试环境。

这时你可以:

  • 查看变量;
  • 单步执行;
  • 判断接下来为什么会报错。
7.4 pdb 的使用价值

pdb 的核心价值不是“炫技”,而是:

当你不知道程序实际走到了哪里时,让程序把执行过程摊开给你看。

对理解复杂分支、循环、递归和异常传播特别有帮助。


八、单元测试:用代码验证代码

8.1 为什么要写测试?

很多人写完功能后,会手动跑几次看看结果对不对。

这样当然可以,但问题是:

  • 靠肉眼检查不稳定;
  • 一改代码就要重新手测;
  • 容易漏掉边界情况;
  • 项目一大就根本测不过来。

所以更可靠的方式是:

把“程序应该如何工作”也写成代码。

这就是测试。

8.2 什么是单元测试?

单元测试通常是针对某个小而明确的单元来验证行为,例如:

  • 一个函数;
  • 一个类的方法;
  • 一个工具模块中的某个逻辑。

Python 标准库自带 unittest 模块,可以直接使用。

8.3 一个简单的被测类

先写一个示例类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Student:
def __init__(self, name, score):
self.name = name
self.score = score

def get_grade(self):
if not isinstance(self.score, int):
raise ValueError('score 必须是整数')
if self.score < 0 or self.score > 100:
raise ValueError('score 必须在 0 到 100 之间')
if self.score >= 80:
return 'A'
if self.score >= 60:
return 'B'
return 'C'
8.4 编写 unittest 测试类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import unittest


class TestStudent(unittest.TestCase):
def test_80_to_100(self):
s1 = Student('Alice', 100)
s2 = Student('Bob', 80)
self.assertEqual(s1.get_grade(), 'A')
self.assertEqual(s2.get_grade(), 'A')

def test_60_to_79(self):
s1 = Student('Cindy', 79)
s2 = Student('David', 60)
self.assertEqual(s1.get_grade(), 'B')
self.assertEqual(s2.get_grade(), 'B')

def test_0_to_59(self):
s1 = Student('Eve', 59)
s2 = Student('Frank', 0)
self.assertEqual(s1.get_grade(), 'C')
self.assertEqual(s2.get_grade(), 'C')

def test_invalid(self):
s1 = Student('Grace', -1)
s2 = Student('Henry', 101)
with self.assertRaises(ValueError):
s1.get_grade()
with self.assertRaises(ValueError):
s2.get_grade()
8.5 如何运行测试

最简单的方式之一是:

1
2
if __name__ == '__main__':
unittest.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
2
3
4
5
6
class TestSomething(unittest.TestCase):
def setUp(self):
print('setUp...')

def tearDown(self):
print('tearDown...')

实际项目里,它们常用于:

  • 初始化测试数据;
  • 建立临时连接;
  • 清理测试环境。
8.8 单元测试真正带来的价值

单元测试最重要的价值不是“看起来专业”,而是:

  1. 帮助你验证边界条件
  2. 修改代码后能快速回归检查
  3. 让逻辑预期变得明确、可重复、可自动执行。

这对后期维护非常重要。


九、文档测试:doctest

9.1 什么是 doctest

有些函数我们喜欢在文档字符串里直接写使用示例。

例如:

1
2
3
4
5
6
7
8
9
10
def add(x, y):
"""
返回两个数之和。

>>> add(1, 2)
3
>>> add(-1, 5)
4
"""
return x + y

这里文档字符串中的:

  • >>> add(1, 2)
  • 3

既是示例,也是可验证的测试。

这就是 doctest 的思路。

9.2 doctest 的运行方式

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def fact(n):
"""
计算 1 * 2 * 3 * ... * n

>>> fact(1)
1
>>> fact(5)
120
"""
if n < 1:
raise ValueError('n 必须大于等于 1')
if n == 1:
return 1
return n * fact(n - 1)


if __name__ == '__main__':
import doctest
doctest.testmod()

运行这个文件时,doctest 会自动检查文档示例和真实输出是否一致。

9.3 doctest 的优点和适用场景

它很适合:

  • 小函数;
  • 教学示例;
  • 工具函数的使用说明;
  • 文档和代码需要同步校验的场景。

不过它通常不能完全替代正式单元测试,因为复杂逻辑、复杂状态、复杂断言还是 unittest 更适合。

可以把它理解为:

  • doctest 更偏“文档示例即测试”;
  • unittest 更偏“系统化测试用例”。

十、写异常处理和测试时的常见误区

10.1 不要用空的 except 随便吞掉错误

例如:

1
2
3
4
try:
do_something()
except:
pass

这种写法非常危险,因为:

  • 真正的问题被悄悄吞掉了;
  • 你可能以为程序正常,其实只是错误被隐藏了;
  • 后面会更难排查。

更好的方式是:

  • 捕获明确异常;
  • 至少记录日志;
  • 不要无声失败。
10.2 不要把所有代码都塞进一个巨大的 try

try 的范围应该尽量只包住“可能抛出特定异常的核心语句”。

否则你会很难判断:

  • 到底是哪里出的错;
  • 捕获到的异常是不是你真正想处理的那个。
10.3 不要把 assert 当作普通输入校验

assert 更适合内部逻辑检查,而不是面向用户的输入控制。

用户输入校验更推荐:

  • if 判断;
  • 明确抛出 ValueErrorTypeError 等。
10.4 测试不要只测“正常情况”

很多人写测试时只测:

  • 输入正常;
  • 输出正常。

但更容易出问题的往往是边界条件,例如:

  • 0
  • 空字符串
  • 空列表
  • 超范围值
  • 错误类型
  • 缺失字段

真正高价值的测试,往往正是这些边界测试。


十一、综合示例:把错误处理、日志和测试串起来

下面用一个小例子把前面的思路串起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import logging
import unittest

logging.basicConfig(level=logging.INFO)


class Student:
def __init__(self, name, score):
self.name = name
self.score = score

def get_grade(self):
if not isinstance(self.score, int):
raise TypeError('score 必须是整数')
if self.score < 0 or self.score > 100:
raise ValueError('score 必须在 0 到 100 之间')
if self.score >= 80:
return 'A'
if self.score >= 60:
return 'B'
return 'C'


def print_grade(student):
try:
grade = student.get_grade()
logging.info('%s 的等级是 %s', student.name, grade)
return grade
except Exception as e:
logging.exception('获取等级失败: %s', e)
raise


class TestStudent(unittest.TestCase):
def test_grade_a(self):
s = Student('Alice', 95)
self.assertEqual(s.get_grade(), 'A')

def test_grade_b(self):
s = Student('Bob', 70)
self.assertEqual(s.get_grade(), 'B')

def test_grade_c(self):
s = Student('Cindy', 50)
self.assertEqual(s.get_grade(), 'C')

def test_invalid_score(self):
s = Student('David', 120)
with self.assertRaises(ValueError):
s.get_grade()

这个小例子体现了几件事:

  • 类方法里做输入合法性检查;
  • 出错时抛出明确异常;
  • 外层逻辑用日志记录错误;
  • 测试既覆盖正常情况,也覆盖非法情况。

这就是比较完整、比较健康的一套编程习惯。


十二、学习建议:这一章最值得优先掌握什么?

12.1 先真正理解异常传播机制

只要你理解了:

  • 异常会沿调用链往上抛;
  • 不一定每层都捕获;
  • 要在合适层次处理;

你的错误处理思路就会比很多初学者清晰很多。

12.2 先熟练掌握 try / except / finally

这是最基础也最常用的部分。

尤其要搞清楚:

  • except 是干什么的;
  • else 什么时候执行;
  • finally 为什么无论如何都会执行。
12.3 调试时不要只会“改代码试试”

更好的习惯是:

  • 先读报错;
  • 再看变量;
  • 必要时加日志;
  • 复杂情况再上调试器。

这比盲改代码效率高得多。

12.4 从现在开始建立测试意识

即使是很小的程序,也可以尝试问自己:

  • 这个函数的正常输入是什么?
  • 边界输入是什么?
  • 错误输入应该怎样表现?

你一旦开始这样思考,代码质量就会明显提升。


十三、总结

这一篇我们系统梳理了 Python 中“错误处理、调试与测试”的核心内容:

  1. 异常:程序运行时的问题通常以异常形式出现;
  2. try ... except ... finally:用于捕获、处理和收尾;
  3. 异常传播:错误会沿调用链向上抛出;
  4. raise:用于主动抛出异常,表达业务规则;
  5. logging:比 print() 更适合真实项目中的调试和问题记录;
  6. assert:用于内部逻辑断言;
  7. pdb:帮助你单步查看程序执行状态;
  8. unittest:用代码验证代码,让功能可重复检查;
  9. doctest:让文档示例也能变成测试。

如果说前面的面向对象章节是在学习“如何更好地组织代码”,那么这一篇就是在学习:

当代码出错时,如何优雅地处理;当逻辑复杂时,如何高效地定位;当功能完成后,如何系统地验证。

掌握这部分之后,你写 Python 的方式会从“只关注功能实现”,逐步过渡到“同时关注稳定性、可调试性和可维护性”。

继续往后写时,比较自然的衔接方向通常有两个:

  • 常用内建模块与标准库实战:比如 datetimecollectionspathlibjsonre
  • 文件读写与数据处理:进一步进入更贴近实际开发的内容。

参考资料