8 minutes
测试入门:unittest 与 pytest
当项目从几十行代码膨胀到几千行时,你会面临一个残酷的现实:改了一处逻辑,不知道哪里会碎。手动测试一遍所有功能越来越耗时,而且人总会漏掉东西。这就是自动化测试登场的地方——用代码来验证代码。
Python 的标准库内置了 unittest 模块,社区则更青睐简洁的 pytest。本章从零开始,带你建立测试思维,掌握两种测试框架,最终写出可维护的测试。
为什么需要测试?
没有测试的项目就像走在钢丝上:
# 假设这是你的核心函数
def calculate_discount(price: float, member_level: str) -> float:
"""根据会员等级计算折扣价"""
discounts = {"normal": 0.95, "silver": 0.9, "gold": 0.8}
return price * discounts.get(member_level, 1.0)
# 改了一行代码——有人把 silver 的折扣改成了 0.85
# 没有测试的话,你可能永远不会发现这个"优化"改变了行为
测试带来的好处:
| 好处 | 说明 |
|---|---|
| 回归防护 | 修改代码后立即知道是否有东西被破坏 |
| 活的文档 | 测试代码展示了函数在各种情况下的预期行为 |
| 设计反馈 | 如果函数很难写测试,通常意味着它的设计需要改进 |
| 信心 | 重构时不怕改出问题,测试网络会兜住你 |
unittest:标准库自带
unittest 是 Python 标准库的一部分,灵感来自 Java 的 JUnit。不需要额外安装,开箱即用。
基本结构
import unittest
# 被测函数
def add(a, b):
return a + b
class TestMathOperations(unittest.TestCase):
"""测试类必须继承 unittest.TestCase"""
def test_add_positive(self):
"""测试方法必须以 test_ 开头"""
self.assertEqual(add(3, 5), 8)
def test_add_negative(self):
self.assertEqual(add(-1, 1), 0)
def test_add_zero(self):
self.assertEqual(add(0, 0), 0)
if __name__ == "__main__":
unittest.main()
运行测试:
python test_math.py
# 输出:
# ...
# ----------------------------------------------------------------------
# Ran 3 tests in 0.001s
#
# OK
setUp / tearDown
每个测试方法运行前后,setUp 和 tearDown 会被自动调用。这适合准备和清理共享资源:
import unittest
import tempfile
import os
class TestFileOps(unittest.TestCase):
def setUp(self):
"""每个测试前执行——创建临时目录"""
self.temp_dir = tempfile.mkdtemp()
self.test_file = os.path.join(self.temp_dir, "test.txt")
with open(self.test_file, "w") as f:
f.write("Hello, World!")
def tearDown(self):
"""每个测试后执行——清理临时文件"""
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_read_file(self):
with open(self.test_file) as f:
content = f.read()
self.assertEqual(content, "Hello, World!")
def test_file_exists(self):
self.assertTrue(os.path.exists(self.test_file))
也可以使用类级别的 setUpClass / tearDownClass(在整个测试类运行前后执行一次):
class TestDatabase(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""测试类加载时执行一次"""
cls.connection = create_database_connection()
@classmethod
def tearDownClass(cls):
"""测试类销毁时执行一次"""
cls.connection.close()
常用断言方法
unittest 提供了一系列断言方法,覆盖各种检查场景:
class TestAssertions(unittest.TestCase):
def test_assertions(self):
# 相等性
self.assertEqual(2 + 2, 4)
self.assertNotEqual(2 + 2, 5)
# 布尔值
self.assertTrue(1 < 2)
self.assertFalse(1 > 2)
# 空值
self.assertIsNone(None)
self.assertIsNotNone("hello")
# 类型
self.assertIsInstance(42, int)
self.assertNotIsInstance("42", int)
# 集合
self.assertIn("a", "abc")
self.assertNotIn("z", "abc")
# 近似相等(浮点数)
self.assertAlmostEqual(0.1 + 0.2, 0.3, places=7)
测试异常
要验证函数是否正确地抛出异常,使用 assertRaises:
def divide(a, b):
if b == 0:
raise ValueError("除数不能为零")
return a / b
class TestDivide(unittest.TestCase):
def test_divide_normal(self):
self.assertEqual(divide(10, 2), 5.0)
def test_divide_by_zero(self):
# 方式一:上下文管理器(推荐)
with self.assertRaises(ValueError) as ctx:
divide(10, 0)
self.assertEqual(str(ctx.exception), "除数不能为零")
# 方式二:函数式
self.assertRaises(ValueError, divide, 10, 0)
测试发现
unittest 可以自动发现项目中的所有测试文件:
# 递归发现当前目录下所有 test*.py 文件并运行
python -m unittest discover
# 指定目录和模式
python -m unittest discover -s tests -p "test_*.py"
# 运行单个测试类
python -m unittest test_math.TestMathOperations
# 运行单个测试方法
python -m unittest test_math.TestMathOperations.test_add_positive
# 详细输出
python -m unittest -v
跳过测试
import sys
class TestSkip(unittest.TestCase):
@unittest.skip("临时跳过")
def test_todo(self):
pass
@unittest.skipIf(sys.platform == "win32", "Windows 不适用")
def test_unix_only(self):
pass
@unittest.skipUnless(sys.version_info >= (3, 10), "需要 Python 3.10+")
def test_new_feature(self):
pass
pytest:更现代的测试框架
pytest 是 Python 社区最受欢迎的测试框架。它的哲学是"测试代码应该像普通 Python 一样自然"。
安装:
pip install pytest
与 unittest 的对比
同样是测试加法,pytest 的版本简洁得多:
# test_math_pytest.py
def add(a, b):
return a + b
# 不需要类,不需要继承,不需要断言方法
def test_add_positive():
assert add(3, 5) == 8
def test_add_negative():
assert add(-1, 1) == 0
def test_add_zero():
assert add(0, 0) == 0
# 只需运行 pytest
pytest
# 或者指定文件
pytest test_math_pytest.py -v
| 对比项 | unittest | pytest |
|---|---|---|
| 语法 | 需要类 + 继承 | 纯函数,assert 语句 |
| 依赖 | 内置,零安装 | 需要 pip install pytest |
| 断言方式 | self.assertEqual(x, y) |
assert x == y |
| 失败信息 | 有限 | 详细的 diff 和上下文 |
| 插件生态 | 一般 | 极其丰富 |
| 参数化 | 有限 | @pytest.mark.parametrize |
| 学习曲线 | 较陡 | 平缓 |
详细的失败信息
pytest 最令人称道的特性之一:断言失败时给出丰富的上下文:
def get_user_name(user_id):
users = {1: "Alice", 2: "Bob"}
return users.get(user_id)
def test_get_user_name():
assert get_user_name(1) == "Alice"
assert get_user_name(3) == "Unknown" # 会失败
FAILED test_example.py::test_get_user_name -
AssertionError: assert None == 'Unknown'
+ where None = get_user_name(3)
你会直接看到问题的根源——get_user_name(3) 返回了 None,而不是预期的 "Unknown"。
Fixture:测试前的准备工作
pytest 的 fixture 系统比 unittest 的 setUp/tearDown 更灵活:
import pytest
@pytest.fixture
def sample_data():
"""这个 fixture 为测试提供数据"""
return {"name": "Alice", "age": 30, "items": [1, 2, 3]}
# fixture 作为参数注入
def test_sample_name(sample_data):
assert sample_data["name"] == "Alice"
def test_sample_items(sample_data):
assert len(sample_data["items"]) == 3
assert sum(sample_data["items"]) == 6
Fixture 的作用域
import pytest
@pytest.fixture(scope="function") # 默认,每个测试函数独立
def per_test():
print("\n 每个测试前执行")
yield
print(" 每个测试后执行")
@pytest.fixture(scope="module") # 每个模块共享
def db_connection():
print("\n[模块] 连接数据库")
conn = {"connected": True}
yield conn
print("[模块] 关闭连接")
@pytest.fixture(scope="session") # 整个测试会话共享
def global_config():
return {"base_url": "http://localhost:8000"}
# 使用 yield 的 fixture:
# yield 之前的代码是 setUp(测试前执行)
# yield 之后的代码是 tearDown(测试后执行)
@pytest.fixture
def temp_file():
import tempfile
import os
fd, path = tempfile.mkstemp()
yield path
os.close(fd)
os.unlink(path)
def test_write_read(temp_file):
with open(temp_file, "w") as f:
f.write("test data")
with open(temp_file) as f:
assert f.read() == "test data"
autouse:自动应用的 Fixture
@pytest.fixture(autouse=True)
def time_it():
"""自动测量每个测试的执行时间"""
import time
start = time.perf_counter()
yield
elapsed = time.perf_counter() - start
print(f"耗时: {elapsed:.4f}s")
参数化测试
参数化让你用不同的输入运行同一个测试逻辑:
import pytest
def is_even(n):
return n % 2 == 0
@pytest.mark.parametrize("input_val, expected", [
(2, True),
(3, False),
(0, True),
(-2, True),
(-3, False),
(100, True),
])
def test_is_even(input_val, expected):
assert is_even(input_val) == expected
当参数很多时,参数化测试的好处很明显:
- 一个测试覆盖所有边界情况
- 新增测试用例只需加一行数据
- 失败时明确知道是哪个参数组合出错
嵌套参数化:
@pytest.mark.parametrize("a, b", [(1, 2), (3, 4)])
@pytest.mark.parametrize("op, expected", [
("add", lambda a, b: a + b),
("mul", lambda a, b: a * b),
])
def test_two_levels(a, b, op, expected):
"""组合 2×2 = 4 个测试用例"""
assert op(a, b) == expected
使用 pytest-mock
pytest-mock 提供了更简洁的 mock 语法:
pip install pytest-mock
import requests
def fetch_user_data(user_id):
"""从外部 API 获取用户数据"""
response = requests.get(f"https://api.example.com/users/{user_id}")
response.raise_for_status()
return response.json()
def test_fetch_user_data(mocker):
"""用 mock 模拟 API 调用,不依赖网络"""
mock_response = mocker.Mock()
mock_response.json.return_value = {"id": 1, "name": "Alice"}
mock_response.raise_for_status.return_value = None
mocker.patch("requests.get", return_value=mock_response)
result = fetch_user_data(1)
assert result["name"] == "Alice"
requests.get.assert_called_once_with("https://api.example.com/users/1")
monkeypatch:pytest 原生的打补丁工具
不需要额外安装,pytest 自带 monkeypatch:
import os
def get_api_key():
api_key = os.environ.get("API_KEY")
if not api_key:
raise RuntimeError("API_KEY 环境变量未设置")
return api_key
def test_get_api_key(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key-123")
assert get_api_key() == "test-key-123"
def test_get_api_key_missing(monkeypatch):
monkeypatch.delenv("API_KEY", raising=False)
with pytest.raises(RuntimeError, match="未设置"):
get_api_key()
测试覆盖率
覆盖率告诉你有多少代码被测试执行到。它不能保证代码没有 bug,但未覆盖的代码肯定没被测试过。
pip install pytest-cov
# 生成覆盖率报告
pytest --cov=myproject --cov-report=html
# 终端报告
pytest --cov=myproject --cov-report=term-missing
覆盖率指标:
| 指标 | 含义 |
|---|---|
| line coverage | 被执行的代码行数占比 |
| branch coverage | 条件分支(if/else)的覆盖占比 |
| function coverage | 被调用的函数占比 |
注意:100% 覆盖率不等于没有 bug——只说明你的测试把每条代码都跑了一遍。但高覆盖率(80%+)仍然是项目健康的重要指标。
实战:测试 Calculator 类
让我们完整地测试一个计算器类:
# calculator.py
class Calculator:
"""一个简单的计算器类"""
def add(self, a, b):
return a + b
def subtract(self, a, b):
return a - b
def multiply(self, a, b):
return a * b
def divide(self, a, b):
if b == 0:
raise ValueError("除数不能为零")
return a / b
def power(self, a, b):
return a ** b
def modulo(self, a, b):
if b == 0:
raise ValueError("模数不能为零")
return a % b
# test_calculator.py
import pytest
from calculator import Calculator
@pytest.fixture
def calc():
"""每个测试都使用新的 Calculator 实例"""
return Calculator()
class TestCalculator:
"""Calculator 的测试套件"""
def test_add(self, calc):
assert calc.add(3, 5) == 8
assert calc.add(-1, 1) == 0
assert calc.add(0, 0) == 0
def test_subtract(self, calc):
assert calc.subtract(10, 3) == 7
assert calc.subtract(5, 5) == 0
assert calc.subtract(3, 10) == -7
def test_multiply(self, calc):
assert calc.multiply(4, 3) == 12
assert calc.multiply(0, 5) == 0
assert calc.multiply(-2, 3) == -6
def test_divide(self, calc):
assert calc.divide(10, 2) == 5.0
assert calc.divide(7, 2) == 3.5
with pytest.raises(ValueError, match="除数不能为零"):
calc.divide(10, 0)
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 8),
(5, 0, 1),
(2, -1, 0.5),
])
def test_power(self, calc, a, b, expected):
assert calc.power(a, b) == expected
def test_modulo(self, calc):
assert calc.modulo(10, 3) == 1
assert calc.modulo(4, 2) == 0
with pytest.raises(ValueError, match="模数不能为零"):
calc.modulo(10, 0)
def test_divide_by_zero_message(self, calc):
with pytest.raises(ValueError) as exc_info:
calc.divide(5, 0)
assert str(exc_info.value) == "除数不能为零"
实战:测试文件 I/O
# file_ops.py
import json
from pathlib import Path
def save_json(data, filepath):
"""将数据保存为 JSON 文件"""
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_json(filepath):
"""从 JSON 文件读取数据"""
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
def append_line(filepath, line):
"""向文件追加一行"""
with open(filepath, "a", encoding="utf-8") as f:
f.write(line + "\n")
# test_file_ops.py
import pytest
import json
from pathlib import Path
from file_ops import save_json, load_json, append_line
class TestJsonFileOps:
def test_save_and_load_json(self, tmp_path):
"""保存后再读取,内容应一致"""
data = {"name": "Alice", "age": 30}
filepath = tmp_path / "data.json"
save_json(data, filepath)
loaded = load_json(filepath)
assert loaded == data
def test_save_json_overwrite(self, tmp_path):
"""保存应覆盖已有文件"""
filepath = tmp_path / "data.json"
save_json({"version": 1}, filepath)
save_json({"version": 2}, filepath)
loaded = load_json(filepath)
assert loaded["version"] == 2
def test_load_nonexistent_file(self, tmp_path):
"""读取不存在的文件应抛异常"""
filepath = tmp_path / "nonexistent.json"
with pytest.raises(FileNotFoundError):
load_json(filepath)
class TestAppendLine:
def test_append_new_file(self, tmp_path):
"""在新文件中追加一行"""
filepath = tmp_path / "log.txt"
append_line(filepath, "first line")
content = filepath.read_text(encoding="utf-8")
assert content == "first line\n"
def test_append_existing_file(self, tmp_path):
"""在已有文件中追加多行"""
filepath = tmp_path / "log.txt"
filepath.write_text("line1\n", encoding="utf-8")
append_line(filepath, "line2")
append_line(filepath, "line3")
lines = filepath.read_text(encoding="utf-8").splitlines()
assert lines == ["line1", "line2", "line3"]
注意上面用到了 tmp_path——这是 pytest 内置的 fixture,为每个测试提供一个独立的临时目录,测试结束后自动清理。
实战:测试 API 调用
测试网络调用的关键是 不要真的发起 HTTP 请求——否则你的测试就会依赖网络、第三方服务是否在线、API key 是否有效。用 mock 来模拟网络层。
# weather_client.py
import requests
from dataclasses import dataclass
@dataclass
class Weather:
city: str
temperature: float
condition: str
class WeatherClient:
"""调用外部天气 API 的客户端"""
BASE_URL = "https://api.weather.example.com"
def __init__(self, api_key: str):
self.api_key = api_key
self.session = requests.Session()
self.session.headers.update({"Authorization": f"Bearer {api_key}"})
def get_current(self, city: str) -> Weather:
"""获取指定城市的当前天气"""
response = self.session.get(
f"{self.BASE_URL}/current",
params={"city": city}
)
response.raise_for_status()
data = response.json()
return Weather(
city=data["location"],
temperature=data["temp_c"],
condition=data["condition"],
)
# test_weather_client.py
import pytest
import requests
from weather_client import WeatherClient, Weather
class TestWeatherClient:
@pytest.fixture
def client(self):
return WeatherClient(api_key="test-key")
def test_get_current_success(self, client, mocker):
"""正常获取天气"""
mock_response = mocker.Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {
"location": "Beijing",
"temp_c": 22.5,
"condition": "Sunny",
}
mocker.patch("requests.Session.get", return_value=mock_response)
weather = client.get_current("Beijing")
assert weather.city == "Beijing"
assert weather.temperature == 22.5
assert weather.condition == "Sunny"
def test_get_current_http_error(self, client, mocker):
"""API 返回 HTTP 错误"""
mock_response = mocker.Mock()
mock_response.raise_for_status.side_effect = requests.HTTPError("401 Unauthorized")
mocker.patch("requests.Session.get", return_value=mock_response)
with pytest.raises(requests.HTTPError):
client.get_current("Beijing")
def test_get_current_network_error(self, client, mocker):
"""网络连接失败"""
mocker.patch(
"requests.Session.get",
side_effect=requests.ConnectionError("连接超时")
)
with pytest.raises(requests.ConnectionError):
client.get_current("Beijing")
测试文件组织
良好的测试结构让项目更易维护:
project/
├── src/
│ ├── calculator.py
│ ├── file_ops.py
│ └── weather_client.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # 共享 fixture
│ ├── test_calculator.py
│ ├── test_file_ops.py
│ └── test_weather_client.py
├── pyproject.toml
└── pytest.ini
conftest.py 是 pytest 的配置文件,其中的 fixture 可以被同目录及子目录下所有测试共享:
# tests/conftest.py
import pytest
@pytest.fixture(scope="session")
def project_root():
"""返回项目根目录"""
from pathlib import Path
return Path(__file__).parent.parent
@pytest.fixture
def temp_data_dir(tmp_path):
"""创建一个测试数据目录"""
data_dir = tmp_path / "data"
data_dir.mkdir()
return data_dir
pytest.ini 可以配置 pytest 的行为:
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
markers =
slow: 慢速测试(默认跳过)
integration: 需要外部服务的集成测试
addopts = -v --tb=short --strict-markers
测试最佳实践
1. 每个测试只测一件事
# 坏:一个测试检查多个行为
def test_user_operations(bad):
user = create_user("Alice")
assert user.name == "Alice"
user.set_age(30)
assert user.age == 30
# 如果 set_age 失败,你也不知道前面的 create_user 有没有问题
# 好:每个测试聚焦一个场景
def test_create_user():
user = create_user("Alice")
assert user.name == "Alice"
def test_set_user_age():
user = create_user("Alice")
user.set_age(30)
assert user.age == 30
2. 测试边界条件
def test_divide_by_zero(): ...
def test_empty_input(): ...
def test_very_large_number(): ...
def test_negative_values(): ...
def test_special_characters(): ...
3. 测试命名要有描述性
# 坏
def test_calc(): ...
# 好
def test_calculator_divide_by_zero_raises_error(): ...
def test_calculator_add_two_positive_numbers(): ...
def test_weather_client_handles_network_timeout(): ...
4. 使用 TDD(测试驱动开发)
红-绿-重构循环:
1. 先写一个会失败的测试(红)
2. 写最少的代码让它通过(绿)
3. 重构代码,保持测试通过(重构)
# 1. 先写测试(会失败)
def test_validate_email():
assert validate_email("user@example.com") is True
assert validate_email("invalid") is False
# 2. 写实现
def validate_email(email: str) -> bool:
return "@" in email and "." in email.split("@")[1]
# 3. 重构——完善正则
import re
def validate_email(email: str) -> bool:
pattern = r"^[\w\.-]+@[\w\.-]+\.\w+$"
return bool(re.match(pattern, email))
小结
本章我们学习了 Python 测试的核心知识:
- 测试的价值:回归防护、活的文档、更好的设计、重构信心
- unittest:标准库内置,使用
TestCase类,setUp/tearDown管理生命周期,丰富的断言方法 - pytest:更简洁的语法、
assert替代断言方法、详细的失败信息、强大的 fixture 系统、参数化测试 - Fixture:
scope控制生命周期,autouse自动注入,yieldfixture 实现清理逻辑 - Mock:用
pytest-mock或monkeypatch模拟外部依赖,让测试不依赖网络和环境 - 覆盖率:
pytest-cov衡量测试覆盖了多少代码 - 实战:完整测试了 Calculator 类、文件 I/O 函数和 API 客户端
记住:测试不是写在最后的事后想法——它是代码的一部分,和生产代码一样重要。从今天开始,为你的每个新函数写测试吧。
Summary: unittest 与 pytest 测试框架、fixture、mock、覆盖率和实战。