Pytest
简介
Pytest 是一个功能强大且易于使用的 Python 测试框架,支持简单的单元测试到复杂的功能测试。它具有以下特点:
- 简洁的测试语法,使用
assert语句即可 - 强大的 Fixture 系统用于测试依赖管理
- 丰富的插件生态系统
- 详细的测试报告和失败信息
- 支持参数化测试、标记测试、并行测试等高级特性
- 兼容 unittest 和 nose 测试
快速开始
安装
# 基础安装
pip install pytest
# 安装常用插件
pip install pytest-cov pytest-xdist pytest-mock pytest-asyncio
第一个测试
创建测试文件 test_sample.py:
# test_sample.py
def test_addition():
assert 1 + 1 == 2
def test_string_concatenation():
assert "hello" + " " + "world" == "hello world"
def test_division():
result = 10 / 2
assert result == 5.0
assert isinstance(result, float)
运行测试:
# 运行所有测试
pytest
# 运行指定文件
pytest test_sample.py
# 运行指定测试函数
pytest test_sample.py::test_addition
# 显示详细输出
pytest -v
# 显示打印输出
pytest -s
测试发现
命名规则
Pytest 自动发现符合以下规则的测试:
# 文件命名规则
# test_*.py
# *_test.py
# 测试函数/方法命名规则
# test_*
# 示例文件结构
tests/
├── test_user.py # 会被发现
├── auth_test.py # 会被发现
├── helper.py # 不会被发现
└── conftest.py # 特殊文件,fixture 定义
测试收集
# test_collection.py
class TestUser:
"""测试类:以 Test 开头的类会被自动发现"""
def test_create_user(self):
"""测试方法:以 test_ 开头"""
assert True
def normal_method(self):
"""普通方法:不会被当作测试"""
pass
def test_login():
"""独立测试函数"""
assert True
自定义测试收集规则:
# 只收集特定模式文件
pytest -k "test_"
# 排除特定目录
pytest --ignore=tests/integration/
# 只收集特定目录
pytest tests/unit/
断言
assert语句
Pytest 使用 Python 原生的 assert 语句,无需导入特殊断言方法:
# test_assertions.py
def test_equality():
assert 1 == 1
assert "hello" == "hello"
def test_comparison():
assert 5 > 3
assert 10 >= 10
assert 2 < 4
def test_membership():
assert 3 in [1, 2, 3, 4]
assert "key" in {"key": "value"}
def test_identity():
obj = object()
assert obj is obj
def test_boolean():
assert bool(1) is True
assert bool(0) is False
assert not None
详细断言信息:
def test_dict_comparison():
expected = {
"name": "Alice",
"age": 30,
"city": "New York"
}
actual = {
"name": "Alice",
"age": 25, # 不同
"city": "New York"
}
assert expected == actual
# Pytest 会显示详细的差异信息
异常断言
使用 pytest.raises 测试异常:
import pytest
def test_zero_division():
with pytest.raises(ZeroDivisionError):
1 / 0
def test_key_error():
with pytest.raises(KeyError):
{}["missing_key"]
def test_exception_message():
with pytest.raises(ValueError, match="must be positive"):
validate_age(-1)
def validate_age(age):
if age < 0:
raise ValueError("Age must be positive")
return age
def test_exception_attributes():
with pytest.raises(ValueError) as exc_info:
validate_age(-1)
assert exc_info.type is ValueError
assert str(exc_info.value) == "Age must be positive"
assert "positive" in str(exc_info.value)
警告断言
import warnings
def test_warning():
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
# 触发警告
warnings.warn("This is a warning", UserWarning)
assert len(w) == 1
assert issubclass(w[0].category, UserWarning)
assert "warning" in str(w[0].message)
# 使用 pytest.warns
def test_pytest_warns():
with pytest.warns(UserWarning, match="warning"):
warnings.warn("This is a warning", UserWarning)
def test_no_warning():
with pytest.warns(None) as warning_list:
# 不应该产生任何警告
pass
Fixture
定义Fixture
Fixture 是 Pytest 的依赖注入机制,用于提供测试所需的测试数据、对象或资源:
import pytest
# 简单 fixture
@pytest.fixture
def sample_data():
return [1, 2, 3, 4, 5]
def test_sum(sample_data):
assert sum(sample_data) == 15
# yield fixture(用于资源清理)
@pytest.fixture
def database():
# setup
db = {"users": []}
print("\nSetup database")
yield db
# teardown
print("\nTeardown database")
db.clear()
def test_database(database):
database["users"].append("Alice")
assert len(database["users"]) == 1
# 带参数的 fixture
@pytest.fixture
def user():
class User:
def __init__(self, name, email):
self.name = name
self.email = email
return User("Bob", "bob@example.com")
def test_user_email(user):
assert "@" in user.email
assert user.name == "Bob"
作用域
Fixture 支持不同的作用域,控制其生命周期:
import pytest
# function 作用域(默认):每个测试函数都重新创建
@pytest.fixture(scope="function")
def function_resource():
print("\nFunction scope setup")
yield 1
print("\nFunction scope teardown")
# class 作用域:每个测试类创建一次
@pytest.fixture(scope="class")
def class_resource():
print("\nClass scope setup")
yield 2
print("\nClass scope teardown")
# module 作用域:每个模块创建一次
@pytest.fixture(scope="module")
def module_resource():
print("\nModule scope setup")
yield 3
print("\nModule scope teardown")
# session 作用域:整个测试会话创建一次
@pytest.fixture(scope="session")
def session_resource():
print("\nSession scope setup")
yield 4
print("\nSession scope teardown")
class TestFixtureScopes:
def test_one(self, function_resource, class_resource, module_resource):
assert function_resource == 1
def test_two(self, function_resource, class_resource, module_resource):
assert function_resource == 1
参数化
对 fixture 进行参数化:
import pytest
@pytest.fixture(params=[1, 2, 3])
def number(request):
return request.param
def test_numbers(number):
# 这个测试会运行3次,分别使用 1, 2, 3
assert number > 0
# 多参数组合
@pytest.fixture(params=["alice", "bob", "charlie"])
def username(request):
return request.param
@pytest.fixture(params=["reader", "writer", "admin"])
def role(request):
return request.param
def test_user_roles(username, role):
# 会运行 3x3=9 次,所有组合
assert isinstance(username, str)
assert isinstance(role, str)
conftest.py
conftest.py 用于共享 fixture 和配置:
# conftest.py
import pytest
import tempfile
import os
# 全局可用的 fixture
@pytest.fixture(scope="session")
def global_config():
return {
"database_url": "sqlite:///:memory:",
"debug": True
}
# 自动使用的 fixture
@pytest.fixture(autouse=True)
def setup_test_environment():
"""每个测试自动使用"""
print("\n=== Test started ===")
yield
print("\n=== Test finished ===")
# 临时目录 fixture
@pytest.fixture(scope="function")
def temp_dir():
"""创建临时目录"""
with tempfile.TemporaryDirectory() as tmpdir:
old_cwd = os.getcwd()
os.chdir(tmpdir)
yield tmpdir
os.chdir(old_cwd)
# 测试数据 fixture
@pytest.fixture
def sample_users():
return [
{"id": 1, "name": "Alice", "role": "admin"},
{"id": 2, "name": "Bob", "role": "user"},
{"id": 3, "name": "Charlie", "role": "user"},
]
在其他测试文件中使用:
# test_user_service.py
def test_user_count(sample_users):
assert len(sample_users) == 3
def test_admin_exists(sample_users):
admins = [u for u in sample_users if u["role"] == "admin"]
assert len(admins) == 1
参数化
单个参数
使用 @pytest.mark.parametrize 进行参数化:
import pytest
@pytest.mark.parametrize("input,expected", [
(1, 2),
(2, 4),
(3, 6),
(4, 8),
])
def test_multiply_by_two(input, expected):
assert input * 2 == expected
# 单个参数列表
@pytest.mark.parametrize("number", [1, 2, 3, 4, 5])
def test_is_positive(number):
assert number > 0
# 字符串参数化
@pytest.mark.parametrize("name", ["Alice", "Bob", "Charlie"])
def test_greeting(name):
assert f"Hello, {name}" == f"Hello, {name}"
多个参数
@pytest.mark.parametrize("x,y,expected", [
(1, 2, 3),
(2, 3, 5),
(10, -5, 5),
(0, 0, 0),
])
def test_addition(x, y, expected):
assert x + y == expected
# 参数化测试类
@pytest.mark.parametrize("a,b", [
(1, 2),
(3, 4),
])
class TestMath:
def test_add(self, a, b):
assert a + b > 0
def test_multiply(self, a, b):
assert a * b >= 0
参数组合
使用多个参数化标记产生笛卡尔积:
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_combinations(x, y):
# 会运行 4 次:(1,10), (1,20), (2,10), (2,20)
assert x + y > 0
# 条件参数化
@pytest.mark.parametrize("x", [1, 2, 3, None])
def test_not_none(x):
if x is None:
pytest.skip("Skip None values")
assert x is not None
# 参数 IDs
@pytest.mark.parametrize("input,expected", [
(2, True),
(3, True),
(4, False),
(5, False),
], ids=["prime_2", "prime_3", "composite_4", "composite_5"])
def test_is_prime(input, expected):
"""测试会在报告中显示自定义 ID"""
assert is_prime(input) == expected
def is_prime(n):
if n < 2:
return False
for i in range(2, int(n**0.5) + 1):
if n % i == 0:
return False
return True
测试组织
测试类
使用测试类组织相关测试:
class TestUserService:
"""用户服务测试类"""
@pytest.fixture(autouse=True)
def setup(self):
"""每个测试方法前执行"""
self.service = UserService()
yield
self.service = None
def test_create_user(self):
user = self.service.create_user("Alice", "alice@example.com")
assert user.name == "Alice"
def test_delete_user(self):
user = self.service.create_user("Bob", "bob@example.com")
result = self.service.delete_user(user.id)
assert result is True
def test_find_nonexistent_user(self):
with pytest.raises(UserNotFoundError):
self.service.find_user(999)
class TestAuthService:
"""认证服务测试类"""
def test_login_success(self):
assert True
def test_login_failure(self):
assert True
测试包
按目录结构组织测试:
tests/
├── conftest.py # 共享 fixtures
├── unit/
│ ├── conftest.py # 单元测试 fixtures
│ ├── test_user.py
│ └── test_auth.py
├── integration/
│ ├── conftest.py # 集成测试 fixtures
│ └── test_api.py
└── e2e/
├── conftest.py # 端到端测试 fixtures
└── test_workflow.py
# tests/conftest.py
@pytest.fixture(scope="session")
def test_database():
"""全局测试数据库"""
db = create_test_database()
yield db
db.close()
# tests/unit/conftest.py
@pytest.fixture
def mock_db():
"""单元测试使用 mock 数据库"""
return MockDatabase()
# tests/integration/conftest.py
@pytest.fixture
def real_db(test_database):
"""集成测试使用真实测试数据库"""
return test_database
标记测试
使用标记对测试进行分类:
import pytest
# 定义标记
pytestmark = [
pytest.mark.version("2.0"),
]
# 单个标记
@pytest.mark.unit
def test_simple_function():
assert 1 + 1 == 2
# 多个标记
@pytest.mark.unit
@pytest.mark.math
def test_calculate():
assert 2 * 3 == 6
# 自定义标记
@pytest.mark.slow
def test_long_running():
import time
time.sleep(5)
assert True
@pytest.mark.integration
def test_database_integration():
assert True
@pytest.markSmoke
def test_smoke():
assert True
# 跳过标记
@pytest.mark.skip(reason="Feature not implemented yet")
def test_future_feature():
assert True
# 在类上使用标记
@pytest.mark.web
class TestWebAPI:
def test_get(self):
assert True
def test_post(self):
assert True
在 pytest.ini 中配置标记:
[pytest]
markers =
unit: Unit tests
integration: Integration tests
slow: Slow running tests
web: Web API tests
Smoke: Smoke tests
运行特定标记的测试:
# 只运行 unit 测试
pytest -m unit
# 运行多个标记
pytest -m "unit or integration"
# 排除标记
pytest -m "not slow"
# 标记组合
pytest -m "unit and not slow"
跳过与失败
skip
无条件跳过测试:
import pytest
@pytest.mark.skip(reason="暂时跳过此测试")
def test_something():
assert True
def test_another():
pytest.skip("跳过这个测试")
# 条件跳过
def test_conditional_skip():
if not import_module("optional_feature"):
pytest.skip("需要 optional_feature 模块")
assert True
# 跳过整个类
@pytest.mark.skip(reason="等待修复")
class TestBrokenFeature:
def test_one(self):
assert True
def test_two(self):
assert True
skipif
条件性跳过:
import sys
import pytest
@pytest.mark.skipif(sys.version_info < (3, 8),
reason="需要 Python 3.8+")
def test_new_feature():
assert True
@pytest.mark.skipif(sys.platform == "win32",
reason="Unix 系统测试")
def test_unix_only():
assert True
# 多个条件
@pytest.mark.skipif(
(sys.version_info < (3, 8)) or (sys.platform == "win32"),
reason="需要 Python 3.8+ 且非 Windows"
)
def test_combination():
assert True
# 使用 fixture 条件
@pytest.fixture
def skip_if_no_db():
if not database_available():
pytest.skip("数据库不可用")
def test_database(skip_if_no_db):
assert True
xfail
预期失败的测试:
@pytest.mark.xfail
def test_expected_failure():
"""预期这个测试会失败"""
assert 1 + 1 == 3
@pytest.mark.xfail(reason="Bug #123 已知问题")
def test_known_bug():
"""已知 Bug,暂时不修复"""
assert False
# 条件性 xfail
@pytest.mark.xfail(sys.version_info < (3, 9),
reason="Python 3.9 之前有 bug")
def test_version_specific():
assert True
# 严格模式(必须失败,否则算失败)
@pytest.mark.xfail(strict=True, reason="必须失败")
def test_must_fail():
assert True # 这会被标记为失败
# 在测试中动态 xfail
def test_runtime_xfail():
if some_condition():
pytest.xfail("条件不满足,预期失败")
assert True
# xfail 但不运行
@pytest.mark.xfail(run=False)
def test_not_run():
assert False
Mock与Patch
unittest.mock
使用标准库的 mock 功能:
from unittest.mock import Mock, MagicMock, patch
def test_mock_basic():
"""基础 Mock 使用"""
mock = Mock()
mock.method()
mock.method.assert_called_once()
def test_mock_return_value():
"""设置返回值"""
mock = Mock()
mock.method.return_value = 42
result = mock.method()
assert result == 42
mock.method.assert_called_once()
def test_mock_side_effect():
"""副作用:每次调用返回不同值"""
mock = Mock()
mock.method.side_effect = [1, 2, 3]
assert mock.method() == 1
assert mock.method() == 2
assert mock.method() == 3
def test_mock_exception():
"""抛出异常"""
mock = Mock()
mock.method.side_effect = ValueError("Error message")
with pytest.raises(ValueError, match="Error message"):
mock.method()
def test_mock_attributes():
"""Mock 属性"""
mock = Mock()
mock.name = "Alice"
mock.age = 30
assert mock.name == "Alice"
assert mock.age == 30
mocker
pytest-mock 插件提供的 mocker fixture:
import pytest
def test_mocker_basic(mocker):
"""使用 mocker fixture"""
mock_func = mocker.Mock()
mock_func.return_value = 42
assert mock_func() == 42
mock_func.assert_called_once()
def test_mocker_patch(mocker):
"""Patch 对象"""
mocker.patch("module.function", return_value="mocked")
from module import function
assert function() == "mocked"
def test_mocker_patch_class(mocker):
"""Patch 类"""
MockClass = mocker.patch("module.MyClass")
instance = MockClass.return_value
instance.method.return_value = "result"
from module import MyClass
obj = MyClass()
assert obj.method() == "result"
def test_mocker_spy(mocker):
"""Spy 函数:保留原函数但监控调用"""
def original_func(x):
return x * 2
spy = mocker.spy(original_func)
result = original_func(5)
assert result == 10
spy.assert_called_once_with(5)
Patch
使用 patch 装饰器或上下文管理器:
from unittest.mock import patch
# 装饰器方式
@patch("module.external_api_call")
def test_with_decorator(mock_api):
mock_api.return_value = {"status": "ok"}
result = call_external_api()
assert result == {"status": "ok"}
mock_api.assert_called_once()
# 多个 patch
@patch("module.database")
@patch("module.cache")
def test_multiple_patches(mock_cache, mock_db):
mock_db.query.return_value = [1, 2, 3]
mock_cache.get.return_value = None
result = get_data()
assert len(result) == 3
# 上下文管理器方式
def test_with_context_manager():
with patch("module.time consuming_operation") as mock_op:
mock_op.return_value = "fast result"
result = time_consuming_operation()
assert result == "fast result"
# Patch 对象方法
class TestService:
@patch("object.requests.get")
def test_api_call(self, mock_get):
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": "value"}
mock_get.return_value = mock_response
service = TestService()
data = service.fetch_data()
assert data == {"data": "value"}
mock_get.assert_called_once()
测试覆盖率
pytest-cov
安装和配置:
pip install pytest-cov
# 生成覆盖率报告
pytest --cov=my_module tests/
# 生成终端报告
pytest --cov=my_module --cov-report=term
# 生成 HTML 报告
pytest --cov=my_module --cov-report=html
# 生成 XML 报告(用于 CI)
pytest --cov=my_module --cov-report=xml
# 组合多种报告
pytest --cov=my_module --cov-report=term-missing --cov-report=html
# 设置覆盖率阈值(低于阈值失败)
pytest --cov=my_module --cov-fail-under=80
覆盖率报告
# pytest.ini 或 setup.cfg 配置
[tool:pytest]
addopts = --cov=src --cov-report=term-missing --cov-report=html --cov-fail-under=80
[coverage:run]
source = src
omit =
*/tests/*
*/test_*.py
*/__init__.py
*/migrations/*
[coverage:report]
precision = 2
show_missing = True
skip_covered = False
[coverage:html]
directory = htmlcov
分支覆盖率:
# 启用分支覆盖率
pytest --cov=my_module --cov-branch
按模块查看覆盖率:
# .coveragerc
[coverage:run]
branch = True
source =
my_app
my_utils
[coverage:report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
if TYPE_CHECKING:
并行测试
pytest-xdist
安装和基础使用:
pip install pytest-xdist
# 使用所有 CPU 核心
pytest -n auto
# 指定进程数
pytest -n 4
# 分布式测试(多台机器)
pytest -dist --tx 3*popen//id=worker1 --tx 3*popen//id=worker2
# 确保 fixture 线程安全
@pytest.fixture(scope="session")
def session_data():
"""session 作用域在并行测试时只创建一次"""
return {"counter": 0}
@pytest.fixture
def isolated_data():
"""每个测试独立的数据"""
return {"value": 42}
插件生态
常用插件
# 异步测试
pip install pytest-asyncio
# Django 测试
pip install pytest-django
# Flask 测试
pip install pytest-flask
# 临时目录和文件
pip install pytest-tmpdir
# 测试时间排序
pip install pytest-testmon
# 随机化测试顺序
pip install pytest-randomly
# 性能测试
pip install pytest-benchmark
# HTML 报告
pip install pytest-html
# 重试失败的测试
pip install pytest-rerunfailures
自定义插件
创建自定义 pytest 插件:
# my_pytest_plugin.py
import pytest
def pytest_configure(config):
"""pytest 配置时调用"""
config.addinivalue_line(
"markers", "env(name): mark test to run only on named environment"
)
def pytest_collection_modifyitems(config, items):
"""修改收集到的测试项"""
for item in items:
# 自动添加标记
if "slow" in item.nodeid:
item.add_marker(pytest.mark.slow)
@pytest.fixture
def custom_fixture():
"""自定义 fixture"""
return "custom value"
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""增强测试报告"""
outcome = yield
report = outcome.get_result()
# 添加自定义信息
if report.when == "call":
report.custom_info = "Additional test info"
def pytest_terminal_summary(terminalreporter, exitstatus, config):
"""添加终端摘要信息"""
terminalreporter.write_sep("=", "Custom Summary Line")
注册插件:
# setup.py 或 pyproject.toml
setup(
name="my-pytest-plugin",
entry_points={
"pytest11": [
"my_plugin = my_pytest_plugin",
],
},
)
或在 conftest.py 中启用:
# conftest.py
pytest_plugins = ["my_pytest_plugin"]
测试配置
pytest.ini
配置文件示例:
# pytest.ini
[pytest]
# 测试发现路径
testpaths = tests
# 测试文件模式
python_files = test_*.py *_test.py
# 测试类模式
python_classes = Test*
# 测试函数模式
python_functions = test_*
# 默认命令行选项
addopts =
-v
--strict-markers
--disable-warnings
--cov=src
--cov-report=term-missing
--cov-report=html
# 最小版本
minversion = 7.0
# 标记定义
markers =
unit: 单元测试
integration: 集成测试
slow: 慢速测试
web: Web API 测试
Smoke: 冒烟测试
# 日志配置
log_cli = true
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)8s] %(message)s
log_cli_date_format = %Y-%m-%d %H:%M:%S
# 警告设置
filterwarnings =
error
ignore::UserWarning
ignore::DeprecationWarning
命令行选项
常用命令行选项:
# 显示详细输出
pytest -v
# 显示极简输出
pytest -q
# 显示打印输出
pytest -s
# 显示本地变量
pytest -l
# 只运行失败的测试
pytest --lf
# 先运行失败的测试
pytest --ff
# 停止在第 N 个失败
pytest -x # 第一个失败后停止
pytest --maxfail=3 # 3 个失败后停止
# 捕获输出
pytest --capture=no # 不捕获输出
pytest --capture=sys # 捕获到 sys.stdout/stderr
# 显示最慢的 10 个测试
pytest --durations=10
# 显示 fixture 用法
pytest --fixtures
# 显示标记
pytest --markers
# PDB 调试
pytest --pdb # 失败时进入 PDB
pytest --trace # 每个测试前进入 PDB
# 随机化测试顺序
pytest --random-order
# 重复运行测试
pytest --count=5
# 并行运行
pytest -n 4
最佳实践
1. 测试命名和组织
# ✅ 好的命名
def test_user_can_login_with_valid_credentials():
pass
def test_user_cannot_login_with_invalid_password():
pass
# ❌ 不好的命名
def test_login():
pass
def test_login2():
pass
2. Fixture 设计
# ✅ 好的设计:职责单一
@pytest.fixture
def clean_database():
"""干净的数据库"""
db = create_database()
db.clear()
yield db
db.close()
@pytest.fixture
def sample_user():
"""测试用户"""
return User(name="Alice", email="alice@example.com")
# ❌ 不好的设计:职责混乱
@pytest.fixture
def messy_fixture():
db = create_database()
user = User("Alice")
client = APIClient()
yield db, user, client
# 清理逻辑混乱
3. 避免测试依赖
# ✅ 好的做法:测试独立
class TestUserCreation:
def test_create_user_with_valid_data(self):
user = create_user("Alice", "alice@example.com")
assert user.name == "Alice"
def test_create_user_with_duplicate_email(self):
with pytest.raises(DuplicateEmailError):
create_user("Alice", "alice@example.com")
create_user("Bob", "alice@example.com")
# ❌ 不好的做法:测试依赖顺序
class TestUserWorkflow:
def test_1_create_user(self):
self.user = create_user("Alice")
def test_2_update_user(self):
# 依赖 test_1_create_user 先执行
self.user.name = "Bob"
4. 使用标记组织测试
# 快速单元测试
@pytest.mark.unit
@pytest.mark.fast
def test_simple_calculation():
assert 1 + 1 == 2
# 慢速集成测试
@pytest.mark.integration
@pytest.mark.slow
def test_database_integration():
# 耗时操作
pass
5. Mock 的正确使用
# ✅ Mock 外部依赖
@patch("requests.get")
def test_api_call(mock_get):
mock_get.return_value.status_code = 200
response = call_external_api()
assert response.status_code == 200
# ❌ 不要 Mock 被测试的对象
@patch("module.my_function")
def test_my_function(mock_func):
# 错误:mock 了要测试的函数
result = my_function()
assert result == "mocked"
6. 参数化减少重复
# ✅ 使用参数化
@pytest.mark.parametrize("input,expected", [
("", True),
(" ", True),
("\t\n", True),
("text", False),
])
def test_is_blank(input, expected):
assert is_blank(input) == expected
# ❌ 重复的测试
def test_is_blank_empty_string():
assert is_blank("") == True
def test_is_blank_whitespace():
assert is_blank(" ") == True
def test_is_blank_text():
assert is_blank("text") == False
7. 清晰的断言消息
# ✅ 自定义断言消息
def test_user_age():
user = User(age=15)
assert user.is_adult() is False, "15 岁用户应该是未成年人"
# ❌ 不清晰的断言
def test_user_age():
user = User(age=15)
assert user.is_adult() is False
8. 测试数据管理
# ✅ 使用 fixtures 管理测试数据
@pytest.fixture
def user_data():
return {
"valid": {
"name": "Alice",
"email": "alice@example.com",
"age": 25,
},
"invalid": {
"name": "",
"email": "invalid-email",
"age": -1,
}
}
def test_create_user_with_valid_data(user_data):
user = User(**user_data["valid"])
assert user.is_valid()
# ❌ 硬编码测试数据
def test_create_user_with_valid_data():
user = User(name="Alice", email="alice@example.com", age=25)
assert user.is_valid()
CI/CD集成
GitHub Actions
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8, 3.9, "3.10", "3.11"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest pytest-cov pytest-xdist
pip install -r requirements.txt
- name: Run tests
run: |
pytest --cov=src --cov-report=xml --cov-report=term
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
flags: unittests
name: codecov-umbrella
GitLab CI
# .gitlab-ci.yml
test:
image: python:3.10
parallel:
matrix:
- PYTHON_VERSION: ["3.8", "3.9", "3.10", "3.11"]
before_script:
- python -m pip install --upgrade pip
- pip install pytest pytest-cov
- pip install -r requirements.txt
script:
- pytest --cov=src --cov-report=xml --cov-report=html
coverage: '/(?i)lines.*: \d+\.\d+%/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
Jenkins
// Jenkinsfile
pipeline {
agent any
stages {
stage('Setup') {
steps {
sh 'python -m pip install --upgrade pip'
sh 'pip install pytest pytest-cov'
sh 'pip install -r requirements.txt'
}
}
stage('Test') {
steps {
sh 'pytest --cov=src --cov-report=xml --cov-report=term --junitxml=pytest-report.xml'
}
}
stage('Report') {
steps {
junit 'pytest-report.xml'
publishCoverage adapters: [coberturaAdapter('coverage.xml')]
}
}
}
post {
always {
archiveArtifacts artifacts: 'htmlcov/**/*'
}
}
}
Docker Compose
# docker-compose.yml
version: '3.8'
services:
app:
build: .
volumes:
- .:/app
command: pytest --cov=src
test:
build: .
volumes:
- .:/app
command: >
sh -c "
pytest --cov=src --cov-report=xml --cov-report=term &&
pytest --html=report.html --self-contained-html
"
预提交钩子:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pytest-dev/pytest
rev: 7.1.2
hooks:
- id: pytest
args: [--cov=src, --cov-fail-under=80]
通过 Pytest,你可以构建完整、可靠、易维护的测试套件,确保代码质量和系统稳定性。