第三十五篇:单元测试与文档测试——用测试保障代码质量
前言
没有测试的代码就是技术债。Python 提供了强大的内置测试框架 unittest 和灵活的 doctest,让开发者能够系统地验证代码正确性。本文将系统讲解 Python 测试的核心知识及最佳实践。
unittest 基础 —— 第一个测试
为什么需要单元测试?
# 假设这是一个数学工具模块(math_utils.py)
def add(a, b):
return a + b
def divide(a, b):
if b == 0:
raise ValueError("除数不能为零")
return a / b
# ❌ 每次修改后手动验证效率低且容易遗漏
result = add(1, 2)
if result != 3:
print(f"失败!期望 3,得到 {result}")
result = divide(10, 3)
if abs(result - 3.3333) > 0.0001:
print(f"失败!")
使用 unittest 编写测试
import unittest
def add(a, b):
return a + b
def subtract(a, b):
return a - b
def multiply(a, b):
return a * b
class TestMathFunctions(unittest.TestCase):
def test_add(self):
"""测试加法函数"""
self.assertEqual(add(1, 2), 3)
self.assertEqual(add(-1, 1), 0)
self.assertEqual(add(0, 0), 0)
self.assertIsInstance(add(1, 2), int)
def test_subtract(self):
"""测试减法函数"""
self.assertEqual(subtract(5, 3), 2)
self.assertEqual(subtract(0, 0), 0)
self.assertEqual(subtract(-1, -1), 0)
def test_multiply(self):
"""测试乘法函数"""
self.assertEqual(multiply(3, 4), 12)
self.assertEqual(multiply(-2, 3), -6)
self.assertEqual(multiply(0, 100), 0)
# 运行测试
if __name__ == "__main__":
unittest.main()
# 输出:
# ...
# Ran 3 tests in 0.001s
# OK
运行测试的多种方式
# 方式一:直接运行测试文件
python test_math.py
# 方式二:使用 unittest 模块
python -m unittest test_math.py
# 方式三:自动发现并运行所有测试
python -m unittest discover
# 方式四:显示详细输出
python -m unittest test_math.py -v
# 使用 pytest(第三方,更简洁)
pip install pytest
pytest test_math.py -v
TestCase 核心方法
常用断言方法
import unittest
import math
class TestAssertions(unittest.TestCase):
def test_equal(self):
self.assertEqual(1 + 1, 2)
self.assertNotEqual(1 + 1, 3)
def test_identity(self):
a = [1, 2]
b = a
c = [1, 2]
self.assertIs(a, b) # a 和 b 是同一对象
self.assertIsNot(a, c) # a 和 c 不是同一对象
def test_truthiness(self):
self.assertTrue(True)
self.assertFalse(False)
self.assertTrue(bool([1]))
self.assertFalse(bool([]))
def test_is_none(self):
value = None
self.assertIsNone(value)
self.assertIsNotNone(1)
def test_exception(self):
with self.assertRaises(ValueError):
raise ValueError("测试错误")
with self.assertRaises(TypeError) as cm:
raise TypeError("类型错误")
self.assertIn("类型错误", str(cm.exception))
def test_regex(self):
import re
self.assertRegex("hello123", r"\d+")
self.assertRegex("Test@example.com", r".+@.+\\..+")
self.assertNotRegex("no email here", r"@")
def test_close(self):
"""浮点数精度比较"""
self.assertAlmostEqual(0.1 + 0.2, 0.3, places=7)
self.assertAlmostEqual(math.pi, 3.14159, places=5)
def test_list_contains(self):
items = ["apple", "banana", "cherry"]
self.assertIn("banana", items)
self.assertNotIn("date", items)
def test_file_content(self):
"""检查文件大小或属性"""
self.assertGreater(100, 50)
self.assertLess(50, 100)
self.assertGreaterEqual(100, 100)
self.assertLessEqual(50, 50)
setUp 和 tearDown —— 测试前后的钩子
import unittest
import os
class TestFileOperations(unittest.TestCase):
def setUp(self):
"""每个测试方法执行前运行"""
print(f"\n[SetUp] 准备: {self._testMethodName}")
self.test_file = "test_data.txt"
with open(self.test_file, "w") as f:
f.write("Hello, World!\nLine 2\nLine 3\n")
self.temp_dir = "/tmp/test_output"
os.makedirs(self.temp_dir, exist_ok=True)
def tearDown(self):
"""每个测试方法执行后运行"""
print(f"[TearDown] 清理: {self._testMethodName}")
if os.path.exists(self.test_file):
os.remove(self.test_file)
def test_read_file(self):
with open(self.test_file, "r") as f:
content = f.read()
self.assertIn("Hello", content)
self.assertEqual(content.count("\n"), 3)
def test_count_lines(self):
with open(self.test_file, "r") as f:
lines = f.readlines()
self.assertEqual(len(lines), 3)
# 输出:
#
# [SetUp] 准备: test_read_file
# .[TearDown] 清理: test_read_file
#
# [SetUp] 准备: test_count_lines
# .[TearDown] 清理: test_count_lines
class-level 级别 setup/teardown
import unittest
import tempfile
class TestDatabaseConnection(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""整个类的所有测试开始前只运行一次"""
print("[SetupClass] 初始化数据库连接池...")
cls.connection_string = "sqlite:///:memory:"
cls.db_engine = f"Engine({cls.connection_string})"
@classmethod
def tearDownClass(cls):
"""整个类的所有测试结束后只运行一次"""
print("[TeardownClass] 关闭数据库连接池...")
cls.db_engine = None
def setUp(self):
"""每个测试开始前运行"""
print(f"[Setup] 获取新会话...")
self.session_id = f"session_{id(self)}"
def tearDown(self):
"""每个测试结束后运行"""
print(f"[Teardown] 释放会话 {self.session_id}")
self.session_id = None
def test_query_1(self):
print("执行查询 1...")
self.assertIsNotNone(self.db_engine)
def test_query_2(self):
print("执行查询 2...")
self.assertIsNotNone(self.db_engine)
# 输出顺序:
# [SetupClass] 初始化数据库连接池...
# [Setup] 获取新会话... → session_A
# 执行查询 1...
# [Teardown] 释放会话 session_A
# [Setup] 获取新会话... → session_B
# 执行查询 2...
# [Teardown] 释放会话 session_B
# [TeardownClass] 关闭数据库连接池...
实战:完整的测试案例
待测代码(calculator.py)
class Calculator:
"""简单的计算器类"""
def __init__(self):
self.history = []
def add(self, a, b):
result = a + b
self.history.append(f"{a} + {b} = {result}")
return result
def subtract(self, a, b):
result = a - b
self.history.append(f"{a} - {b} = {result}")
return result
def multiply(self, a, b):
result = a * b
self.history.append(f"{a} * {b} = {result}")
return result
def divide(self, a, b):
if b == 0:
raise ZeroDivisionError("除数不能为零")
result = a / b
self.history.append(f"{a} / {b} = {result}")
return result
def power(self, base, exponent):
result = base ** exponent
self.history.append(f"{base} ^ {exponent} = {result}")
return result
def clear_history(self):
self.history.clear()
def get_last_result(self):
if not self.history:
raise IndexError("计算历史为空")
return self.history[-1].split("=")[-1].strip()
完整测试(test_calculator.py)
import unittest
from calculator import Calculator
class TestCalculator(unittest.TestCase):
def setUp(self):
self.calc = Calculator()
def test_add(self):
self.assertEqual(self.calc.add(2, 3), 5)
self.assertEqual(self.calc.add(-1, 1), 0)
self.assertEqual(self.calc.add(0, 0), 0)
self.assertEqual(self.calc.add(1.5, 2.5), 4.0)
def test_subtract(self):
self.assertEqual(self.calc.subtract(5, 3), 2)
self.assertEqual(self.calc.subtract(0, 5), -5)
self.assertEqual(self.calc.subtract(-5, -5), 0)
def test_multiply(self):
self.assertEqual(self.calc.multiply(3, 4), 12)
self.assertEqual(self.calc.multiply(-2, 3), -6)
self.assertEqual(self.calc.multiply(0, 999), 0)
def test_divide_by_zero(self):
with self.assertRaises(ZeroDivisionError):
self.calc.divide(10, 0)
def test_divide(self):
self.assertAlmostEqual(self.calc.divide(10, 3), 3.333333, places=6)
self.assertEqual(self.calc.divide(8, 4), 2.0)
def test_power(self):
self.assertEqual(self.calc.power(2, 10), 1024)
self.assertEqual(self.calc.power(10, 0), 1)
self.assertEqual(self.calc.power(0, 5), 0)
def test_history(self):
self.calc.add(1, 2)
self.calc.multiply(3, 4)
self.assertEqual(len(self.calc.history), 2)
self.assertEqual(self.calc.get_last_result(), "12")
def test_clear_history(self):
self.calc.add(1, 2)
self.calc.clear_history()
self.assertEqual(len(self.calc.history), 0)
with self.assertRaises(IndexError):
self.calc.get_last_result()
def test_chained_operations(self):
"""链式运算测试"""
result = self.calc.add(10, 5)
result = self.calc.multiply(result, 2)
result = self.calc.subtract(result, 3)
self.assertEqual(result, 27)
doctest —— 从文档中生成测试
基本用法
def fibonacci(n):
"""
计算第 n 个斐波那契数。
Parameters:
n (int): 非负整数索引
Returns:
int: 第 n 个斐波那契数
Examples:
>>> fibonacci(0)
0
>>> fibonacci(1)
1
>>> fibonacci(5)
5
>>> fibonacci(10)
55
>>> fibonacci(-1)
Traceback (most recent call last):
...
ValueError: n 必须是非负整数
Notes:
斐波那契数列: 0, 1, 1, 2, 3, 5, 8, 13, ...
"""
if n < 0:
raise ValueError("n 必须是非负整数")
if n <= 1:
return n
a, b = 0, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b
# 运行 doctest
if __name__ == "__main__":
import doctest
doctest.testmod(verbose=True)
# 输出:
# Trying: fibonacci(0)
# Expecting:
# 0
# ok
# Trying: fibonacci(1)
# Expecting:
# 1
# ok
# ...
多行输出测试
def format_table(data):
"""
将数据格式化为表格字符串。
Examples:
>>> data = [("Alice", 25), ("Bob", 30)]
>>> table = format_table(data)
>>> "Alice" in table and "Bob" in table
True
>>> len(table.split("\\n")) >= 3 # 至少 3 行(标题 + 表头 + 数据)
True
>>> data = [("X", 1), ("Y", 2), ("Z", 3)]
>>> table = format_table(data)
>>> "X" in table
True
"""
header = "| Name | Age |"
separator = "|------|-----|"
rows = [f"| {name} | {age} |" for name, age in data]
return "\n".join([header, separator] + rows)
doctest 的运行方式
# 方式一:作为模块运行
python -m doctest fibonacci.py
# 方式二:命令行指定文件名
python -m doctest -v fibonacci.py
# 方式三:在代码中内嵌
import doctest
def run_tests():
doctest.testmod()
print("All tests passed!")
测试分类与项目组织
目录结构
my_project/
├── src/
│ ├── __init__.py
│ ├── calculator.py
│ └── utils.py
├── tests/
│ ├── __init__.py
│ ├── test_calculator.py
│ ├── test_utils.py
│ └── conftest.py # pytest fixtures(如果使用 pytest)
├── README.md
└── requirements.txt
测试夹具 Fixtures(unittest 版本)
import unittest
import json
import tempfile
import os
def load_json_config(filepath):
"""加载 JSON 配置文件"""
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
def create_temp_json(data):
"""创建临时 JSON 文件"""
fd, path = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
return path
class TestJSONConfig(unittest.TestCase):
def setUp(self):
"""每个测试前创建不同的配置"""
self.config_paths = {}
def tearDown(self):
"""每个测试后清理临时文件"""
for path in self.config_paths.values():
if os.path.exists(path):
os.remove(path)
def test_load_valid_config(self):
path = create_temp_json({"debug": True, "port": 8080})
config = load_json_config(path)
self.assertTrue(config["debug"])
self.assertEqual(config["port"], 8080)
self.config_paths["valid"] = path
def test_load_missing_key(self):
path = create_temp_json({"version": "1.0"})
config = load_json_config(path)
self.assertEqual(config["version"], "1.0")
self.assertNotIn("debug", config)
self.config_paths["minimal"] = path
def test_nested_config(self):
path = create_temp_json({
"database": {
"host": "localhost",
"port": 5432,
"name": "mydb"
}
})
config = load_json_config(path)
self.assertEqual(config["database"]["host"], "localhost")
self.assertEqual(config["database"]["port"], 5432)
self.config_paths["nested"] = path
参数化测试
手动参数化
import unittest
def is_palindrome(s):
"""判断是否为回文字符串"""
cleaned = s.lower().replace(" ", "")
return cleaned == cleaned[::-1]
class TestPalindrome(unittest.TestCase):
def test_palindromes(self):
"""多个回文测试用例"""
test_cases = [
("racecar", True),
("madam", True),
("level", True),
("A man a plan a canal Panama", True),
("hello", False),
("world", False),
("", True),
("a", True),
]
for input_str, expected in test_cases:
with self.subTest(input=input_str, expected=expected):
self.assertEqual(is_palindrome(input_str), expected)
# subTest 会在某个用例失败时继续执行后续用例
# 而不是立即中断
使用 unittest 参数化装饰器
import unittest
def fizz_buzz(n):
"""FizzBuzz 问题"""
if n % 15 == 0:
return "FizzBuzz"
elif n % 3 == 0:
return "Fizz"
elif n % 5 == 0:
return "Buzz"
else:
return str(n)
class TestFizzBuzz(unittest.TestCase):
def test_fizzbuzz_cases(self):
cases = [
(3, "Fizz"),
(5, "Buzz"),
(15, "FizzBuzz"),
(7, "7"),
(9, "Fizz"),
(25, "Buzz"),
(30, "FizzBuzz"),
(1, "1"),
(100, "Buzz"),
(99, "Fizz"),
]
for number, expected in cases:
with self.subTest(n=number, expected=expected):
self.assertEqual(fizz_buzz(number), expected)
mock —— 模拟外部依赖
为什么要 mock?
import unittest
from unittest.mock import Mock, patch, MagicMock
import requests
def fetch_weather(city):
"""从天气 API 获取天气数据"""
url = f"https://api.weather.com/v1/{city}"
response = requests.get(url)
response.raise_for_status()
data = response.json()
return data["temperature"]
# ❶ 真实调用会受网络影响
# ❷ 速度很慢
# ❸ 可能产生费用
# ❹ 不可控因素太多
使用 Mock 替代真实请求
class TestFetchWeather(unittest.TestCase):
def test_fetch_weather_success(self):
"""测试成功获取天气数据"""
with patch("requests.get") as mock_get:
# 设置 mock 返回值
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"temperature": 25}
mock_get.return_value = mock_response
temperature = fetch_weather("北京")
# 验证
self.assertEqual(temperature, 25)
mock_get.assert_called_once_with(
"https://api.weather.com/v1/北京"
)
def test_fetch_weather_api_error(self):
"""测试 API 返回错误"""
with patch("requests.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 404
mock_response.raise_for_status.side_effect = requests.HTTPError()
mock_get.return_value = mock_response
with self.assertRaises(requests.HTTPError):
fetch_weather("unknown_city")
def test_fetch_weather_timeout(self):
"""测试网络超时"""
with patch("requests.get") as mock_get:
mock_get.side_effect = requests.Timeout()
with self.assertRaises(requests.Timeout):
fetch_weather("上海")
Mock 数据库调用
import unittest
from unittest.mock import MagicMock, call
def process_orders(order_ids, db_connection):
"""处理订单列表"""
results = []
for order_id in order_ids:
order = db_connection.query("SELECT * FROM orders WHERE id = ?", order_id)
if order and order["status"] == "pending":
db_connection.execute(
"UPDATE orders SET status = 'processed' WHERE id = ?", order_id
)
results.append(order_id)
return results
class TestProcessOrders(unittest.TestCase):
def test_process_orders(self):
# 创建 mock 数据库
mock_db = MagicMock()
mock_db.query.return_value = {"status": "pending", "total": 100}
order_ids = [1, 2, 3]
results = process_orders(order_ids, mock_db)
self.assertEqual(results, [1, 2, 3])
# 验证数据库操作是否正确
self.assertEqual(mock_db.query.call_count, 3)
self.assertEqual(mock_db.execute.call_count, 3)
# 验证具体的调用参数
mock_db.query.assert_any_call(
"SELECT * FROM orders WHERE id = ?", 1
)
测试覆盖率
# 安装 coverage
pip install coverage
# 运行测试并生成覆盖率报告
coverage run -m unittest discover tests/
coverage report
coverage html
# 输出示例:
# Name Stmts Miss Cover
# ------------------------------------------
# src/calculator.py 32 0 100%
# src/utils.py 25 3 88%
# ------------------------------------------
# TOTAL 57 3 95%
总结
本文系统讲解了 Python 测试的核心知识:
- unittest 基础 — TestCase、setUp/tearDown、五种运行方式
- 断言方法 — assertEqual/assertIs/assertRaises/assertAlmostEqual 等
- doctest — 从文档注释自动生成测试用例
- 参数化测试 — subTest 和测试用例集合
- Mock — 替代外部依赖(HTTP 请求、数据库调用)
- 覆盖率 — coverage 工具量化测试质量
- 最佳实践 — 命名规范、独立测试、快速反馈
下一篇文章将进入 IO 编程章节。
本文是 Python 教程系列的第三十五篇。
