测试与调试
代码能运行不等于正确。测试验证预期行为,调试定位异常根因,两者结合才能构建可靠的系统。Python 提供了从简单断言到专业框架的多层次测试工具,以及从打印变量到交互式调试器的多种调试手段。
assert 语句
assert expression [, message] 在 expression 为假时抛出 AssertionError。它主要用于开发和测试阶段验证内部不变量,不应替代正式的数据校验或错误处理:
def divide(a, b):
assert isinstance(a, (int, float)) and isinstance(b, (int, float)), "参数必须是数字"
assert b != 0, "除数不能为零"
return a / b
print(divide(10, 2)) # 5.0
# divide(10, "2") # AssertionError: 参数必须是数字
# divide(10, 0) # AssertionError: 除数不能为零
关键性质:当 Python 以优化模式运行(python -O 或 python -OO)时,所有 assert 语句会被完全移除,不会执行也不会报错。因此绝不能用 assert 检查用户输入、文件存在性或安全边界:
# ❌ 错误:优化模式下 assert 消失,文件不存在也不会报错
assert os.path.exists("config.json")
config = json.load(open("config.json"))
# ✅ 正确:用常规异常处理
if not os.path.exists("config.json"):
raise FileNotFoundError("config.json 不存在")
__debug__ 是一个内置常量,在正常情况下为 True,在优化模式下为 False。可以基于它编写仅在调试时执行的代码:
if __debug__:
print("调试模式:加载详细日志配置")
doctest:文档即测试
doctest 从文档字符串中提取交互式会话示例并执行验证,确保文档与代码同步。它特别适合测试纯函数和工具函数:
def calc_bonus(salary: float, years: int) -> float:
"""
根据工龄计算年终奖。
工龄满 5 年发 2 倍月薪,不满发 0.5 倍。
>>> calc_bonus(10000.0, 6)
20000.0
>>> calc_bonus(10000.0, 3)
5000.0
>>> calc_bonus(8000.0, 5)
16000.0
"""
if years >= 5:
return salary * 2
return salary * 0.5
命令行运行 doctest:
python -m doctest -v your_module.py # -v 显示详细输出
Doctest 支持指令(directives)控制测试行为,写在 >>> 行末尾的 # doctest: 之后:
def get_timestamp():
"""
返回当前时间戳。
>>> import time
>>> t = get_timestamp()
>>> isinstance(t, float) # 只要类型对即可
True
>>> print(t) # doctest: +ELLIPSIS
1717...
"""
return time.time()
def random_choice(items):
"""
随机选择一项。
>>> random_choice([1, 2, 3]) in [1, 2, 3] # doctest: +SKIP
True
"""
import random
return random.choice(items)
常用指令包括 +ELLIPSIS(允许 ... 匹配任意输出)、+SKIP(跳过该测试)、+NORMALIZE_WHITESPACE(忽略空白差异)。Doctest 的局限在于不适合测试复杂状态、外部依赖和异常场景。
unittest 框架
unittest 是 Python 标准库中的 xUnit 风格测试框架,基于 TestCase 类组织测试。它无需安装,适合中大型项目和 CI/CD 集成:
import unittest
from datetime import datetime
class Employee:
def __init__(self, name: str, salary: float, join_year: int):
self.name = name
self.salary = salary
self.join_year = join_year
def years_of_service(self) -> int:
return datetime.now().year - self.join_year
def annual_bonus(self) -> float:
return self.salary * 2 if self.years_of_service() >= 5 else self.salary * 0.5
class TestEmployee(unittest.TestCase):
def setUp(self):
"""每个测试方法执行前运行,初始化共享资源"""
self.senior = Employee("翼王", 232000.0, 2015)
self.junior = Employee("航仔", 18888.0, 2023)
def tearDown(self):
"""每个测试方法执行后运行,清理资源"""
pass
def test_years_of_service(self):
self.assertGreaterEqual(self.senior.years_of_service(), 5)
self.assertEqual(self.junior.years_of_service(), 1)
def test_annual_bonus_senior(self):
self.assertEqual(self.senior.annual_bonus(), 232000.0 * 2)
def test_annual_bonus_junior(self):
self.assertEqual(self.junior.annual_bonus(), 18888.0 * 0.5)
def test_invalid_salary(self):
with self.assertRaises(TypeError):
Employee("测试", "不是数字", 2020)
@unittest.skip("功能尚未实现")
def test_promotion(self):
pass
@unittest.expectedFailure
def test_bug_demo(self):
"""已知失败的测试,修复后应移除装饰器"""
self.assertEqual(1, 2)
if __name__ == "__main__":
unittest.main()
unittest 提供丰富的断言方法:assertEqual、assertTrue、assertFalse、assertIsNone、assertIn、assertIsInstance、assertRaises(上下文管理器形式可捕获异常实例)。setUp/tearDown 支持测试级别的资源管理,类级别的 setUpClass/tearDownClass(配合 @classmethod)用于昂贵的全局资源(如数据库连接)。
测试发现功能允许批量运行:
python -m unittest discover -s tests -p "test_*.py" -v
pdb 调试器
pdb 是 Python 内置的交互式源码调试器。Python 3.7+ 推荐使用内置函数 breakpoint() 进入调试,它默认调用 pdb.set_trace(),但可通过 PYTHONBREAKPOINT 环境变量切换为其他调试器(如 ipdb、pudb):
def process_payroll(employees):
total = 0.0
for emp in employees:
breakpoint() # 程序暂停,进入 pdb 交互
total += emp["salary"]
return total
staff = [
{"name": "航仔", "salary": 18888.0},
{"name": "翼王", "salary": 232000.0},
]
# process_payroll(staff)
进入 pdb 后常用的命令:
| 命令 | 作用 |
|---|---|
n (next) | 执行下一行,不进入函数内部 |
s (step) | 进入函数调用内部 |
c (continue) | 继续运行到下一个断点或结束 |
l (list) | 显示当前位置附近的源码 |
p <expr> | 打印表达式值 |
pp <expr> | 美化打印(适合字典和列表) |
b <line> | 在指定行设置断点 |
q (quit) | 退出调试器,终止程序 |
事后调试(post-mortem)在程序崩溃后进入调试状态,查看栈帧和变量:
import pdb
import sys
def buggy():
return 1 / 0
try:
buggy()
except Exception:
pdb.post_mortem(sys.exc_info()[2]) # 在异常发生处进入调试
print 调试与日志
在不便使用 pdb 的场景(如生产环境、多线程程序),打印变量是快速定位问题的手段。使用 repr() 或 !r 转换可以暴露字符串的引号和转义字符,避免空白字符造成的误导:
name = "航仔\n"
print(f"name={name}") # 换行被直接输出,难以察觉
print(f"name={name!r}") # name='航仔\n' —— 转义可见
print(f"salary={salary:.2f}") # 格式化数字,保留两位小数
对于长期运行的程序,应使用 logging 模块替代 print。logging 支持分级输出、多目标(文件/控制台/网络)、格式化字符串和运行时级别控制:
import logging
# 基础配置(通常在程序入口执行一次)
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[
logging.FileHandler("app.log", encoding="utf-8"),
logging.StreamHandler()
]
)
logger = logging.getLogger("hr_system")
def update_salary(emp_name: str, new_salary: float):
logger.info("开始更新 %s 的工资", emp_name)
if new_salary < 2300:
logger.warning("%s 的工资 %.2f 低于最低标准", emp_name, new_salary)
try:
# ... 数据库操作 ...
logger.info("%s 工资更新成功", emp_name)
except Exception:
logger.exception("%s 工资更新失败", emp_name) # 自动记录异常栈
日志级别从低到高为 DEBUG < INFO < WARNING < ERROR < CRITICAL。设置 level=logging.INFO 后,DEBUG 级别的消息不会输出。logger.exception() 在 ERROR 级别记录消息的同时自动附加异常 traceback,是捕获异常时的首选方法。
生产环境建议:不要依赖 basicConfig 的默认设置,应显式配置 FileHandler(按大小或日期轮转)和 Formatter,并通过配置文件或环境变量控制日志级别,避免重启服务修改代码。