pytest 完整学习指南
目录
介绍
什么是 pytest
pytest 是一个成熟的、功能丰富的 Python 测试框架,它使编写小型测试变得简单,同时也能扩展以支持应用程序和库的复杂功能测试。
主要特性
- 简单易用:编写测试用例非常简单,只需要使用 assert 语句
- 自动发现:自动发现测试文件和测试函数
- 丰富的断言:提供详细的断言失败信息
- Fixture 系统:强大的测试夹具系统,支持依赖注入
- 参数化测试:轻松创建数据驱动的测试
- 插件生态:丰富的插件生态系统
- 并行执行:支持并行运行测试
- 详细报告:支持多种格式的测试报告
与其他测试框架对比
特性 | pytest | unittest | nose2 |
---|---|---|---|
学习曲线 | 简单 | 中等 | 中等 |
断言方式 | assert 语句 | self.assert* | assert 语句 |
Fixture 支持 | 优秀 | 基础 | 中等 |
插件生态 | 丰富 | 有限 | 中等 |
参数化 | 内置支持 | 需要额外工作 | 支持 |
报告功能 | 丰富 | 基础 | 中等 |
适用场景
- 单元测试:测试单个函数或类的功能
- 集成测试:测试多个组件之间的交互
- 功能测试:测试完整的功能流程
- API 测试:测试 REST API 接口
- Web 测试:结合 Selenium 进行 Web 自动化测试
- 性能测试:结合相关插件进行性能测试
安装和环境配置
基础安装
# 安装 pytest
pip install pytest
# 验证安装
pytest --version
完整安装(包含常用插件)
# 安装 pytest 及常用插件
pip install pytest pytest-html pytest-cov pytest-xdist allure-pytest
# 或使用 requirements.txt
cat > requirements.txt << EOF
pytest>=7.0.0
pytest-html>=3.1.0
pytest-cov>=4.0.0
pytest-xdist>=3.0.0
allure-pytest>=2.12.0
requests>=2.28.0
selenium>=4.0.0
EOF
pip install -r requirements.txt
开发环境配置
# 创建项目目录结构
mkdir pytest-demo
cd pytest-demo
# 创建基本目录结构
mkdir -p {src,tests,reports,config}
touch {src,tests}/__init__.py
# 创建配置文件
touch pytest.ini
touch conftest.py
IDE 配置
PyCharm 配置
-
设置测试运行器:
- File → Settings → Tools → Python Integrated Tools
- 设置 Default test runner 为 pytest
-
配置测试模板:
import pytest def test_${NAME}(): # TODO: 实现测试逻辑 assert True
VS Code 配置
// .vscode/settings.json
{
"python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false,
"python.testing.pytestArgs": [
"tests"
],
"python.testing.autoTestDiscoverOnSaveEnabled": true
}
项目结构示例
pytest-demo/
├── src/ # 源代码目录
│ ├── __init__.py
│ ├── calculator.py # 示例业务代码
│ └── utils.py
├── tests/ # 测试代码目录
│ ├── __init__.py
│ ├── conftest.py # pytest 配置文件
│ ├── test_calculator.py # 测试文件
│ └── test_utils.py
├── reports/ # 测试报告目录
├── config/ # 配置文件目录
├── pytest.ini # pytest 配置文件
├── requirements.txt # 依赖文件
└── README.md
pytest 基础
第一个测试
让我们从一个简单的例子开始:
创建被测试的代码
# src/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, base, exponent):
"""幂运算"""
return base ** exponent
创建测试文件
# tests/test_calculator.py
import pytest
from src.calculator import Calculator
def test_add():
"""测试加法功能"""
calc = Calculator()
result = calc.add(2, 3)
assert result == 5
def test_subtract():
"""测试减法功能"""
calc = Calculator()
result = calc.subtract(5, 3)
assert result == 2
def test_multiply():
"""测试乘法功能"""
calc = Calculator()
result = calc.multiply(4, 3)
assert result == 12
def test_divide():
"""测试除法功能"""
calc = Calculator()
result = calc.divide(10, 2)
assert result == 5.0
def test_divide_by_zero():
"""测试除零异常"""
calc = Calculator()
with pytest.raises(ValueError, match="除数不能为零"):
calc.divide(10, 0)
运行测试
# 运行所有测试
pytest
# 运行特定文件
pytest tests/test_calculator.py
# 运行特定测试函数
pytest tests/test_calculator.py::test_add
# 显示详细输出
pytest -v
# 显示测试覆盖率
pytest --cov=src
测试发现机制
pytest 使用以下规则自动发现测试:
测试文件发现规则
- 文件名以
test_
开头或以_test.py
结尾 - 在
tests/
目录下的 Python 文件
# 有效的测试文件名
test_calculator.py
test_user_management.py
calculator_test.py
user_management_test.py
测试函数发现规则
- 函数名以
test_
开头 - 在测试类中,方法名以
test_
开头
# 有效的测试函数
def test_addition():
pass
def test_user_login():
pass
class TestCalculator:
def test_add(self):
pass
def test_subtract(self):
pass
测试类发现规则
- 类名以
Test
开头 - 不能有
__init__
方法
class TestCalculator:
"""计算器测试类"""
def test_basic_operations(self):
pass
class TestUserAuth:
"""用户认证测试类"""
def test_login(self):
pass
def test_logout(self):
pass
测试执行
基本执行命令
# 运行当前目录下所有测试
pytest
# 运行指定目录
pytest tests/
# 运行指定文件
pytest tests/test_calculator.py
# 运行指定测试函数
pytest tests/test_calculator.py::test_add
# 运行指定测试类
pytest tests/test_calculator.py::TestCalculator
# 运行指定测试类的方法
pytest tests/test_calculator.py::TestCalculator::test_add
输出控制
# 详细输出
pytest -v
# 简洁输出
pytest -q
# 显示本地变量
pytest -l
# 显示测试执行时间最长的10个测试
pytest --durations=10
# 只显示失败的测试
pytest --tb=short
# 在第一个失败后停止
pytest -x
# 在N个失败后停止
pytest --maxfail=2
测试选择
# 按关键字选择测试
pytest -k "add or subtract"
# 按标记选择测试
pytest -m "slow"
# 排除特定标记
pytest -m "not slow"
# 运行失败的测试
pytest --lf
# 运行失败和新增的测试
pytest --ff
并行执行
# 安装 pytest-xdist
pip install pytest-xdist
# 使用多个CPU核心并行执行
pytest -n auto
# 指定进程数
pytest -n 4
# 按测试文件分发
pytest --dist=loadfile
测试输出示例
$ pytest -v tests/test_calculator.py
========================= test session starts =========================
platform linux -- Python 3.9.0, pytest-7.2.0, pluggy-1.0.0
cachedir: .pytest_cache
rootdir: /home/user/pytest-demo
collected 5 items
tests/test_calculator.py::test_add PASSED [ 20%]
tests/test_calculator.py::test_subtract PASSED [ 40%]
tests/test_calculator.py::test_multiply PASSED [ 60%]
tests/test_calculator.py::test_divide PASSED [ 80%]
tests/test_calculator.py::test_divide_by_zero PASSED [100%]
========================== 5 passed in 0.02s ==========================
测试组织最佳实践
1. 使用测试类组织相关测试
# tests/test_calculator.py
import pytest
from src.calculator import Calculator
class TestBasicOperations:
"""基本运算测试"""
def setup_method(self):
"""每个测试方法执行前的设置"""
self.calc = Calculator()
def test_add_positive_numbers(self):
assert self.calc.add(2, 3) == 5
def test_add_negative_numbers(self):
assert self.calc.add(-2, -3) == -5
def test_add_zero(self):
assert self.calc.add(5, 0) == 5
class TestAdvancedOperations:
"""高级运算测试"""
def setup_method(self):
self.calc = Calculator()
def test_power_positive_exponent(self):
assert self.calc.power(2, 3) == 8
def test_power_zero_exponent(self):
assert self.calc.power(5, 0) == 1
class TestErrorHandling:
"""错误处理测试"""
def setup_method(self):
self.calc = Calculator()
def test_divide_by_zero_raises_error(self):
with pytest.raises(ValueError):
self.calc.divide(10, 0)
2. 使用描述性的测试名称
def test_add_two_positive_integers_returns_sum():
"""测试两个正整数相加返回正确的和"""
calc = Calculator()
result = calc.add(2, 3)
assert result == 5
def test_divide_by_zero_raises_value_error_with_message():
"""测试除零操作抛出带有正确消息的ValueError"""
calc = Calculator()
with pytest.raises(ValueError, match="除数不能为零"):
calc.divide(10, 0)
3. 使用文档字符串
def test_calculator_add_method():
"""
测试计算器的加法方法
Given: 一个计算器实例
When: 调用add方法传入两个数字
Then: 返回正确的和
"""
# Given
calc = Calculator()
# When
result = calc.add(2, 3)
# Then
assert result == 5
断言和异常处理
基本断言
pytest 使用 Python 的内置 assert
语句,并提供了丰富的断言失败信息。
基础断言示例
def test_basic_assertions():
"""基本断言示例"""
# 相等断言
assert 2 + 2 == 4
assert "hello" == "hello"
# 不等断言
assert 3 != 4
assert "hello" != "world"
# 布尔断言
assert True
assert not False
# 成员断言
assert 'a' in 'abc'
assert 'x' not in 'abc'
# 比较断言
assert 5 > 3
assert 2 < 10
assert 5 >= 5
assert 3 <= 3
容器断言
def test_container_assertions():
"""容器断言示例"""
# 列表断言
my_list = [1, 2, 3, 4, 5]
assert len(my_list) == 5
assert 3 in my_list
assert my_list[0] == 1
assert my_list[-1] == 5
# 字典断言
my_dict = {'name': 'Alice', 'age': 30}
assert 'name' in my_dict
assert my_dict['name'] == 'Alice'
assert len(my_dict) == 2
# 集合断言
my_set = {1, 2, 3}
assert 2 in my_set
assert len(my_set) == 3
# 字符串断言
text = "Hello, World!"
assert text.startswith("Hello")
assert text.endswith("!")
assert "World" in text
类型断言
def test_type_assertions():
"""类型断言示例"""
# 类型检查
assert isinstance(42, int)
assert isinstance("hello", str)
assert isinstance([1, 2, 3], list)
assert isinstance({'a': 1}, dict)
# 类型不匹配
assert not isinstance("42", int)
assert not isinstance(42, str)
近似值断言
import pytest
def test_approximate_assertions():
"""近似值断言示例"""
# 浮点数比较
assert 0.1 + 0.2 == pytest.approx(0.3)
assert abs(0.1 + 0.2 - 0.3) < 1e-10
# 指定精度
assert 2.2 == pytest.approx(2.3, abs=0.1)
assert 100 == pytest.approx(99, rel=0.01)
# 列表近似比较
assert [0.1 + 0.2, 0.2 + 0.4] == pytest.approx([0.3, 0.6])
异常测试
基本异常测试
import pytest
def test_exception_handling():
"""异常处理测试"""
def divide(a, b):
if b == 0:
raise ValueError("Division by zero")
return a / b
# 测试异常是否被抛出
with pytest.raises(ValueError):
divide(10, 0)
# 测试异常消息
with pytest.raises(ValueError, match="Division by zero"):
divide(10, 0)
# 测试异常消息(正则表达式)
with pytest.raises(ValueError, match=r"Division.*zero"):
divide(10, 0)
捕获异常信息
def test_exception_info():
"""捕获异常详细信息"""
def custom_error():
raise ValueError("Custom error message")
# 捕获异常对象
with pytest.raises(ValueError) as exc_info:
custom_error()
# 检查异常类型
assert exc_info.type == ValueError
# 检查异常消息
assert str(exc_info.value) == "Custom error message"
# 检查异常参数
assert exc_info.value.args[0] == "Custom error message"
多种异常类型测试
def test_multiple_exceptions():
"""测试多种异常类型"""
def process_data(data):
if data is None:
raise ValueError("Data cannot be None")
if not isinstance(data, (list, tuple)):
raise TypeError("Data must be a list or tuple")
if len(data) == 0:
raise IndexError("Data cannot be empty")
return sum(data)
# 测试不同的异常
with pytest.raises(ValueError):
process_data(None)
with pytest.raises(TypeError):
process_data("invalid")
with pytest.raises(IndexError):
process_data([])
# 正常情况
assert process_data([1, 2, 3]) == 6
异常不应该被抛出
def test_no_exception():
"""测试不应该抛出异常的情况"""
def safe_divide(a, b):
try:
return a / b
except ZeroDivisionError:
return float('inf')
# 这些操作不应该抛出异常
result = safe_divide(10, 2)
assert result == 5.0
result = safe_divide(10, 0)
assert result == float('inf')
自定义断言
创建自定义断言函数
def assert_is_even(number):
"""自定义断言:检查数字是否为偶数"""
assert number % 2 == 0, f"Expected {number} to be even"
def assert_contains_substring(text, substring):
"""自定义断言:检查文本是否包含子字符串"""
assert substring in text, f"Expected '{text}' to contain '{substring}'"
def assert_list_sorted(lst, reverse=False):
"""自定义断言:检查列表是否已排序"""
sorted_list = sorted(lst, reverse=reverse)
assert lst == sorted_list, f"Expected {lst} to be sorted"
def test_custom_assertions():
"""使用自定义断言"""
assert_is_even(4)
assert_contains_substring("Hello World", "World")
assert_list_sorted([1, 2, 3, 4])
assert_list_sorted([4, 3, 2, 1], reverse=True)
创建断言辅助类
class AssertHelper:
"""断言辅助类"""
@staticmethod
def assert_response_ok(response):
"""断言HTTP响应成功"""
assert 200 <= response.status_code < 300, \
f"Expected successful response, got {response.status_code}"
@staticmethod
def assert_json_contains(json_data, expected_keys):
"""断言JSON包含指定键"""
for key in expected_keys:
assert key in json_data, f"Expected key '{key}' in JSON data"
@staticmethod
def assert_file_exists(file_path):
"""断言文件存在"""
import os
assert os.path.exists(file_path), f"Expected file {file_path} to exist"
def test_assert_helper():
"""使用断言辅助类"""
# 模拟响应对象
class MockResponse:
def __init__(self, status_code):
self.status_code = status_code
response = MockResponse(200)
AssertHelper.assert_response_ok(response)
json_data = {"name": "Alice", "age": 30}
AssertHelper.assert_json_contains(json_data, ["name", "age"])
断言重写和调试
断言失败信息
pytest 会自动提供详细的断言失败信息:
def test_assertion_failure_info():
"""演示断言失败信息"""
expected = [1, 2, 3, 4]
actual = [1, 2, 3, 5]
# 这个断言会失败,并显示详细的差异信息
# assert actual == expected
运行失败时的输出:
> assert actual == expected
E assert [1, 2, 3, 5] == [1, 2, 3, 4]
E At index 3 diff: 5 != 4
E Use -v to get the full diff
自定义断言消息
def test_custom_assertion_messages():
"""自定义断言消息"""
user_age = 15
min_age = 18
# 提供自定义错误消息
assert user_age >= min_age, \
f"User age {user_age} is below minimum required age {min_age}"
# 使用格式化字符串
username = "alice"
expected_format = r"^[a-zA-Z][a-zA-Z0-9_]*$"
import re
assert re.match(expected_format, username), \
f"Username '{username}' does not match required format {expected_format}"
测试跳过和标记
跳过测试
无条件跳过
import pytest
@pytest.mark.skip(reason="功能尚未实现")
def test_future_feature():
"""这个测试会被跳过"""
pass
def test_conditional_skip():
"""在测试内部跳过"""
if not has_network_connection():
pytest.skip("需要网络连接")
# 实际测试代码
response = requests.get("https://api.example.com")
assert response.status_code == 200
def has_network_connection():
"""检查网络连接的辅助函数"""
try:
import socket
socket.create_connection(("8.8.8.8", 53), timeout=3)
return True
except OSError:
return False
条件跳过
import sys
import pytest
# 基于操作系统跳过
@pytest.mark.skipif(sys.platform == "win32", reason="不支持Windows")
def test_unix_specific():
"""仅在Unix系统上运行的测试"""
import os
assert os.name == 'posix'
# 基于Python版本跳过
@pytest.mark.skipif(sys.version_info < (3, 8), reason="需要Python 3.8+")
def test_python38_feature():
"""需要Python 3.8+的功能测试"""
# 使用Python 3.8+的特性
pass
# 基于模块可用性跳过
numpy = pytest.importorskip("numpy")
def test_numpy_functionality():
"""需要numpy的测试"""
arr = numpy.array([1, 2, 3])
assert len(arr) == 3
# 基于自定义条件跳过
def is_database_available():
"""检查数据库是否可用"""
try:
import psycopg2
conn = psycopg2.connect(
host="localhost",
database="test",
user="test",
password="test"
)
conn.close()
return True
except:
return False
@pytest.mark.skipif(not is_database_available(), reason="数据库不可用")
def test_database_operations():
"""需要数据库的测试"""
pass
预期失败
标记预期失败的测试
import pytest
@pytest.mark.xfail(reason="已知的bug,等待修复")
def test_known_bug():
"""已知会失败的测试"""
assert 1 == 2 # 这个会失败,但被标记为预期失败
@pytest.mark.xfail(sys.platform == "win32", reason="Windows上的已知问题")
def test_windows_issue():
"""在Windows上预期失败的测试"""
pass
@pytest.mark.xfail(strict=True)
def test_strict_xfail():
"""严格模式的xfail,如果意外通过会报错"""
pass
# 条件性预期失败
@pytest.mark.xfail(
condition=sys.version_info < (3, 9),
reason="Python 3.9以下版本的已知问题"
)
def test_version_specific_issue():
"""特定版本的问题"""
pass
动态标记预期失败
def test_dynamic_xfail():
"""动态标记预期失败"""
if not has_required_dependency():
pytest.xfail("缺少必需的依赖")
# 测试代码
pass
def has_required_dependency():
"""检查是否有必需的依赖"""
try:
import some_optional_module
return True
except ImportError:
return False
自定义标记
创建自定义标记
import pytest
# 在 pytest.ini 中注册标记
# [tool:pytest]
# markers =
# slow: marks tests as slow
# integration: marks tests as integration tests
# unit: marks tests as unit tests
# smoke: marks tests as smoke tests
# api: marks tests as API tests
@pytest.mark.slow
def test_time_consuming_operation():
"""耗时的测试"""
import time
time.sleep(2)
assert True
@pytest.mark.integration
def test_database_integration():
"""集成测试"""
pass
@pytest.mark.unit
def test_pure_function():
"""单元测试"""
def add(a, b):
return a + b
assert add(2, 3) == 5
@pytest.mark.smoke
def test_basic_functionality():
"""冒烟测试"""
pass
@pytest.mark.api
def test_api_endpoint():
"""API测试"""
pass
组合标记
@pytest.mark.slow
@pytest.mark.integration
def test_slow_integration():
"""既慢又是集成测试"""
pass
# 使用参数化标记
@pytest.mark.parametrize("input,expected", [
(2, 4),
(3, 9),
(4, 16),
])
@pytest.mark.unit
def test_square_function(input, expected):
"""参数化的单元测试"""
def square(x):
return x * x
assert square(input) == expected
标记类和模块
# 标记整个测试类
@pytest.mark.integration
class TestDatabaseOperations:
"""数据库操作集成测试"""
def test_create_user(self):
pass
def test_update_user(self):
pass
def test_delete_user(self):
pass
# 在模块级别使用标记
pytestmark = pytest.mark.api
def test_get_users():
"""获取用户列表"""
pass
def test_create_user():
"""创建用户"""
pass
运行特定标记的测试
# 运行特定标记的测试
pytest -m "slow"
# 运行多个标记
pytest -m "slow or integration"
# 排除特定标记
pytest -m "not slow"
# 复杂的标记表达式
pytest -m "integration and not slow"
# 运行单元测试和冒烟测试
pytest -m "unit or smoke"
# 列出所有可用的标记
pytest --markers
配置文件中的标记
pytest.ini 配置
[tool:pytest]
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests
unit: marks tests as unit tests
smoke: marks tests as smoke tests
api: marks tests as API tests
database: marks tests that require database
network: marks tests that require network access
windows_only: marks tests that only run on Windows
linux_only: marks tests that only run on Linux
# 默认运行的标记
addopts = -m "not slow"
# 测试路径
testpaths = tests
# 最小版本要求
minversion = 6.0
pyproject.toml 配置
[tool.pytest.ini_options]
markers = [
"slow: marks tests as slow",
"integration: marks tests as integration tests",
"unit: marks tests as unit tests",
"smoke: marks tests as smoke tests",
"api: marks tests as API tests",
]
addopts = "-v --tb=short"
testpaths = ["tests"]
高级标记用法
参数化标记
@pytest.mark.parametrize("environment", ["dev", "staging", "prod"])
@pytest.mark.integration
def test_environment_specific(environment):
"""针对不同环境的测试"""
config = load_config(environment)
assert config is not None
def load_config(env):
"""加载环境配置"""
configs = {
"dev": {"host": "dev.example.com"},
"staging": {"host": "staging.example.com"},
"prod": {"host": "prod.example.com"}
}
return configs.get(env)
条件标记
def pytest_configure(config):
"""动态注册标记"""
config.addinivalue_line(
"markers", "slow: marks tests as slow"
)
def pytest_collection_modifyitems(config, items):
"""根据条件修改测试项"""
if config.getoption("--runslow"):
# 如果指定了 --runslow,不跳过慢测试
return
skip_slow = pytest.mark.skip(reason="need --runslow option to run")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)
def pytest_addoption(parser):
"""添加命令行选项"""
parser.addoption(
"--runslow", action="store_true", default=False,
help="run slow tests"
)
标记的最佳实践
1. 标记命名约定
# 好的标记名称
@pytest.mark.unit # 清晰的测试类型
@pytest.mark.integration # 明确的测试级别
@pytest.mark.slow # 描述性的特征
@pytest.mark.requires_db # 明确的依赖
# 避免的标记名称
@pytest.mark.test1 # 不描述性
@pytest.mark.temp # 临时性标记
@pytest.mark.broken # 应该使用 xfail
2. 标记组织策略
# 按测试类型分类
@pytest.mark.unit
@pytest.mark.integration
@pytest.mark.e2e
# 按功能模块分类
@pytest.mark.auth
@pytest.mark.payment
@pytest.mark.notification
# 按执行特征分类
@pytest.mark.slow
@pytest.mark.fast
@pytest.mark.parallel_safe
# 按环境要求分类
@pytest.mark.requires_network
@pytest.mark.requires_database
@pytest.mark.requires_redis
Fixture 和参数化
Fixture 基础
Fixture 是 pytest 的核心功能之一,用于为测试提供可重用的设置和清理代码。
基本 Fixture
import pytest
@pytest.fixture
def sample_data():
"""提供测试数据的fixture"""
return {"name": "Alice", "age": 30, "city": "New York"}
@pytest.fixture
def calculator():
"""提供计算器实例的fixture"""
from src.calculator import Calculator
return Calculator()
def test_user_data(sample_data):
"""使用sample_data fixture的测试"""
assert sample_data["name"] == "Alice"
assert sample_data["age"] == 30
def test_calculator_add(calculator):
"""使用calculator fixture的测试"""
result = calculator.add(2, 3)
assert result == 5
Fixture 的设置和清理
@pytest.fixture
def database_connection():
"""数据库连接fixture,包含设置和清理"""
# 设置阶段
print("连接到数据库")
connection = create_database_connection()
yield connection # 提供给测试使用
# 清理阶段
print("关闭数据库连接")
connection.close()
@pytest.fixture
def temp_file():
"""临时文件fixture"""
import tempfile
import os
# 创建临时文件
fd, path = tempfile.mkstemp()
yield path # 提供文件路径给测试
# 清理:删除临时文件
os.close(fd)
os.unlink(path)
def test_database_operations(database_connection):
"""使用数据库连接的测试"""
# 测试代码
pass
def test_file_operations(temp_file):
"""使用临时文件的测试"""
with open(temp_file, 'w') as f:
f.write("test data")
with open(temp_file, 'r') as f:
content = f.read()
assert content == "test data"
def create_database_connection():
"""模拟数据库连接"""
class MockConnection:
def close(self):
pass
return MockConnection()
Fixture 作用域
不同作用域的 Fixture
@pytest.fixture(scope="function") # 默认作用域
def function_fixture():
"""每个测试函数都会创建新的实例"""
print("Function fixture setup")
yield "function_data"
print("Function fixture teardown")
@pytest.fixture(scope="class")
def class_fixture():
"""每个测试类只创建一次"""
print("Class fixture setup")
yield "class_data"
print("Class fixture teardown")
@pytest.fixture(scope="module")
def module_fixture():
"""每个模块只创建一次"""
print("Module fixture setup")
yield "module_data"
print("Module fixture teardown")
@pytest.fixture(scope="session")
def session_fixture():
"""整个测试会话只创建一次"""
print("Session fixture setup")
yield "session_data"
print("Session fixture teardown")
class TestFixtureScopes:
"""测试fixture作用域"""
def test_first(self, function_fixture, class_fixture, module_fixture, session_fixture):
assert function_fixture == "function_data"
assert class_fixture == "class_data"
assert module_fixture == "module_data"
assert session_fixture == "session_data"
def test_second(self, function_fixture, class_fixture, module_fixture, session_fixture):
# class_fixture, module_fixture, session_fixture 会重用
# function_fixture 会重新创建
assert function_fixture == "function_data"
assert class_fixture == "class_data"
实际应用示例
@pytest.fixture(scope="session")
def database_engine():
"""会话级别的数据库引擎"""
from sqlalchemy import create_engine
engine = create_engine("sqlite:///:memory:")
# 创建表结构
create_tables(engine)
yield engine
# 会话结束时清理
engine.dispose()
@pytest.fixture(scope="class")
def database_session(database_engine):
"""类级别的数据库会话"""
from sqlalchemy.orm import sessionmaker
Session = sessionmaker(bind=database_engine)
session = Session()
yield session
session.close()
@pytest.fixture
def clean_database(database_session):
"""每个测试前清理数据库"""
# 清理所有表
for table in reversed(database_session.bind.metadata.sorted_tables):
database_session.execute(table.delete())
database_session.commit()
yield database_session
# 测试后回滚
database_session.rollback()
def create_tables(engine):
"""创建数据库表"""
pass # 实际的表创建逻辑
参数化测试
基本参数化
import pytest
@pytest.mark.parametrize("input,expected", [
(2, 4),
(3, 9),
(4, 16),
(5, 25),
])
def test_square(input, expected):
"""参数化测试平方函数"""
def square(x):
return x * x
assert square(input) == expected
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(2, 3, 5),
(10, 20, 30),
(-1, 1, 0),
])
def test_addition(a, b, expected):
"""参数化测试加法"""
assert a + b == expected
多参数参数化
@pytest.mark.parametrize("operation", ["add", "subtract", "multiply"])
@pytest.mark.parametrize("a,b", [(2, 3), (5, 7), (10, 4)])
def test_calculator_operations(operation, a, b, calculator):
"""多参数参数化测试"""
if operation == "add":
result = calculator.add(a, b)
expected = a + b
elif operation == "subtract":
result = calculator.subtract(a, b)
expected = a - b
elif operation == "multiply":
result = calculator.multiply(a, b)
expected = a * b
assert result == expected
参数化标识
@pytest.mark.parametrize("input,expected", [
pytest.param(2, 4, id="square_of_2"),
pytest.param(3, 9, id="square_of_3"),
pytest.param(4, 16, id="square_of_4"),
pytest.param(0, 0, id="square_of_zero"),
pytest.param(-2, 4, id="square_of_negative"),
])
def test_square_with_ids(input, expected):
"""带有自定义ID的参数化测试"""
def square(x):
return x * x
assert square(input) == expected
# 使用函数生成ID
def idfn(val):
"""生成参数ID的函数"""
if isinstance(val, str):
return f"str_{val}"
elif isinstance(val, int):
return f"int_{val}"
return str(val)
@pytest.mark.parametrize("value", [1, "hello", 3.14], ids=idfn)
def test_with_id_function(value):
"""使用ID函数的参数化测试"""
assert value is not None
条件参数化
import sys
@pytest.mark.parametrize("platform,expected", [
pytest.param("linux", "posix", marks=pytest.mark.skipif(sys.platform != "linux", reason="Linux only")),
pytest.param("win32", "nt", marks=pytest.mark.skipif(sys.platform != "win32", reason="Windows only")),
pytest.param("darwin", "posix", marks=pytest.mark.skipif(sys.platform != "darwin", reason="macOS only")),
])
def test_platform_specific(platform, expected):
"""平台特定的参数化测试"""
import os
assert os.name == expected
Fixture 参数化
参数化 Fixture
@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def database_url(request):
"""参数化的数据库URL fixture"""
urls = {
"sqlite": "sqlite:///:memory:",
"postgresql": "postgresql://user:pass@localhost/test",
"mysql": "mysql://user:pass@localhost/test"
}
return urls[request.param]
@pytest.fixture(params=[1, 2, 4, 8])
def worker_count(request):
"""参数化的工作线程数fixture"""
return request.param
def test_database_connection(database_url):
"""测试会针对每个数据库类型运行"""
# 这个测试会运行3次,每次使用不同的数据库URL
assert "://" in database_url
def test_worker_performance(worker_count):
"""测试会针对每个工作线程数运行"""
# 这个测试会运行4次,每次使用不同的工作线程数
assert worker_count > 0
间接参数化
@pytest.fixture
def user_data(request):
"""根据参数创建用户数据"""
user_type = request.param
if user_type == "admin":
return {"name": "Admin", "role": "admin", "permissions": ["read", "write", "delete"]}
elif user_type == "user":
return {"name": "User", "role": "user", "permissions": ["read"]}
elif user_type == "guest":
return {"name": "Guest", "role": "guest", "permissions": []}
@pytest.mark.parametrize("user_data", ["admin", "user", "guest"], indirect=True)
def test_user_permissions(user_data):
"""间接参数化测试用户权限"""
if user_data["role"] == "admin":
assert "delete" in user_data["permissions"]
elif user_data["role"] == "user":
assert "read" in user_data["permissions"]
assert "delete" not in user_data["permissions"]
elif user_data["role"] == "guest":
assert len(user_data["permissions"]) == 0
高级 Fixture 用法
Fixture 依赖
@pytest.fixture
def database():
"""数据库fixture"""
print("Setting up database")
return "database_connection"
@pytest.fixture
def user_service(database):
"""依赖数据库的用户服务fixture"""
print("Setting up user service")
return f"user_service_with_{database}"
@pytest.fixture
def auth_service(database):
"""依赖数据库的认证服务fixture"""
print("Setting up auth service")
return f"auth_service_with_{database}"
@pytest.fixture
def application(user_service, auth_service):
"""依赖多个服务的应用fixture"""
print("Setting up application")
return {
"user_service": user_service,
"auth_service": auth_service
}
def test_application_integration(application):
"""测试应用集成"""
assert "user_service" in application
assert "auth_service" in application
自动使用的 Fixture
@pytest.fixture(autouse=True)
def setup_logging():
"""自动使用的日志设置fixture"""
import logging
logging.basicConfig(level=logging.DEBUG)
print("Logging configured")
@pytest.fixture(autouse=True, scope="class")
def class_setup():
"""类级别自动使用的fixture"""
print("Class setup")
yield
print("Class teardown")
def test_with_auto_fixtures():
"""这个测试会自动使用上面的fixtures"""
assert True
class TestWithAutoFixtures:
"""这个类的所有测试都会使用自动fixtures"""
def test_one(self):
assert True
def test_two(self):
assert True
pytest-html 报告
安装和基本使用
安装 pytest-html
# 安装 pytest-html
pip install pytest-html
# 验证安装
pytest --help | grep html
基本使用
# 生成HTML报告
pytest --html=reports/report.html
# 生成自包含的HTML报告(包含CSS)
pytest --html=reports/report.html --self-contained-html
# 指定报告标题
pytest --html=reports/report.html --html-title="我的测试报告"
基本配置
# pytest.ini
[tool:pytest]
addopts = --html=reports/report.html --self-contained-html
自定义报告
添加环境信息
# conftest.py
import pytest
def pytest_configure(config):
"""配置测试环境信息"""
config._metadata = {
"项目名称": "pytest示例项目",
"测试环境": "开发环境",
"Python版本": "3.9.0",
"平台": "Linux",
"测试人员": "张三"
}
@pytest.hookimpl(optionalhook=True)
def pytest_html_report_title(report):
"""自定义报告标题"""
report.title = "pytest测试报告"
def pytest_metadata(metadata):
"""修改元数据"""
metadata.pop("JAVA_HOME", None) # 移除不需要的信息
metadata["自定义信息"] = "这是自定义的元数据"
自定义测试结果
# conftest.py
import pytest
from datetime import datetime
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""自定义测试结果"""
outcome = yield
report = outcome.get_result()
# 添加额外信息到报告
if report.when == "call":
# 添加执行时间
report.extra = getattr(report, 'extra', [])
# 添加描述信息
if hasattr(item.function, '__doc__') and item.function.__doc__:
report.extra.append(pytest_html.extras.text(
item.function.__doc__, name="测试描述"
))
# 添加时间戳
report.extra.append(pytest_html.extras.text(
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
name="执行时间"
))
import pytest_html
def pytest_html_results_table_header(cells):
"""自定义表格头部"""
cells.insert(2, pytest_html.html.th('描述'))
cells.insert(3, pytest_html.html.th('执行时间'))
def pytest_html_results_table_row(report, cells):
"""自定义表格行"""
# 添加测试描述
if hasattr(report, 'description'):
cells.insert(2, pytest_html.html.td(report.description))
else:
cells.insert(2, pytest_html.html.td('无描述'))
# 添加执行时间
if hasattr(report, 'duration'):
cells.insert(3, pytest_html.html.td(f"{report.duration:.3f}s"))
else:
cells.insert(3, pytest_html.html.td('未知'))
报告增强
添加截图和日志
# conftest.py
import pytest
import pytest_html
import base64
from io import BytesIO
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""为失败的测试添加截图"""
outcome = yield
report = outcome.get_result()
extra = getattr(report, 'extra', [])
if report.when == 'call' or report.when == "setup":
xfail = hasattr(report, 'wasxfail')
if (report.skipped and xfail) or (report.failed and not xfail):
# 添加失败截图(如果是Web测试)
if hasattr(item, 'funcargs') and 'driver' in item.funcargs:
driver = item.funcargs['driver']
screenshot = driver.get_screenshot_as_png()
extra.append(pytest_html.extras.png(screenshot, name="失败截图"))
# 添加日志信息
if hasattr(item, '_request'):
logs = capture_logs()
if logs:
extra.append(pytest_html.extras.text(logs, name="日志信息"))
report.extra = extra
def capture_logs():
"""捕获日志信息"""
# 这里可以实现日志捕获逻辑
return "这里是捕获的日志信息"
# 测试示例
def test_with_screenshot():
"""带截图的测试示例"""
# 模拟测试失败
assert False, "这是一个故意失败的测试"
def test_with_extra_info():
"""带额外信息的测试"""
import pytest_html
# 在测试中添加额外信息
extra = [
pytest_html.extras.text("这是额外的文本信息", name="自定义信息"),
pytest_html.extras.url("https://www.example.com", name="相关链接"),
pytest_html.extras.json({"key": "value", "number": 123}, name="JSON数据")
]
# 将额外信息附加到当前测试
if hasattr(pytest.current_request, 'node'):
pytest.current_request.node.extra = extra
assert True
自定义CSS样式
# conftest.py
def pytest_html_report_title(report):
"""自定义报告标题和样式"""
report.title = "自定义测试报告"
def pytest_configure(config):
"""添加自定义CSS"""
config._metadata = {
"项目": "pytest示例",
"版本": "1.0.0"
}
# 创建自定义CSS文件
custom_css = """
<style>
.header {
background-color: #4CAF50;
color: white;
padding: 10px;
text-align: center;
}
.passed {
background-color: #d4edda;
color: #155724;
}
.failed {
background-color: #f8d7da;
color: #721c24;
}
.skipped {
background-color: #fff3cd;
color: #856404;
}
.summary {
font-size: 18px;
font-weight: bold;
margin: 20px 0;
}
</style>
"""
def pytest_html_report_title(report):
"""添加自定义样式到报告"""
report.title = "自定义样式测试报告"
# 注意:实际的CSS注入需要通过其他方式实现
生成多格式报告
# conftest.py
import json
import xml.etree.ElementTree as ET
from datetime import datetime
def pytest_sessionfinish(session, exitstatus):
"""会话结束时生成额外的报告格式"""
# 生成JSON格式报告
generate_json_report(session)
# 生成XML格式报告
generate_xml_report(session)
def generate_json_report(session):
"""生成JSON格式的测试报告"""
report_data = {
"timestamp": datetime.now().isoformat(),
"total_tests": session.testscollected,
"passed": 0,
"failed": 0,
"skipped": 0,
"tests": []
}
# 这里需要收集测试结果数据
# 实际实现需要在测试执行过程中收集数据
with open("reports/report.json", "w", encoding="utf-8") as f:
json.dump(report_data, f, indent=2, ensure_ascii=False)
def generate_xml_report(session):
"""生成XML格式的测试报告"""
root = ET.Element("testsuites")
testsuite = ET.SubElement(root, "testsuite")
testsuite.set("name", "pytest")
testsuite.set("timestamp", datetime.now().isoformat())
# 添加测试用例
# 实际实现需要遍历测试结果
tree = ET.ElementTree(root)
tree.write("reports/report.xml", encoding="utf-8", xml_declaration=True)
高级配置
完整的 conftest.py 示例
# conftest.py
import pytest
import pytest_html
import os
import sys
from datetime import datetime
# 创建报告目录
os.makedirs("reports", exist_ok=True)
def pytest_configure(config):
"""pytest配置"""
# 设置元数据
config._metadata = {
"项目名称": "pytest示例项目",
"测试环境": os.getenv("TEST_ENV", "开发环境"),
"Python版本": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
"平台": sys.platform,
"执行时间": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"执行人": os.getenv("USER", "未知用户")
}
@pytest.hookimpl(optionalhook=True)
def pytest_html_report_title(report):
"""自定义报告标题"""
report.title = "pytest自动化测试报告"
def pytest_metadata(metadata):
"""修改元数据显示"""
# 移除不需要的系统信息
metadata.pop("JAVA_HOME", None)
metadata.pop("Packages", None)
metadata.pop("Plugins", None)
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""自定义测试报告"""
outcome = yield
report = outcome.get_result()
if report.when == "call":
# 初始化extra列表
extra = getattr(report, 'extra', [])
# 添加测试描述
if item.function.__doc__:
extra.append(pytest_html.extras.text(
item.function.__doc__.strip(),
name="测试描述"
))
# 添加测试参数(如果有)
if hasattr(item, 'callspec'):
params = str(item.callspec.params)
extra.append(pytest_html.extras.text(params, name="测试参数"))
# 为失败的测试添加更多信息
if report.failed:
# 添加失败原因
if report.longrepr:
extra.append(pytest_html.extras.text(
str(report.longrepr),
name="失败详情"
))
report.extra = extra
def pytest_html_results_table_header(cells):
"""自定义结果表格头部"""
cells.insert(2, pytest_html.html.th('描述', class_='sortable desc', col='desc'))
cells.insert(3, pytest_html.html.th('时长', class_='sortable duration', col='duration'))
def pytest_html_results_table_row(report, cells):
"""自定义结果表格行"""
# 添加描述列
description = "无描述"
if hasattr(report, 'extra'):
for extra in report.extra:
if extra.get('name') == '测试描述':
description = extra.get('content', '无描述')
break
cells.insert(2, pytest_html.html.td(description))
# 添加时长列
duration = f"{report.duration:.3f}s" if hasattr(report, 'duration') else "未知"
cells.insert(3, pytest_html.html.td(duration))
# 测试数据fixture
@pytest.fixture(scope="session")
def test_config():
"""测试配置fixture"""
return {
"base_url": "https://api.example.com",
"timeout": 30,
"retry_count": 3
}
@pytest.fixture
def sample_user():
"""示例用户数据"""
return {
"username": "testuser",
"email": "test@example.com",
"age": 25
}
运行和查看报告
命令行选项
# 基本HTML报告
pytest --html=reports/report.html
# 自包含HTML报告
pytest --html=reports/report.html --self-contained-html
# 指定CSS文件
pytest --html=reports/report.html --css=custom.css
# 同时生成多种报告
pytest --html=reports/report.html --junitxml=reports/junit.xml --cov=src --cov-report=html:reports/coverage
# 在CI环境中运行
pytest --html=reports/report.html --self-contained-html --tb=short -v
报告示例
生成的HTML报告包含以下部分:
- 环境信息:Python版本、平台、插件等
- 测试摘要:总数、通过、失败、跳过等统计
- 测试结果表格:详细的测试结果列表
- 失败详情:失败测试的详细信息
- 额外信息:截图、日志、自定义数据等
报告特点:
- 响应式设计,支持移动设备
- 可排序和过滤的结果表格
- 折叠/展开的详细信息
- 支持自定义样式和内容
Allure 报告集成
Allure 安装配置
安装 Allure
# 安装 allure-pytest
pip install allure-pytest
# 安装 Allure 命令行工具 (需要 Java 8+)
# 方法1: 使用 npm
npm install -g allure-commandline
# 方法2: 下载二进制文件
# 从 https://github.com/allure-framework/allure2/releases 下载
# 解压并添加到 PATH
# 方法3: 使用包管理器
# macOS
brew install allure
# Ubuntu/Debian
sudo apt-get install allure
# 验证安装
allure --version
基本配置
# pytest.ini
[tool:pytest]
addopts = --alluredir=reports/allure-results
生成和查看报告
# 运行测试并生成 Allure 数据
pytest --alluredir=reports/allure-results
# 生成并打开 Allure 报告
allure serve reports/allure-results
# 生成静态报告
allure generate reports/allure-results -o reports/allure-report --clean
# 打开已生成的报告
allure open reports/allure-report
基本注解
测试描述注解
import allure
import pytest
@allure.feature("用户管理")
@allure.story("用户注册")
@allure.title("测试用户注册功能")
@allure.description("验证用户可以成功注册新账户")
@allure.severity(allure.severity_level.CRITICAL)
def test_user_registration():
"""用户注册测试"""
with allure.step("输入用户信息"):
username = "testuser"
email = "test@example.com"
password = "password123"
with allure.step("提交注册表单"):
result = register_user(username, email, password)
with allure.step("验证注册结果"):
assert result["success"] is True
assert result["user_id"] is not None
@allure.feature("用户管理")
@allure.story("用户登录")
@allure.title("测试用户登录功能")
@allure.description("""
测试用户登录功能的详细描述:
1. 用户输入正确的用户名和密码
2. 系统验证用户凭据
3. 返回登录成功状态和用户信息
""")
@allure.severity(allure.severity_level.CRITICAL)
def test_user_login():
"""用户登录测试"""
with allure.step("准备测试数据"):
username = "testuser"
password = "password123"
with allure.step("执行登录操作"):
result = login_user(username, password)
with allure.step("验证登录结果"):
assert result["success"] is True
assert "token" in result
def register_user(username, email, password):
"""模拟用户注册"""
return {"success": True, "user_id": 12345}
def login_user(username, password):
"""模拟用户登录"""
return {"success": True, "token": "abc123"}
严重程度和标签
import allure
@allure.severity(allure.severity_level.BLOCKER)
@allure.tag("smoke", "critical")
@allure.label("owner", "张三")
@allure.label("layer", "api")
def test_critical_api():
"""关键API测试"""
assert True
@allure.severity(allure.severity_level.NORMAL)
@allure.tag("regression")
@allure.label("owner", "李四")
def test_normal_feature():
"""普通功能测试"""
assert True
@allure.severity(allure.severity_level.MINOR)
@allure.tag("ui")
def test_ui_element():
"""UI元素测试"""
assert True
# 严重程度级别
# BLOCKER: 阻塞性问题
# CRITICAL: 严重问题
# NORMAL: 一般问题
# MINOR: 轻微问题
# TRIVIAL: 微不足道的问题
链接注解
import allure
@allure.link("https://example.com/docs", name="相关文档")
@allure.issue("BUG-123", "修复登录问题")
@allure.testcase("TC-456", "用户登录测试用例")
def test_with_links():
"""带链接的测试"""
assert True
@allure.link("https://jira.company.com/browse/PROJ-789")
def test_jira_link():
"""关联JIRA的测试"""
assert True
高级功能
参数化测试的 Allure 支持
import allure
import pytest
@allure.feature("计算器")
@allure.story("基本运算")
@pytest.mark.parametrize("a,b,expected", [
pytest.param(2, 3, 5, id="正数相加"),
pytest.param(-1, 1, 0, id="负数和正数相加"),
pytest.param(0, 0, 0, id="零相加"),
])
def test_addition(a, b, expected):
"""参数化加法测试"""
with allure.step(f"计算 {a} + {b}"):
result = a + b
with allure.step(f"验证结果等于 {expected}"):
assert result == expected
@allure.feature("数据验证")
@allure.story("输入验证")
@pytest.mark.parametrize("input_data", [
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 30},
{"name": "Charlie", "age": 35},
])
def test_user_data_validation(input_data):
"""用户数据验证测试"""
allure.dynamic.title(f"验证用户 {input_data['name']} 的数据")
allure.dynamic.description(f"验证年龄为 {input_data['age']} 的用户数据")
with allure.step("验证用户名"):
assert len(input_data["name"]) > 0
with allure.step("验证年龄"):
assert input_data["age"] > 0
附件和截图
import allure
import json
import tempfile
import os
def test_with_attachments():
"""带附件的测试"""
# 文本附件
allure.attach("这是一个文本附件", name="文本信息", attachment_type=allure.attachment_type.TEXT)
# JSON附件
test_data = {"name": "测试", "value": 123, "items": [1, 2, 3]}
allure.attach(
json.dumps(test_data, indent=2, ensure_ascii=False),
name="测试数据",
attachment_type=allure.attachment_type.JSON
)
# HTML附件
html_content = """
<html>
<body>
<h1>测试报告</h1>
<p>这是一个HTML附件示例</p>
</body>
</html>
"""
allure.attach(html_content, name="HTML报告", attachment_type=allure.attachment_type.HTML)
# 文件附件
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write("这是一个临时文件的内容")
temp_file = f.name
try:
allure.attach.file(temp_file, name="临时文件", attachment_type=allure.attachment_type.TEXT)
finally:
os.unlink(temp_file)
assert True
def test_with_screenshot():
"""带截图的测试(Web测试示例)"""
# 模拟截图数据
screenshot_data = b"fake_screenshot_data"
allure.attach(
screenshot_data,
name="页面截图",
attachment_type=allure.attachment_type.PNG
)
assert True
@allure.step("执行数据库查询")
def execute_database_query(query):
"""执行数据库查询的步骤"""
allure.attach(query, name="SQL查询", attachment_type=allure.attachment_type.TEXT)
# 模拟查询结果
result = [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
allure.attach(
json.dumps(result, indent=2),
name="查询结果",
attachment_type=allure.attachment_type.JSON
)
return result
def test_database_operations():
"""数据库操作测试"""
with allure.step("连接数据库"):
# 模拟数据库连接
pass
query = "SELECT * FROM users WHERE active = 1"
result = execute_database_query(query)
with allure.step("验证查询结果"):
assert len(result) > 0
动态标题和描述
import allure
import pytest
@pytest.mark.parametrize("user_type,expected_permissions", [
("admin", ["read", "write", "delete"]),
("user", ["read", "write"]),
("guest", ["read"]),
])
def test_user_permissions(user_type, expected_permissions):
"""用户权限测试"""
# 动态设置标题
allure.dynamic.title(f"测试 {user_type} 用户的权限")
# 动态设置描述
allure.dynamic.description(f"验证 {user_type} 用户具有正确的权限: {', '.join(expected_permissions)}")
# 动态设置标签
allure.dynamic.tag(user_type)
allure.dynamic.label("permission_level", user_type)
with allure.step(f"获取 {user_type} 用户权限"):
actual_permissions = get_user_permissions(user_type)
with allure.step("验证权限列表"):
assert set(actual_permissions) == set(expected_permissions)
def get_user_permissions(user_type):
"""获取用户权限"""
permissions = {
"admin": ["read", "write", "delete"],
"user": ["read", "write"],
"guest": ["read"]
}
return permissions.get(user_type, [])
@allure.feature("API测试")
@allure.story("用户API")
def test_api_response():
"""API响应测试"""
endpoint = "/api/users/123"
# 动态设置测试用例链接
allure.dynamic.link(f"https://api-docs.example.com{endpoint}", name="API文档")
with allure.step(f"发送GET请求到 {endpoint}"):
response = mock_api_request(endpoint)
with allure.step("验证响应状态码"):
assert response["status_code"] == 200
with allure.step("验证响应数据"):
assert "user" in response["data"]
def mock_api_request(endpoint):
"""模拟API请求"""
return {
"status_code": 200,
"data": {"user": {"id": 123, "name": "Alice"}}
}
报告生成和查看
高级报告配置
# conftest.py
import allure
import pytest
import os
from datetime import datetime
def pytest_configure(config):
"""配置 Allure 环境信息"""
# 设置环境信息
allure_env_path = os.path.join(config.option.allure_report_dir or "allure-results", "environment.properties")
env_info = {
"测试环境": os.getenv("TEST_ENV", "开发环境"),
"应用版本": os.getenv("APP_VERSION", "1.0.0"),
"浏览器": os.getenv("BROWSER", "Chrome"),
"操作系统": os.name,
"Python版本": f"{sys.version_info.major}.{sys.version_info.minor}",
"执行时间": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
os.makedirs(os.path.dirname(allure_env_path), exist_ok=True)
with open(allure_env_path, "w", encoding="utf-8") as f:
for key, value in env_info.items():
f.write(f"{key}={value}\n")
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""为失败的测试添加截图和日志"""
outcome = yield
rep = outcome.get_result()
if rep.when == "call" and rep.failed:
# 添加失败时的截图(如果是Web测试)
if hasattr(item, "funcargs") and "driver" in item.funcargs:
try:
driver = item.funcargs["driver"]
screenshot = driver.get_screenshot_as_png()
allure.attach(
screenshot,
name="失败截图",
attachment_type=allure.attachment_type.PNG
)
except Exception as e:
allure.attach(
str(e),
name="截图失败",
attachment_type=allure.attachment_type.TEXT
)
# 添加失败时的日志
if hasattr(rep, "longreprtext"):
allure.attach(
rep.longreprtext,
name="失败日志",
attachment_type=allure.attachment_type.TEXT
)
import sys
自定义分类
# conftest.py
import allure
def pytest_collection_modifyitems(config, items):
"""为测试添加自动分类"""
for item in items:
# 根据文件路径添加feature
if "test_api" in item.fspath.basename:
item.add_marker(allure.feature("API测试"))
elif "test_ui" in item.fspath.basename:
item.add_marker(allure.feature("UI测试"))
elif "test_unit" in item.fspath.basename:
item.add_marker(allure.feature("单元测试"))
# 根据测试名称添加story
if "login" in item.name:
item.add_marker(allure.story("用户登录"))
elif "register" in item.name:
item.add_marker(allure.story("用户注册"))
elif "payment" in item.name:
item.add_marker(allure.story("支付功能"))
# 根据标记添加严重程度
if item.get_closest_marker("critical"):
item.add_marker(allure.severity(allure.severity_level.CRITICAL))
elif item.get_closest_marker("high"):
item.add_marker(allure.severity(allure.severity_level.NORMAL))
命令行使用
# 基本使用
pytest --alluredir=reports/allure-results
# 清理之前的结果
pytest --alluredir=reports/allure-results --clean-alluredir
# 生成报告并自动打开
pytest --alluredir=reports/allure-results && allure serve reports/allure-results
# 生成静态报告
pytest --alluredir=reports/allure-results
allure generate reports/allure-results -o reports/allure-report --clean
# 指定报告标题
allure generate reports/allure-results -o reports/allure-report --clean --name "我的测试报告"
# 在CI环境中使用
pytest --alluredir=allure-results --tb=short
allure generate allure-results --clean
Allure 报告特性
报告内容
- 概览页面:测试执行统计、趋势图表
- 分类页面:按Feature、Story、Severity分类
- 套件页面:按测试套件组织
- 图表页面:各种统计图表
- 时间线:测试执行时间线
- 行为驱动:BDD风格的测试组织
高级特性
# 重试机制集成
@allure.feature("稳定性测试")
@pytest.mark.flaky(reruns=3, reruns_delay=2)
def test_flaky_operation():
"""不稳定操作测试"""
import random
if random.random() < 0.7: # 70%概率失败
assert False, "随机失败"
assert True
# 数据驱动测试
@allure.feature("数据驱动测试")
@pytest.mark.parametrize("test_data", [
{"input": "valid_email@example.com", "expected": True},
{"input": "invalid_email", "expected": False},
{"input": "", "expected": False},
], ids=["有效邮箱", "无效邮箱", "空邮箱"])
def test_email_validation(test_data):
"""邮箱验证测试"""
allure.dynamic.title(f"验证邮箱: {test_data['input']}")
with allure.step("执行邮箱验证"):
result = validate_email(test_data["input"])
with allure.step("检查验证结果"):
assert result == test_data["expected"]
def validate_email(email):
"""邮箱验证函数"""
import re
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return bool(re.match(pattern, email))
高级功能
配置文件
pytest.ini 完整配置
[tool:pytest]
# 最小版本要求
minversion = 6.0
# 测试路径
testpaths = tests
# 测试文件模式
python_files = test_*.py *_test.py
# 测试函数模式
python_functions = test_*
# 测试类模式
python_classes = Test*
# 默认命令行选项
addopts =
-v
--tb=short
--strict-markers
--strict-config
--cov=src
--cov-report=term-missing
--cov-report=html:reports/coverage
--html=reports/report.html
--self-contained-html
--alluredir=reports/allure-results
# 标记定义
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests
unit: marks tests as unit tests
smoke: marks tests as smoke tests
api: marks tests as API tests
ui: marks tests as UI tests
database: marks tests that require database
network: marks tests that require network access
critical: marks tests as critical
high: marks tests as high priority
medium: marks tests as medium priority
low: marks tests as low priority
# 过滤警告
filterwarnings =
error
ignore::UserWarning
ignore::DeprecationWarning
# 日志配置
log_cli = true
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s
log_cli_date_format = %Y-%m-%d %H:%M:%S
log_file = tests.log
log_file_level = DEBUG
log_file_format = %(asctime)s [%(levelname)8s] %(filename)s:%(lineno)d: %(message)s
log_file_date_format = %Y-%m-%d %H:%M:%S
# 并行执行配置
addopts = -n auto
# 覆盖率配置
addopts = --cov-fail-under=80
pyproject.toml 配置
[tool.pytest.ini_options]
minversion = "6.0"
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_functions = ["test_*"]
python_classes = ["Test*"]
addopts = [
"-v",
"--tb=short",
"--strict-markers",
"--strict-config",
"--cov=src",
"--cov-report=term-missing",
"--cov-report=html:reports/coverage",
]
markers = [
"slow: marks tests as slow",
"integration: marks tests as integration tests",
"unit: marks tests as unit tests",
"smoke: marks tests as smoke tests",
]
filterwarnings = [
"error",
"ignore::UserWarning",
"ignore::DeprecationWarning",
]
[tool.coverage.run]
source = ["src"]
omit = [
"*/tests/*",
"*/venv/*",
"*/__pycache__/*",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise AssertionError",
"raise NotImplementedError",
]
插件开发
简单插件示例
# pytest_custom_plugin.py
import pytest
import time
from datetime import datetime
class TestTimer:
"""测试计时器插件"""
def __init__(self):
self.start_time = None
self.test_times = {}
def pytest_runtest_setup(self, item):
"""测试开始前记录时间"""
self.start_time = time.time()
def pytest_runtest_teardown(self, item, nextitem):
"""测试结束后计算耗时"""
if self.start_time:
duration = time.time() - self.start_time
self.test_times[item.nodeid] = duration
# 如果测试耗时超过阈值,添加警告
if duration > 5.0: # 5秒阈值
pytest.warns(UserWarning, f"测试 {item.name} 耗时过长: {duration:.2f}s")
def pytest_sessionfinish(self, session, exitstatus):
"""会话结束时输出统计信息"""
if self.test_times:
print("\n=== 测试耗时统计 ===")
sorted_times = sorted(self.test_times.items(), key=lambda x: x[1], reverse=True)
for test_name, duration in sorted_times[:10]: # 显示最慢的10个测试
print(f"{test_name}: {duration:.3f}s")
total_time = sum(self.test_times.values())
avg_time = total_time / len(self.test_times)
print(f"\n总测试时间: {total_time:.3f}s")
print(f"平均测试时间: {avg_time:.3f}s")
# 注册插件
def pytest_configure(config):
"""注册插件"""
config.pluginmanager.register(TestTimer(), "test_timer")
# 添加命令行选项
def pytest_addoption(parser):
"""添加自定义命令行选项"""
parser.addoption(
"--slow-threshold",
action="store",
default=5.0,
type=float,
help="慢测试的时间阈值(秒)"
)
parser.addoption(
"--generate-report",
action="store_true",
default=False,
help="生成详细的测试报告"
)
# 自定义标记
def pytest_configure(config):
"""注册自定义标记"""
config.addinivalue_line(
"markers", "performance: mark test as performance test"
)
config.addinivalue_line(
"markers", "external: mark test as requiring external resources"
)
高级插件功能
# conftest.py
import pytest
import json
import os
from datetime import datetime
class TestReporter:
"""自定义测试报告器"""
def __init__(self):
self.test_results = []
self.session_start_time = None
def pytest_sessionstart(self, session):
"""会话开始"""
self.session_start_time = datetime.now()
print(f"\n测试会话开始: {self.session_start_time}")
def pytest_runtest_logreport(self, report):
"""收集测试结果"""
if report.when == "call":
self.test_results.append({
"name": report.nodeid,
"outcome": report.outcome,
"duration": getattr(report, 'duration', 0),
"longrepr": str(report.longrepr) if report.longrepr else None,
"timestamp": datetime.now().isoformat()
})
def pytest_sessionfinish(self, session, exitstatus):
"""会话结束,生成报告"""
session_end_time = datetime.now()
session_duration = (session_end_time - self.session_start_time).total_seconds()
# 统计信息
total_tests = len(self.test_results)
passed = len([r for r in self.test_results if r["outcome"] == "passed"])
failed = len([r for r in self.test_results if r["outcome"] == "failed"])
skipped = len([r for r in self.test_results if r["outcome"] == "skipped"])
# 生成JSON报告
report_data = {
"summary": {
"total": total_tests,
"passed": passed,
"failed": failed,
"skipped": skipped,
"success_rate": (passed / total_tests * 100) if total_tests > 0 else 0,
"session_duration": session_duration,
"start_time": self.session_start_time.isoformat(),
"end_time": session_end_time.isoformat()
},
"tests": self.test_results
}
# 保存报告
os.makedirs("reports", exist_ok=True)
with open("reports/custom_report.json", "w", encoding="utf-8") as f:
json.dump(report_data, f, indent=2, ensure_ascii=False)
print(f"\n自定义报告已生成: reports/custom_report.json")
print(f"测试总数: {total_tests}, 通过: {passed}, 失败: {failed}, 跳过: {skipped}")
print(f"成功率: {report_data['summary']['success_rate']:.1f}%")
# 注册插件
def pytest_configure(config):
"""注册自定义报告器"""
if config.option.generate_report:
config.pluginmanager.register(TestReporter(), "test_reporter")
# 动态跳过测试
def pytest_collection_modifyitems(config, items):
"""动态修改测试集合"""
# 根据环境变量跳过某些测试
if os.getenv("SKIP_SLOW_TESTS"):
skip_slow = pytest.mark.skip(reason="跳过慢测试")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)
# 根据配置跳过外部依赖测试
if not config.getoption("--run-external"):
skip_external = pytest.mark.skip(reason="跳过外部依赖测试")
for item in items:
if "external" in item.keywords:
item.add_marker(skip_external)
def pytest_addoption(parser):
"""添加命令行选项"""
parser.addoption(
"--run-external",
action="store_true",
default=False,
help="运行需要外部依赖的测试"
)
钩子函数
常用钩子函数
# conftest.py
import pytest
import logging
# 配置钩子
def pytest_configure(config):
"""pytest配置钩子"""
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
def pytest_unconfigure(config):
"""pytest取消配置钩子"""
print("测试会话结束,清理资源")
# 收集钩子
def pytest_collect_file(parent, path):
"""自定义文件收集"""
if path.ext == ".yaml" and path.basename.startswith("test_"):
return YamlFile.from_parent(parent, fspath=path)
def pytest_collection_modifyitems(config, items):
"""修改收集到的测试项"""
# 为所有API测试添加标记
for item in items:
if "api" in str(item.fspath):
item.add_marker(pytest.mark.api)
# 运行钩子
def pytest_runtest_setup(item):
"""测试设置钩子"""
print(f"设置测试: {item.name}")
def pytest_runtest_call(item):
"""测试调用钩子"""
print(f"执行测试: {item.name}")
def pytest_runtest_teardown(item, nextitem):
"""测试清理钩子"""
print(f"清理测试: {item.name}")
# 报告钩子
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""生成测试报告钩子"""
outcome = yield
report = outcome.get_result()
# 为失败的测试添加额外信息
if report.when == "call" and report.failed:
# 添加环境信息
report.sections.append(("环境信息", get_environment_info()))
# 添加系统状态
report.sections.append(("系统状态", get_system_status()))
def get_environment_info():
"""获取环境信息"""
import sys
import platform
return f"""
Python版本: {sys.version}
平台: {platform.platform()}
架构: {platform.architecture()}
"""
def get_system_status():
"""获取系统状态"""
import psutil
return f"""
CPU使用率: {psutil.cpu_percent()}%
内存使用率: {psutil.virtual_memory().percent}%
磁盘使用率: {psutil.disk_usage('/').percent}%
"""
# 会话钩子
def pytest_sessionstart(session):
"""会话开始钩子"""
print("=== 测试会话开始 ===")
# 初始化全局资源
session.config.cache.set("session_start_time", time.time())
def pytest_sessionfinish(session, exitstatus):
"""会话结束钩子"""
start_time = session.config.cache.get("session_start_time", time.time())
duration = time.time() - start_time
print(f"=== 测试会话结束,总耗时: {duration:.2f}秒 ===")
import time
最佳实践
项目结构
推荐的项目结构
project/
├── src/ # 源代码
│ ├── __init__.py
│ ├── models/ # 数据模型
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── product.py
│ ├── services/ # 业务逻辑
│ │ ├── __init__.py
│ │ ├── user_service.py
│ │ └── product_service.py
│ ├── utils/ # 工具函数
│ │ ├── __init__.py
│ │ ├── helpers.py
│ │ └── validators.py
│ └── config/ # 配置
│ ├── __init__.py
│ ├── settings.py
│ └── database.py
├── tests/ # 测试代码
│ ├── __init__.py
│ ├── conftest.py # pytest配置
│ ├── fixtures/ # 测试夹具
│ │ ├── __init__.py
│ │ ├── database.py
│ │ └── api_client.py
│ ├── unit/ # 单元测试
│ │ ├── __init__.py
│ │ ├── test_models/
│ │ ├── test_services/
│ │ └── test_utils/
│ ├── integration/ # 集成测试
│ │ ├── __init__.py
│ │ ├── test_api/
│ │ └── test_database/
│ ├── e2e/ # 端到端测试
│ │ ├── __init__.py
│ │ └── test_workflows/
│ └── data/ # 测试数据
│ ├── fixtures.json
│ └── test_data.csv
├── reports/ # 测试报告
├── config/ # 配置文件
│ ├── pytest.ini
│ ├── tox.ini
│ └── .coveragerc
├── requirements/ # 依赖文件
│ ├── base.txt
│ ├── dev.txt
│ └── test.txt
├── scripts/ # 脚本文件
│ ├── run_tests.sh
│ └── generate_reports.py
├── .github/ # GitHub Actions
│ └── workflows/
│ └── test.yml
├── pytest.ini # pytest配置
├── pyproject.toml # 项目配置
├── requirements.txt # 依赖
└── README.md # 项目说明
测试组织原则
# tests/conftest.py - 全局配置
import pytest
import os
import sys
# 添加源代码路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
@pytest.fixture(scope="session")
def app_config():
"""应用配置"""
return {
"database_url": "sqlite:///:memory:",
"api_base_url": "http://localhost:8000",
"timeout": 30
}
# tests/unit/conftest.py - 单元测试配置
import pytest
from unittest.mock import Mock
@pytest.fixture
def mock_database():
"""模拟数据库"""
return Mock()
@pytest.fixture
def sample_user():
"""示例用户数据"""
return {
"id": 1,
"username": "testuser",
"email": "test@example.com"
}
# tests/integration/conftest.py - 集成测试配置
import pytest
import requests
@pytest.fixture(scope="class")
def api_client(app_config):
"""API客户端"""
class APIClient:
def __init__(self, base_url):
self.base_url = base_url
self.session = requests.Session()
def get(self, endpoint):
return self.session.get(f"{self.base_url}{endpoint}")
def post(self, endpoint, data):
return self.session.post(f"{self.base_url}{endpoint}", json=data)
return APIClient(app_config["api_base_url"])
CI/CD 集成
GitHub Actions 配置
# .github/workflows/test.yml
name: 测试流水线
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: 设置Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: 安装依赖
run: |
python -m pip install --upgrade pip
pip install -r requirements/test.txt
- name: 代码风格检查
run: |
flake8 src tests
black --check src tests
isort --check-only src tests
- name: 运行测试
run: |
pytest \
--cov=src \
--cov-report=xml \
--cov-report=html \
--html=reports/report.html \
--self-contained-html \
--alluredir=reports/allure-results \
--junitxml=reports/junit.xml
- name: 上传覆盖率报告
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
flags: unittests
name: codecov-umbrella
- name: 生成Allure报告
uses: simple-elf/allure-report-action@master
if: always()
with:
allure_results: reports/allure-results
allure_history: allure-history
- name: 上传测试报告
uses: actions/upload-artifact@v3
if: always()
with:
name: test-reports-${{ matrix.python-version }}
path: |
reports/
htmlcov/
- name: 发送通知
if: failure()
uses: 8398a7/action-slack@v3
with:
status: failure
channel: '#ci-cd'
webhook_url: ${{ secrets.SLACK_WEBHOOK }}
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: 安全扫描
run: |
pip install safety bandit
safety check
bandit -r src/
GitLab CI 配置
# .gitlab-ci.yml
stages:
- lint
- test
- security
- deploy
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
cache:
paths:
- .cache/pip/
- venv/
before_script:
- python -m venv venv
- source venv/bin/activate
- pip install --upgrade pip
- pip install -r requirements/test.txt
lint:
stage: lint
script:
- flake8 src tests
- black --check src tests
- isort --check-only src tests
only:
- merge_requests
- main
test:
stage: test
parallel:
matrix:
- PYTHON_VERSION: ["3.8", "3.9", "3.10", "3.11"]
image: python:$PYTHON_VERSION
script:
- pytest
--cov=src
--cov-report=xml
--cov-report=html
--html=reports/report.html
--self-contained-html
--alluredir=reports/allure-results
coverage: '/TOTAL.+ ([0-9]{1,3}%)/'
artifacts:
when: always
paths:
- reports/
- htmlcov/
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
junit: reports/junit.xml
expire_in: 1 week
security:
stage: security
script:
- pip install safety bandit
- safety check
- bandit -r src/
allow_failure: true
pages:
stage: deploy
script:
- mkdir public
- cp -r htmlcov public/coverage
- cp -r reports public/
artifacts:
paths:
- public
only:
- main
Jenkins Pipeline
// Jenkinsfile
pipeline {
agent any
environment {
PYTHON_VERSION = '3.9'
VENV_PATH = "${WORKSPACE}/venv"
}
stages {
stage('Setup') {
steps {
sh '''
python${PYTHON_VERSION} -m venv ${VENV_PATH}
. ${VENV_PATH}/bin/activate
pip install --upgrade pip
pip install -r requirements/test.txt
'''
}
}
stage('Lint') {
steps {
sh '''
. ${VENV_PATH}/bin/activate
flake8 src tests
black --check src tests
isort --check-only src tests
'''
}
}
stage('Test') {
parallel {
stage('Unit Tests') {
steps {
sh '''
. ${VENV_PATH}/bin/activate
pytest tests/unit/
--cov=src
--cov-report=xml:coverage-unit.xml
--junitxml=reports/junit-unit.xml
'''
}
}
stage('Integration Tests') {
steps {
sh '''
. ${VENV_PATH}/bin/activate
pytest tests/integration/
--junitxml=reports/junit-integration.xml
'''
}
}
}
}
stage('Security') {
steps {
sh '''
. ${VENV_PATH}/bin/activate
pip install safety bandit
safety check || true
bandit -r src/ || true
'''
}
}
stage('Reports') {
steps {
sh '''
. ${VENV_PATH}/bin/activate
pytest
--html=reports/report.html
--self-contained-html
--alluredir=reports/allure-results
'''
allure([
includeProperties: false,
jdk: '',
properties: [],
reportBuildPolicy: 'ALWAYS',
results: [[path: 'reports/allure-results']]
])
}
}
}
post {
always {
publishHTML([
allowMissing: false,
alwaysLinkToLastBuild: true,
keepAll: true,
reportDir: 'htmlcov',
reportFiles: 'index.html',
reportName: 'Coverage Report'
])
junit 'reports/junit*.xml'
archiveArtifacts artifacts: 'reports/**/*', fingerprint: true
}
failure {
emailext (
subject: "Build Failed: ${env.JOB_NAME} - ${env.BUILD_NUMBER}",
body: "Build failed. Check console output at ${env.BUILD_URL}",
to: "${env.CHANGE_AUTHOR_EMAIL}"
)
}
}
}
性能优化
并行执行优化
# conftest.py
import pytest
def pytest_configure(config):
"""配置并行执行"""
# 设置并行工作进程数
if not config.getoption("-n", default=None):
import multiprocessing
config.option.numprocesses = multiprocessing.cpu_count()
# 并行安全的fixture
@pytest.fixture(scope="session")
def database_engine():
"""会话级数据库引擎(并行安全)"""
import threading
from sqlalchemy import create_engine
# 为每个进程创建独立的数据库
process_id = threading.current_thread().ident
db_url = f"sqlite:///test_{process_id}.db"
engine = create_engine(db_url)
yield engine
# 清理
engine.dispose()
import os
try:
os.unlink(f"test_{process_id}.db")
except FileNotFoundError:
pass
# 使用锁保护共享资源
import threading
_lock = threading.Lock()
@pytest.fixture
def shared_resource():
"""共享资源fixture"""
with _lock:
# 安全地访问共享资源
yield "shared_data"
测试执行优化
# 快速失败策略
def pytest_collection_modifyitems(config, items):
"""优化测试执行顺序"""
# 将快速测试放在前面
fast_tests = []
slow_tests = []
for item in items:
if "slow" in item.keywords:
slow_tests.append(item)
else:
fast_tests.append(item)
# 重新排序:快速测试优先
items[:] = fast_tests + slow_tests
# 智能跳过
def pytest_runtest_setup(item):
"""智能跳过策略"""
# 如果前置条件不满足,跳过相关测试
if "requires_network" in item.keywords:
if not check_network_connectivity():
pytest.skip("网络不可用")
if "requires_database" in item.keywords:
if not check_database_connectivity():
pytest.skip("数据库不可用")
def check_network_connectivity():
"""检查网络连接"""
import socket
try:
socket.create_connection(("8.8.8.8", 53), timeout=3)
return True
except OSError:
return False
def check_database_connectivity():
"""检查数据库连接"""
try:
# 尝试连接数据库
return True
except:
return False
内存和资源优化
# conftest.py
import gc
import pytest
@pytest.fixture(autouse=True)
def memory_cleanup():
"""自动内存清理"""
yield
# 测试后强制垃圾回收
gc.collect()
# 资源池管理
class ResourcePool:
"""资源池管理器"""
def __init__(self, factory, max_size=10):
self.factory = factory
self.max_size = max_size
self.pool = []
self.in_use = set()
def acquire(self):
"""获取资源"""
if self.pool:
resource = self.pool.pop()
else:
resource = self.factory()
self.in_use.add(resource)
return resource
def release(self, resource):
"""释放资源"""
if resource in self.in_use:
self.in_use.remove(resource)
if len(self.pool) < self.max_size:
self.pool.append(resource)
else:
# 销毁多余的资源
if hasattr(resource, 'close'):
resource.close()
# 使用资源池
@pytest.fixture(scope="session")
def connection_pool():
"""连接池fixture"""
def create_connection():
# 创建数据库连接
return MockConnection()
pool = ResourcePool(create_connection, max_size=5)
yield pool
# 清理所有连接
for conn in pool.pool:
conn.close()
for conn in pool.in_use:
conn.close()
class MockConnection:
def close(self):
pass
实战案例
Web API 测试案例
# tests/test_api.py
import pytest
import requests
import allure
@allure.feature("用户API")
class TestUserAPI:
"""用户API测试套件"""
@allure.story("用户注册")
@allure.severity(allure.severity_level.CRITICAL)
def test_user_registration(self, api_client):
"""测试用户注册API"""
user_data = {
"username": "newuser",
"email": "newuser@example.com",
"password": "securepassword123"
}
with allure.step("发送注册请求"):
response = api_client.post("/api/users/register", user_data)
with allure.step("验证响应状态"):
assert response.status_code == 201
with allure.step("验证响应数据"):
data = response.json()
assert data["username"] == user_data["username"]
assert data["email"] == user_data["email"]
assert "id" in data
assert "password" not in data # 密码不应该返回
@allure.story("用户登录")
@pytest.mark.parametrize("credentials,expected_status", [
({"username": "validuser", "password": "validpass"}, 200),
({"username": "invaliduser", "password": "validpass"}, 401),
({"username": "validuser", "password": "invalidpass"}, 401),
({"username": "", "password": "validpass"}, 400),
({"username": "validuser", "password": ""}, 400),
])
def test_user_login(self, api_client, credentials, expected_status):
"""测试用户登录API"""
allure.dynamic.title(f"登录测试: {credentials['username']}")
with allure.step("发送登录请求"):
response = api_client.post("/api/users/login", credentials)
with allure.step(f"验证状态码为 {expected_status}"):
assert response.status_code == expected_status
if expected_status == 200:
with allure.step("验证成功登录响应"):
data = response.json()
assert "token" in data
assert "user" in data
数据库测试案例
# tests/test_database.py
import pytest
import allure
from sqlalchemy import create_engine, text
@allure.feature("数据库操作")
class TestDatabaseOperations:
"""数据库操作测试"""
@pytest.fixture(autouse=True)
def setup_database(self, database_engine):
"""设置测试数据库"""
self.engine = database_engine
# 创建测试表
with self.engine.connect() as conn:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.commit()
yield
# 清理测试数据
with self.engine.connect() as conn:
conn.execute(text("DELETE FROM users"))
conn.commit()
@allure.story("用户CRUD操作")
def test_user_crud_operations(self):
"""测试用户CRUD操作"""
with allure.step("创建用户"):
with self.engine.connect() as conn:
result = conn.execute(text("""
INSERT INTO users (username, email)
VALUES (:username, :email)
"""), {"username": "testuser", "email": "test@example.com"})
conn.commit()
user_id = result.lastrowid
with allure.step("读取用户"):
with self.engine.connect() as conn:
result = conn.execute(text("""
SELECT * FROM users WHERE id = :id
"""), {"id": user_id})
user = result.fetchone()
assert user is not None
assert user.username == "testuser"
assert user.email == "test@example.com"
with allure.step("更新用户"):
with self.engine.connect() as conn:
conn.execute(text("""
UPDATE users SET email = :email WHERE id = :id
"""), {"email": "updated@example.com", "id": user_id})
conn.commit()
with allure.step("验证更新"):
with self.engine.connect() as conn:
result = conn.execute(text("""
SELECT email FROM users WHERE id = :id
"""), {"id": user_id})
updated_user = result.fetchone()
assert updated_user.email == "updated@example.com"
with allure.step("删除用户"):
with self.engine.connect() as conn:
conn.execute(text("""
DELETE FROM users WHERE id = :id
"""), {"id": user_id})
conn.commit()
with allure.step("验证删除"):
with self.engine.connect() as conn:
result = conn.execute(text("""
SELECT COUNT(*) as count FROM users WHERE id = :id
"""), {"id": user_id})
count = result.fetchone().count
assert count == 0
常见问题和解决方案
常见错误
1. 导入错误
# 问题:ModuleNotFoundError
# 解决方案:在conftest.py中添加路径
# conftest.py
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
2. Fixture 作用域问题
# 问题:fixture在错误的作用域中被调用
# 解决方案:明确指定fixture作用域
@pytest.fixture(scope="function") # 每个测试函数一个实例
def user_data():
return {"name": "test"}
@pytest.fixture(scope="class") # 每个测试类一个实例
def database_connection():
return create_connection()
@pytest.fixture(scope="session") # 整个测试会话一个实例
def app_config():
return load_config()
3. 参数化测试ID问题
# 问题:参数化测试的ID不够描述性
# 解决方案:使用自定义ID
@pytest.mark.parametrize("input,expected", [
pytest.param(1, 2, id="positive_number"),
pytest.param(-1, 0, id="negative_number"),
pytest.param(0, 1, id="zero"),
], ids=lambda val: f"input_{val}")
def test_increment(input, expected):
assert increment(input) == expected
调试技巧
1. 使用 pdb 调试
def test_complex_logic():
"""复杂逻辑测试"""
data = prepare_data()
# 在需要调试的地方插入断点
import pdb; pdb.set_trace()
result = complex_function(data)
assert result is not None
# 运行时使用 -s 选项
# pytest -s tests/test_debug.py::test_complex_logic
2. 使用 pytest 调试选项
# 在第一个失败处停止
pytest -x
# 显示本地变量
pytest -l
# 进入 pdb 调试器
pytest --pdb
# 在失败时进入 pdb
pytest --pdb-trace
3. 日志调试
import logging
import pytest
# 配置日志
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
def test_with_logging():
"""带日志的测试"""
logger.info("开始测试")
data = {"key": "value"}
logger.debug(f"测试数据: {data}")
result = process_data(data)
logger.info(f"处理结果: {result}")
assert result is not None
# 运行时显示日志
# pytest -s --log-cli-level=DEBUG
性能问题
1. 慢测试识别
# conftest.py
import time
import pytest
@pytest.fixture(autouse=True)
def measure_test_time(request):
"""测量测试执行时间"""
start_time = time.time()
yield
end_time = time.time()
duration = end_time - start_time
if duration > 1.0: # 超过1秒的测试
print(f"\n警告: 测试 {request.node.name} 耗时 {duration:.2f}s")
# 使用 --durations 选项查看最慢的测试
# pytest --durations=10
2. 内存泄漏检测
import gc
import pytest
@pytest.fixture(autouse=True)
def memory_monitor():
"""内存监控"""
gc.collect()
before = len(gc.get_objects())
yield
gc.collect()
after = len(gc.get_objects())
if after - before > 1000: # 对象增长超过1000
print(f"\n警告: 可能存在内存泄漏,对象增长: {after - before}")
这份pytest完整学习指南涵盖了从基础到高级的所有重要概念,包括:
- 基础功能:安装、测试发现、执行
- 断言和异常:各种断言方式和异常测试
- 跳过和标记:灵活的测试控制
- Fixture系统:强大的测试夹具
- 参数化测试:数据驱动测试
- HTML报告:pytest-html集成
- Allure报告:专业的测试报告
- 高级功能:插件开发、钩子函数
- 最佳实践:项目结构、CI/CD集成
- 实战案例:真实的测试场景
通过这份文档,用户可以快速掌握pytest的使用,并在实际项目中应用这些知识。