Python第十课:异常捕获与测试入门

文章目录

  • 引言
  • [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 程序是不是经常出现这样的报错:KeyErrorValueErrorFileNotFoundError......然后程序直接退出?

当你信心满满地修改了一个函数,结果导致另一个看似无关的功能出错,你不得不手动测试半天?

这些问题其实都可以通过系统的异常处理和自动化测试来解决。本文将带你深入 Python 的异常机制,并教你用 unittest 为代码织起一张安全网,让你的程序从"弱不禁风"变得"坚如磐石"。


在开始之前先检查一下你的装备吧!!!

python环境不会装的看这里从安装到Hello World:Python环境搭建完整指南

python编辑器不会装的看这里零基础Python入门:手把手教你安装Python、新版PyCharm和VS Code


Python 异常处理

1. 何为异常?

想象一下,你写了一个程序,让用户输入两个数字,然后做除法:

python 复制代码
a = int(input("请输入被除数: "))
b = int(input("请输入除数: "))
print("结果是:", a / b)

这段代码在正常情况下运行良好。但假如用户输入了 50,程序就会崩溃,打印出一段红色的错误信息:

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" + 2
  • FileNotFoundError:文件不存在
  • IndexError:索引越界
  • KeyError:字典键不存在

学会处理异常,能让你的程序在面对这些"意外"时保持镇定,而不是直接崩溃。


2. 捕获异常

Python 提供了 tryexcept 关键字来捕获和处理异常。基本语法如下:

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 还可以搭配 elsefinally,让异常处理更精细。

  • 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. 手动测试的困境

很多开发者在写代码时,习惯手动测试:运行程序,输入一些数据,看看输出是否符合预期。这种方式在程序很小的时候还能应付,但随着代码规模增长,手动测试的弊端越来越明显:

  1. 耗时:每次修改都要重复执行大量操作,输入同样的数据,点击同样的按钮,几分钟甚至十几分钟就过去了。
  2. 不全面:你只会测试你想到的几种情况,而那些边界条件(比如除数为零、文件不存在、网络超时)很容易被遗漏。
  3. 难以回归:修复了一个 Bug,可能会引入新的 Bug,手动测试很难覆盖所有已有功能,导致"修一个,坏一个"的恶性循环。
  4. 无法重复:手动测试很难精确复现,特别是涉及并发或随机因素时,问题可能无法稳定复现。

拿我们之前写的除法程序来说,手动测试可能会这样做:

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(类方法)。

相关推荐
划水的code搬运工小李2 小时前
Origin技巧(五)连接matlab控制台
开发语言·matlab
自在极意功。2 小时前
ArrayList扩容机制
java·开发语言·算法·集合·arraylist
吃鱼不吐刺.2 小时前
Java线程池
java·开发语言
yj15582 小时前
怎么样避开装修易踩的10个坑
python
知无不研2 小时前
constexpr关键字
开发语言·c++·constexpr
计算机安禾2 小时前
【C语言程序设计】第26篇:变量的作用域与生命周期
c语言·开发语言·数据结构·算法·leetcode·visual studio code·visual studio
2401_898075122 小时前
C++中的智能指针详解
开发语言·c++·算法
花间相见2 小时前
【JAVA基础09】—— 赋值与三元运算符:从基础到实操的避坑指南
java·开发语言·python
wmfglpz882 小时前
使用Python进行PDF文件的处理与操作
jvm·数据库·python