文章目录
- 引言
- [Python 异常处理](#Python 异常处理)
-
- [1. 何为异常?](#1. 何为异常?)
- [2. 捕获异常](#2. 捕获异常)
- [3. 完善异常处理结构](#3. 完善异常处理结构)
- [4. 抛出异常](#4. 抛出异常)
- [5. 异常处理的最佳实践](#5. 异常处理的最佳实践)
- 为什么要测试?
-
- [1. 手动测试的困境](#1. 手动测试的困境)
- [2. 自动化测试](#2. 自动化测试)
- [3. 测试与异常处理的关系](#3. 测试与异常处理的关系)
- [使用 unittest 编写单元测试](#使用 unittest 编写单元测试)
-
- [1. unittest 基础:第一个测试用例](#1. unittest 基础:第一个测试用例)
- [2. 测试异常:assertRaises 的多种用法](#2. 测试异常:assertRaises 的多种用法)
- [3. 使用 setUp 和 tearDown 减少重复代码](#3. 使用 setUp 和 tearDown 减少重复代码)
引言
你的 Python 程序是不是经常出现这样的报错:KeyError、ValueError、FileNotFoundError......然后程序直接退出?
当你信心满满地修改了一个函数,结果导致另一个看似无关的功能出错,你不得不手动测试半天?
这些问题其实都可以通过系统的异常处理和自动化测试来解决。本文将带你深入 Python 的异常机制,并教你用 unittest 为代码织起一张安全网,让你的程序从"弱不禁风"变得"坚如磐石"。
在开始之前先检查一下你的装备吧!!!
python环境不会装的看这里 :从安装到Hello World:Python环境搭建完整指南
python编辑器不会装的看这里 :零基础Python入门:手把手教你安装Python、新版PyCharm和VS Code
Python 异常处理
1. 何为异常?
想象一下,你写了一个程序,让用户输入两个数字,然后做除法:
python
a = int(input("请输入被除数: "))
b = int(input("请输入除数: "))
print("结果是:", a / b)
这段代码在正常情况下运行良好。但假如用户输入了 5 和 0,程序就会崩溃,打印出一段红色的错误信息:
text
Traceback (most recent call last):
File "divide.py", line 3, in <module>
print("结果是:", a / b)
ZeroDivisionError: division by zero
这种程序在运行时遇到的意外情况,就是异常。异常会中断程序的正常执行流程,如果不处理,程序就会停止。
异常 vs 语法错误
语法错误是代码写错了,比如少写一个冒号,Python 解释器根本没法执行你的代码。而异常是语法正确,但在运行时因为某些条件不满足而触发的错误。
Python 内置了很多异常类型,常见的有:
ZeroDivisionError:除以零ValueError:值错误,例如int("abc")TypeError:类型错误,例如"1" + 2FileNotFoundError:文件不存在IndexError:索引越界KeyError:字典键不存在
学会处理异常,能让你的程序在面对这些"意外"时保持镇定,而不是直接崩溃。
2. 捕获异常
Python 提供了 try 和 except 关键字来捕获和处理异常。基本语法如下:
python
try:
# 可能会抛出异常的代码
risky_code()
except SomeException:
# 当 SomeException 发生时执行的代码
handle_exception()
让我们用除法程序来演示:
python
try:
a = int(input("请输入被除数: "))
b = int(input("请输入除数: "))
result = a / b
print("结果是:", result)
except ZeroDivisionError:
print("错误:除数不能为零!")
except ValueError:
print("错误:请输入有效的整数!")
现在,无论用户输入非数字还是除数为零,程序都不会崩溃,而是给出友好的提示。
为什么要捕获具体异常?
你可能会想偷懒,直接写一个 except: 捕获所有异常。但这很危险!比如用户想用 Ctrl+C 终止程序,except: 会捕获 KeyboardInterrupt 异常,导致程序无法退出。所以,永远只捕获你预期会发生的、并且知道如何处理的异常。
如果你想获取异常对象,可以用 as e:
python
try:
num = int(input("请输入一个整数: "))
except ValueError as e:
print(f"转换失败: {e}") # 输出:转换失败: invalid literal for int() with base 10: 'abc'
3. 完善异常处理结构
try-except 还可以搭配 else 和 finally,让异常处理更精细。
else:只有当try块没有抛出异常时才会执行。通常用来放置那些依赖于try成功的代码。finally:无论是否发生异常,都会执行。通常用来释放资源(关闭文件、网络连接等)。
python
try:
file = open("data.txt", "r")
content = file.read()
except FileNotFoundError:
print("文件不存在,请检查路径。")
else:
print("文件读取成功,内容如下:")
print(content)
finally:
# 确保文件被关闭(如果文件已打开)
try:
file.close()
except NameError:
# file 可能未定义(打开失败),忽略
pass
上面的例子中,如果文件打开成功,else 块读取并打印内容;无论成功与否,finally 都会尝试关闭文件(虽然用 with 语句会更优雅,稍后会讲)。
执行顺序总结:
如果 try 中发生异常 → 执行对应的 except → 然后执行 finally
如果 try 中没有异常 → 执行 else → 然后执行 finally
4. 抛出异常
有时候,你需要在代码中主动报告一个错误。比如写一个银行取款函数,如果余额不足,应该告诉调用者。这时可以用 raise 抛出异常。
python
def withdraw(balance, amount):
if amount > balance:
raise ValueError("余额不足!")
return balance - amount
# 使用
try:
new_balance = withdraw(100, 150)
except ValueError as e:
print(e) # 输出:余额不足!
Python 内置的异常类型很多,但有时它们不够精确。比如 ValueError 可能代表各种错误,而你的业务逻辑需要一个更具体的异常。这时可以自定义异常类 ,只需继承 Exception 即可:
python
class InsufficientBalanceError(Exception):
"""余额不足时抛出的异常"""
pass
def withdraw(balance, amount):
if amount > balance:
raise InsufficientBalanceError(f"余额不足,当前余额: {balance}, 请求金额: {amount}")
return balance - amount
现在调用者可以精确捕获 InsufficientBalanceError,与其他 ValueError 区分开。
异常链
有时候你在处理一个异常时又抛出了另一个异常,为了保留原始异常的上下文,可以使用 raise ... from ...:
python
try:
db_query()
except DatabaseError as e:
raise BusinessError("业务处理失败") from e
这样,最终的异常会包含完整的异常链,便于调试。
5. 异常处理的最佳实践
掌握了基础语法,我们来看看在实际项目中应该遵循哪些原则。
❌ 问题1:捕获所有异常
python
try:
# 大量代码...
do_many_things()
except:
print("出错了") # 连 Ctrl+C 都捕获了,程序无法终止
✅ 解决办法:只捕获你能处理的异常
python
try:
do_many_things()
except (ValueError, IOError) as e:
# 只处理已知的异常,其他异常让程序崩溃,以便发现 bug
log_error(e)
❌ 问题2:用异常控制流程
python
# 判断字典是否有键,应该用 in,而不是 try/except
d = {}
try:
value = d["key"]
except KeyError:
value = None
✅ 解决办法:能用条件判断就不用异常
python
value = d.get("key") # 更简洁、高效
❌ 问题3:try 块太大
python
try:
data = load_data()
process(data)
save_result()
except Exception as e:
# 如果 process 出错,你可能想重试加载,但这里全部混在一起,无法区分
...
✅ 解决办法:精细控制异常处理范围
python
try:
data = load_data()
except DataLoadError as e:
# 只处理加载失败的情况
retry_load()
return
# 处理数据和保存结果时不放在 try 里,或者另放一个 try
process(data)
save_result()
❌ 问题4:忽略异常或只 print
python
try:
f = open("file.txt")
content = f.read()
except FileNotFoundError:
print("文件不存在") # 用户看到后可能不知道怎么办,而且没有日志记录
✅ 解决办法:记录异常,给用户友好提示
使用 logging 模块记录异常堆栈,方便排查问题:
python
import logging
logging.basicConfig(level=logging.ERROR)
try:
f = open("file.txt")
content = f.read()
except FileNotFoundError as e:
logging.exception("读取文件失败") # 会自动记录异常堆栈
print("文件不存在,请确认路径后重试。") # 给用户的信息要友好
✅ 使用 finally 或 with 释放资源
文件、网络连接、数据库连接等资源一定要释放。finally 保证无论是否异常都会执行,但更推荐使用上下文管理器(with 语句),它会自动处理资源的关闭:
python
with open("file.txt", "r") as f:
content = f.read()
# 离开 with 块后,文件自动关闭,即使中途发生异常
为什么要测试?
写完异常处理代码后,你可能会有这样的经历:某天你优化了一个函数,自信满满地提交代码,结果几天后用户报告某个功能出错了。你排查半天,发现正是你修改的那个函数间接导致了一个原本正常的流程崩溃。
这时候你会想:要是能提前知道这次修改会影响什么就好了。
这正是测试要解决的问题。
1. 手动测试的困境
很多开发者在写代码时,习惯手动测试:运行程序,输入一些数据,看看输出是否符合预期。这种方式在程序很小的时候还能应付,但随着代码规模增长,手动测试的弊端越来越明显:
- 耗时:每次修改都要重复执行大量操作,输入同样的数据,点击同样的按钮,几分钟甚至十几分钟就过去了。
- 不全面:你只会测试你想到的几种情况,而那些边界条件(比如除数为零、文件不存在、网络超时)很容易被遗漏。
- 难以回归:修复了一个 Bug,可能会引入新的 Bug,手动测试很难覆盖所有已有功能,导致"修一个,坏一个"的恶性循环。
- 无法重复:手动测试很难精确复现,特别是涉及并发或随机因素时,问题可能无法稳定复现。
拿我们之前写的除法程序来说,手动测试可能会这样做:
python
# 假设你运行程序,输入 10 和 2,看到输出 5
# 输入 10 和 0,看到错误提示
# 输入 abc,看到错误提示
# 嗯,看起来没问题了
但如果后面你重构了代码,把输入部分和计算部分拆成函数,你还能保证这些函数都正确吗?你可能需要把上面的手动操作再来一遍,甚至更多遍。
2. 自动化测试
自动化测试就是写一段代码,让它代替你去测试你的代码。你只需要编写一次测试用例,以后每次修改代码后,运行一下测试,就能立即知道所有功能是否正常。
自动化测试的好处显而易见:
- 节省时间:运行成百上千个测试可能只需要几秒钟,而且你可以随时运行,不用手动操作。
- 提升信心:当你有了一套全面的测试,你可以放心地重构、优化代码,因为测试会告诉你有没有破坏现有功能。
- 作为文档:测试用例本身就是对代码行为的描述,新加入的开发者可以通过阅读测试理解函数应该怎么用、在什么情况下会抛出什么异常。
- 保障回归:每次提交代码前运行测试,确保新增功能不影响已有功能,这就是"回归测试"。
例如,为除法程序写一个简单的自动化测试(用 unittest 框架):
python
import unittest
def divide(a, b):
if b == 0:
raise ValueError("除数不能为零")
return a / b
class TestDivide(unittest.TestCase):
def test_normal(self):
self.assertEqual(divide(10, 2), 5)
def test_zero_division(self):
with self.assertRaises(ValueError):
divide(10, 0)
if __name__ == "__main__":
unittest.main()
这段测试代码会验证两个场景:正常除法和除数为零时抛出异常。以后无论你怎么修改 divide 函数,只要运行这个测试,就能立刻知道它是否还符合预期。
3. 测试与异常处理的关系
还记得我们在第一部分写的自定义异常 InsufficientBalanceError 吗?如果你没有为它写测试,谁能保证你修改了取款逻辑后,它依然会在余额不足时正确抛出?
测试正是异常处理逻辑的"质检员"。它能确保:
- 异常在应该抛出的时候被抛出
- 异常在不应该抛出的时候不被抛出
- 异常携带了正确的错误信息
在此之前,希望你记住一句话:没有测试的代码,是不可靠的代码;没有测试的异常处理,只是心理安慰。
使用 unittest 编写单元测试
在前两部分,我们学习了如何用异常处理让代码更健壮,也明白了为什么要写测试。现在,我们来动手实践------用 Python 内置的 unittest 框架为我们的代码编写单元测试。
1. unittest 基础:第一个测试用例
unittest 是 Python 标准库中的测试框架,它受 Java 的 JUnit 启发,使用起来非常直观。
基本结构
- 测试类需要继承
unittest.TestCase - 测试方法必须以
test_开头 - 使用
setUp()和tearDown()进行测试前后的准备和清理工作(可选)
让我们从一个简单的函数开始测试:
python
# calculator.py
def add(a, b):
return a + b
def divide(a, b):
if b == 0:
raise ValueError("除数不能为0")
return a / b
对应的测试文件 test_calculator.py:
python
import unittest
from calculator import add, divide
class TestCalculator(unittest.TestCase):
def test_add(self):
self.assertEqual(add(1, 2), 3)
self.assertEqual(add(-1, 1), 0)
self.assertEqual(add(0, 0), 0)
def test_divide_normal(self):
self.assertEqual(divide(10, 2), 5)
self.assertEqual(divide(9, 3), 3)
self.assertAlmostEqual(divide(1, 3), 0.3333333) # 浮点数比较
def test_divide_by_zero(self):
with self.assertRaises(ValueError):
divide(10, 0)
if __name__ == "__main__":
unittest.main()
运行测试文件:
python
python test_calculator.py
你会看到类似输出:

如果某个测试失败,会显示详细的失败信息,帮助你快速定位问题。
常用断言方法
unittest.TestCase 提供了丰富的断言方法:
| 方法 | 检查 |
|---|---|
assertEqual(a, b) |
a == b |
assertNotEqual(a, b) |
a != b |
assertTrue(x) |
bool(x) is True |
assertFalse(x) |
bool(x) is False |
assertIs(a, b) |
a is b |
assertIsNone(x) |
x is None |
assertIn(a, b) |
a in b |
assertRaises(exc, fun, *args, **kwargs) |
fun(*args, **kwargs) 抛出 exc 异常 |
assertAlmostEqual(a, b) |
浮点数近似相等(指定小数位) |
2. 测试异常:assertRaises 的多种用法
异常测试是确保代码错误处理逻辑正确的重要手段。assertRaises 有几种使用方式:
方式一:上下文管理器(最常用)
python
def test_divide_by_zero(self):
with self.assertRaises(ValueError):
divide(10, 0)
如果你想检查异常的具体信息,可以这样:
python
def test_divide_by_zero(self):
with self.assertRaises(ValueError) as cm:
divide(10, 0)
self.assertEqual(str(cm.exception), "除数不能为0")
cm.exception 就是捕获到的异常对象,你可以对其做进一步断言。
方式二:直接调用(较少用)
python
def test_divide_by_zero(self):
self.assertRaises(ValueError, divide, 10, 0)
这种方式适合单行测试,但不能检查异常信息。
测试自定义异常
假设我们有自定义异常 InsufficientBalanceError:
python
class InsufficientBalanceError(Exception):
pass
def withdraw(balance, amount):
if amount > balance:
raise InsufficientBalanceError(f"余额不足,当前余额: {balance}")
return balance - amount
测试代码:
python
def test_withdraw_insufficient(self):
with self.assertRaises(InsufficientBalanceError) as cm:
withdraw(100, 150)
self.assertIn("余额不足", str(cm.exception))
3. 使用 setUp 和 tearDown 减少重复代码
如果多个测试用例需要相同的准备环境,可以在 setUp 方法中初始化,在 tearDown 中清理。setUp 会在每个测试方法执行前运行,tearDown 在每个测试方法执行后运行。
python
import unittest
import tempfile
import os
class TestFileOps(unittest.TestCase):
def setUp(self):
# 创建临时文件
self.temp_file = tempfile.NamedTemporaryFile(mode='w', delete=False)
self.temp_file.write("hello world")
self.temp_file.close()
def tearDown(self):
# 删除临时文件
os.unlink(self.temp_file.name)
def test_read_file(self):
with open(self.temp_file.name, 'r') as f:
content = f.read()
self.assertEqual(content, "hello world")
def test_file_exists(self):
self.assertTrue(os.path.exists(self.temp_file.name))
如果所有测试只需要一次准备和清理(比如数据库连接),可以使用 setUpClass 和 tearDownClass(类方法)。