6. pytest测试报告与覆盖率
6.1 pytest测试报告体系概述
pytest提供了丰富的测试报告功能,包括终端输出、HTML报告、JUnit XML报告等。
测试报告类型
pytest测试报告体系
终端报告
HTML报告
JUnit XML报告
覆盖率报告
自定义报告
简洁模式
详细模式
彩色输出
pytest-html
allure-pytest
pytest-reportlog
JUnit格式
CI/CD集成
测试历史
pytest-cov
coverage.py
HTML覆盖率
自定义钩子
自定义格式
第三方插件
基础测试报告示例
python
# test_report_basic.py
import pytest
def test_passed():
"""
通过的测试
"""
assert 1 + 1 == 2
def test_failed():
"""
失败的测试
"""
assert 1 + 1 == 3
@pytest.mark.skip(reason="这个测试被跳过")
def test_skipped():
"""
跳过的测试
"""
assert True
@pytest.mark.xfail(reason="这个测试预期失败")
def test_xfailed():
"""
预期失败的测试
"""
assert 1 + 1 == 3
@pytest.mark.xfail(reason="这个测试预期失败")
def test_xpassed():
"""
意外通过的测试
"""
assert 1 + 1 == 2
6.2 pytest-html报告生成
pytest-html插件可以生成美观的HTML测试报告。
pytest-html基础使用
python
# test_html_report.py
import pytest
# 安装:pip install pytest-html
def test_html_report_1():
"""
HTML报告测试1
"""
assert True
def test_html_report_2():
"""
HTML报告测试2
"""
assert 1 + 1 == 2
def test_html_report_3():
"""
HTML报告测试3 - 失败测试
"""
assert 1 + 1 == 3
def test_html_report_with_screenshot(request):
"""
带截图的HTML报告测试
注意:pytest-html 3.0+版本中添加截图的方式已改变
"""
# 模拟截图功能
screenshot_data = "base64_encoded_image_data"
# 在pytest-html 3.0+中,使用pytest_html_extra钩子添加额外内容
# 这里演示如何添加截图到报告
# 实际使用时,需要在conftest.py中实现pytest_html_results_table_row钩子
# 示例:在conftest.py中添加截图
"""
# conftest.py
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == "call" and hasattr(report, "extra"):
# 添加截图到报告
report.extra.append(pytest_html.extras.image(screenshot_data))
"""
assert True
pytest-html配置选项
ini
# pytest.ini
[pytest]
# HTML报告配置
htmlpath = reports/report.html
self_contained_html = true
python
# conftest.py
import pytest
import time
import sys
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""
自定义HTML报告内容
注意:此示例展示了如何自定义HTML报告,但实际实现可能需要根据pytest-html版本调整
"""
outcome = yield
report = outcome.get_result()
# 添加自定义信息到报告
if report.when == "call":
# 添加环境信息
report.environment = {
"Python版本": sys.version,
"操作系统": sys.platform,
"测试时间": time.strftime("%Y-%m-%d %H:%M:%S")
}
# 添加测试元数据
# 注意:item.fspath和item.lineno在pytest 7.0+中已废弃
# 使用item.path替代item.fspath
report.metadata = {
"测试ID": item.nodeid,
"测试文件": str(item.path) if hasattr(item, 'path') else str(item.fspath),
"测试行号": item.obj.__code__.co_firstlineno if hasattr(item, 'obj') else item.lineno
}
def pytest_html_results_summary(prefix, summary, postfix):
"""
自定义HTML报告摘要
"""
prefix.extend([
"<h2>自定义HTML报告摘要</h2>",
"<p>这是自定义的HTML报告摘要内容</p>",
"<p>测试环境:Python 3.8+</p>"
])
def pytest_html_results_table_header(cells):
"""
自定义HTML报告表格头部
"""
cells.insert(2, "<th>执行时间</th>")
cells.insert(3, "<th>测试状态</th>")
def pytest_html_results_table_row(report, cells):
"""
自定义HTML报告表格行
"""
cells.insert(2, f"<td>{report.duration:.3f}秒</td>")
# 添加测试状态
status = "通过" if report.passed else "失败"
cells.insert(3, f"<td>{status}</td>")
6.3 pytest-allure报告生成
allure是一个强大的测试报告框架,提供丰富的报告功能。
注意: allure-pytest插件在不同版本中API可能有所不同,以下示例基于allure-pytest 2.x版本。如果使用allure-pytest 3.x+版本,部分API可能需要调整。
安装allure-pytest
bash
# 安装allure命令行工具
# 下载地址:https://github.com/allure-framework/allure2/releases
# 安装allure-pytest插件
pip install allure-pytest
# 或者指定版本
pip install allure-pytest==2.13.2
pytest-allure基础使用
python
# test_allure_report.py
import pytest
import allure
# 安装:pip install allure-pytest
@allure.feature("用户管理")
@allure.story("用户登录")
def test_user_login():
"""
用户登录测试
"""
# allure.attach的参数顺序:body, name, attachment_type
allure.attach("用户名: admin", name="用户信息")
allure.attach("密码: ******", name="密码信息")
assert True
@allure.feature("用户管理")
@allure.story("用户注册")
@allure.severity(allure.severity_level.CRITICAL)
def test_user_registration():
"""
用户注册测试
"""
with allure.step("填写注册表单"):
allure.attach("用户名: testuser", name="用户名")
allure.attach("邮箱: test@example.com", name="邮箱")
with allure.step("提交注册"):
assert True
@allure.feature("订单管理")
@allure.story("创建订单")
@allure.title("创建新订单测试")
@allure.description("测试创建新订单的功能")
def test_create_order():
"""
创建订单测试
"""
with allure.step("选择商品"):
allure.attach("商品ID: 12345", name="商品信息")
with allure.step("确认订单"):
assert True
@allure.feature("订单管理")
@allure.story("取消订单")
@allure.link("https://example.com/issue/123", name="相关Issue")
def test_cancel_order():
"""
取消订单测试
"""
with allure.step("查询订单"):
allure.attach("订单ID: 67890", name="订单信息")
with allure.step("取消订单"):
assert True
@allure.feature("支付管理")
@allure.story("在线支付")
@allure.issue("https://example.com/issue/456", name="已知问题")
def test_online_payment():
"""
在线支付测试
"""
with allure.step("选择支付方式"):
allure.attach("支付方式: 支付宝", name="支付方式")
with allure.step("完成支付"):
assert True
pytest-allure高级特性
python
# test_allure_advanced.py
import pytest
import allure
import json
@allure.epic("电商平台")
@allure.feature("商品管理")
@allure.story("商品搜索")
class TestProductSearch:
"""
商品搜索测试类
"""
@allure.title("按关键词搜索商品")
@allure.description("测试按关键词搜索商品的功能")
@allure.severity(allure.severity_level.BLOCKER)
def test_search_by_keyword(self):
"""
按关键词搜索商品
"""
with allure.step("输入搜索关键词"):
search_keyword = "手机"
allure.attach(search_keyword, name="搜索关键词")
with allure.step("执行搜索"):
# 模拟搜索结果
search_results = [
{"id": 1, "name": "iPhone 13", "price": 5999},
{"id": 2, "name": "华为 Mate 50", "price": 4999}
]
# 注意:attachment_type参数的使用方式可能因版本而异
# 在allure-pytest 2.x中,可以使用allure.attachment_type.JSON
# 在allure-pytest 3.x中,可能需要使用allure.attachment_type.JSON
allure.attach(
json.dumps(search_results, ensure_ascii=False),
name="搜索结果",
attachment_type=allure.attachment_type.JSON
)
with allure.step("验证搜索结果"):
assert len(search_results) > 0
assert all("手机" in result["name"] for result in search_results)
@allure.title("按价格范围搜索商品")
@allure.description("测试按价格范围搜索商品的功能")
@allure.severity(allure.severity_level.NORMAL)
def test_search_by_price_range(self):
"""
按价格范围搜索商品
"""
with allure.step("设置价格范围"):
min_price = 1000
max_price = 6000
allure.attach(f"价格范围: {min_price} - {max_price}", name="价格范围")
with allure.step("执行搜索"):
# 模拟搜索结果
search_results = [
{"id": 1, "name": "iPhone 13", "price": 5999},
{"id": 3, "name": "小米 12", "price": 2999}
]
allure.attach(
json.dumps(search_results, ensure_ascii=False),
name="搜索结果",
attachment_type=allure.attachment_type.JSON
)
with allure.step("验证搜索结果"):
assert all(min_price <= result["price"] <= max_price for result in search_results)
@allure.epic("电商平台")
@allure.feature("购物车管理")
@allure.story("添加商品到购物车")
class TestShoppingCart:
"""
购物车测试类
"""
@allure.title("添加商品到购物车")
@allure.description("测试添加商品到购物车的功能")
@allure.severity(allure.severity_level.CRITICAL)
def test_add_to_cart(self):
"""
添加商品到购物车
"""
with allure.step("选择商品"):
product = {"id": 1, "name": "iPhone 13", "price": 5999}
allure.attach(
json.dumps(product, ensure_ascii=False),
name="商品信息",
attachment_type=allure.attachment_type.JSON
)
with allure.step("添加到购物车"):
cart_items = [product]
allure.attach(
json.dumps(cart_items, ensure_ascii=False),
name="购物车内容",
attachment_type=allure.attachment_type.JSON
)
with allure.step("验证购物车"):
assert len(cart_items) == 1
assert cart_items[0]["id"] == 1
@allure.title("从购物车移除商品")
@allure.description("测试从购物车移除商品的功能")
@allure.severity(allure.severity_level.NORMAL)
def test_remove_from_cart(self):
"""
从购物车移除商品
"""
with allure.step("初始化购物车"):
cart_items = [
{"id": 1, "name": "iPhone 13", "price": 5999},
{"id": 2, "name": "华为 Mate 50", "price": 4999}
]
allure.attach(
json.dumps(cart_items, ensure_ascii=False),
name="初始购物车",
attachment_type=allure.attachment_type.JSON
)
with allure.step("移除商品"):
cart_items = [item for item in cart_items if item["id"] != 1]
allure.attach(
json.dumps(cart_items, ensure_ascii=False),
name="移除后购物车",
attachment_type=allure.attachment_type.JSON
)
with allure.step("验证购物车"):
assert len(cart_items) == 1
assert cart_items[0]["id"] == 2
6.4 pytest-cov覆盖率报告
pytest-cov插件用于生成代码覆盖率报告。
pytest-cov基础使用
python
# test_coverage_basic.py
import pytest
# 安装:pip install pytest-cov
def calculate_sum(a, b):
"""
计算两个数的和
"""
return a + b
def calculate_product(a, b):
"""
计算两个数的积
"""
return a * b
def calculate_division(a, b):
"""
计算两个数的商
"""
if b == 0:
raise ValueError("除数不能为零")
return a / b
def test_calculate_sum():
"""
测试加法计算
"""
assert calculate_sum(1, 2) == 3
assert calculate_sum(-1, 1) == 0
assert calculate_sum(0, 0) == 0
def test_calculate_product():
"""
测试乘法计算
"""
assert calculate_product(2, 3) == 6
assert calculate_product(-1, 1) == -1
assert calculate_product(0, 5) == 0
def test_calculate_division():
"""
测试除法计算
"""
assert calculate_division(6, 2) == 3
assert calculate_division(10, 5) == 2
def test_calculate_division_by_zero():
"""
测试除零异常
"""
with pytest.raises(ValueError):
calculate_division(1, 0)
pytest-cov配置选项
ini
# pytest.ini
[pytest]
# 覆盖率配置
addopts = --cov=src --cov-report=html --cov-report=term-missing --cov-report=xml
# 覆盖率阈值
[coverage:run]
source = src
omit =
*/tests/*
*/test_*.py
*/__pycache__/*
*/site-packages/*
[coverage:report]
precision = 2
show_missing = True
skip_covered = False
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
if TYPE_CHECKING:
@abstractmethod
python
# conftest.py
import pytest
@pytest.fixture(autouse=True)
def coverage_control():
"""
覆盖率控制fixture
"""
import coverage
cov = coverage.Coverage()
cov.start()
yield
cov.stop()
cov.save()
# 打印覆盖率报告
cov.report()
6.5 覆盖率报告详解
覆盖率报告提供了详细的代码覆盖信息。
覆盖率报告类型
python
# test_coverage_types.py
import pytest
class Calculator:
"""
计算器类
"""
def __init__(self):
"""
初始化计算器
"""
self.history = []
def add(self, a, b):
"""
加法运算
"""
result = a + b
self.history.append(f"{a} + {b} = {result}")
return result
def subtract(self, a, b):
"""
减法运算
"""
result = a - b
self.history.append(f"{a} - {b} = {result}")
return result
def multiply(self, a, b):
"""
乘法运算
"""
result = a * b
self.history.append(f"{a} * {b} = {result}")
return result
def divide(self, a, b):
"""
除法运算
"""
if b == 0:
raise ValueError("除数不能为零")
result = a / b
self.history.append(f"{a} / {b} = {result}")
return result
def get_history(self):
"""
获取计算历史
"""
return self.history
def clear_history(self):
"""
清除计算历史
"""
self.history.clear()
def test_calculator_operations():
"""
测试计算器操作
"""
calc = Calculator()
# 测试加法
assert calc.add(1, 2) == 3
assert calc.add(-1, 1) == 0
# 测试减法
assert calc.subtract(5, 3) == 2
assert calc.subtract(10, 5) == 5
# 测试乘法
assert calc.multiply(2, 3) == 6
assert calc.multiply(-1, 1) == -1
# 测试除法
assert calc.divide(6, 2) == 3
assert calc.divide(10, 5) == 2
# 测试历史记录
history = calc.get_history()
assert len(history) == 6
# 测试清除历史
calc.clear_history()
assert len(calc.get_history()) == 0
def test_divide_by_zero():
"""
测试除零异常
"""
calc = Calculator()
with pytest.raises(ValueError, match="除数不能为零"):
calc.divide(1, 0)
6.6 覆盖率阈值设置
可以设置覆盖率阈值,确保代码质量。
覆盖率阈值配置
ini
# .coveragerc
[run]
source = src
branch = True
[report]
precision = 2
show_missing = True
skip_covered = False
fail_under = 80
[html]
directory = htmlcov
python
# conftest.py
import pytest
@pytest.fixture(autouse=True)
def coverage_threshold_check():
"""
覆盖率阈值检查fixture
"""
import coverage
cov = coverage.Coverage()
cov.start()
yield
cov.stop()
cov.save()
# 检查覆盖率阈值
total = cov.report()
# 获取覆盖率百分比
cov_data = cov.get_data()
covered_lines = sum(len(files) for files in cov_data._lines.values())
total_lines = sum(len(files) for files in cov_data._lines.values())
if total_lines > 0:
coverage_percent = (covered_lines / total_lines) * 100
print(f"\n代码覆盖率: {coverage_percent:.2f}%")
if coverage_percent < 80:
pytest.fail(f"覆盖率 {coverage_percent:.2f}% 低于阈值 80%")
6.7 自定义测试报告
可以创建自定义的测试报告格式。
自定义报告示例
python
# conftest.py
import pytest
import json
import time
from datetime import datetime
class CustomReportGenerator:
"""
自定义报告生成器
"""
def __init__(self):
self.test_results = []
self.start_time = None
self.end_time = None
def start(self):
"""
开始测试
"""
self.start_time = time.time()
def finish(self):
"""
结束测试
"""
self.end_time = time.time()
def add_result(self, report):
"""
添加测试结果
"""
if report.when == "call":
result = {
"nodeid": report.nodeid,
"outcome": report.outcome,
"duration": report.duration,
"timestamp": datetime.now().isoformat(),
"markers": list(report.keywords.keys())
}
if report.failed:
result["error"] = str(report.longrepr)
elif report.skipped:
result["skip_reason"] = str(report.longrepr)
self.test_results.append(result)
def generate_json_report(self, filename="custom_report.json"):
"""
生成JSON格式报告
"""
report_data = {
"summary": {
"total": len(self.test_results),
"passed": sum(1 for r in self.test_results if r["outcome"] == "passed"),
"failed": sum(1 for r in self.test_results if r["outcome"] == "failed"),
"skipped": sum(1 for r in self.test_results if r["outcome"] == "skipped"),
"duration": self.end_time - self.start_time if self.end_time and self.start_time else 0,
"start_time": datetime.fromtimestamp(self.start_time).isoformat() if self.start_time else None,
"end_time": datetime.fromtimestamp(self.end_time).isoformat() if self.end_time else None
},
"tests": self.test_results
}
with open(filename, "w", encoding="utf-8") as f:
json.dump(report_data, f, indent=2, ensure_ascii=False)
print(f"\n自定义JSON报告已生成: {filename}")
def generate_html_report(self, filename="custom_report.html"):
"""
生成HTML格式报告
"""
html_content = self._generate_html_content()
with open(filename, "w", encoding="utf-8") as f:
f.write(html_content)
print(f"\n自定义HTML报告已生成: {filename}")
def _generate_html_content(self):
"""
生成HTML内容
"""
total = len(self.test_results)
passed = sum(1 for r in self.test_results if r["outcome"] == "passed")
failed = sum(1 for r in self.test_results if r["outcome"] == "failed")
skipped = sum(1 for r in self.test_results if r["outcome"] == "skipped")
duration = self.end_time - self.start_time if self.end_time and self.start_time else 0
html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>自定义测试报告</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; }}
.summary {{ background: #f0f0f0; padding: 20px; border-radius: 5px; margin-bottom: 20px; }}
.test-item {{ margin: 10px 0; padding: 10px; border: 1px solid #ddd; border-radius: 3px; }}
.passed {{ border-left: 4px solid #4CAF50; }}
.failed {{ border-left: 4px solid #f44336; }}
.skipped {{ border-left: 4px solid #FF9800; }}
.duration {{ color: #666; font-size: 0.9em; }}
</style>
</head>
<body>
<h1>自定义测试报告</h1>
<div class="summary">
<h2>测试摘要</h2>
<p>总测试数: {total}</p>
<p>通过: {passed}</p>
<p>失败: {failed}</p>
<p>跳过: {skipped}</p>
<p>总耗时: {duration:.2f}秒</p>
<p>通过率: {passed/total*100:.1f}%</p>
</div>
<h2>测试详情</h2>
"""
for result in self.test_results:
status_class = result["outcome"]
html += f"""
<div class="test-item {status_class}">
<h3>{result['nodeid']}</h3>
<p>状态: {result['outcome'].upper()}</p>
<p class="duration">耗时: {result['duration']:.3f}秒</p>
"""
if result["outcome"] == "failed" and "error" in result:
html += f" <p style='color: red;'>错误: {result['error']}</p>\n"
elif result["outcome"] == "skipped" and "skip_reason" in result:
html += f" <p style='color: orange;'>跳过原因: {result['skip_reason']}</p>\n"
html += " </div>\n"
html += """
</body>
</html>
"""
return html
# 创建报告生成器实例
report_generator = CustomReportGenerator()
@pytest.hookimpl(tryfirst=True)
def pytest_configure(config):
"""
配置钩子
"""
report_generator.start()
@pytest.hookimpl(trylast=True)
def pytest_runtest_logreport(report):
"""
记录测试报告
"""
report_generator.add_result(report)
@pytest.hookimpl(trylast=True)
def pytest_sessionfinish(session, exitstatus):
"""
会话结束钩子
"""
report_generator.finish()
# 生成自定义报告
report_generator.generate_json_report()
report_generator.generate_html_report()
6.8 JUnit XML报告生成
JUnit XML报告格式广泛用于CI/CD集成。
JUnit XML报告配置
ini
# pytest.ini
[pytest]
# JUnit XML报告配置
junit_family = xunit2
junit_suite_name = pytest测试套件
junit_log_passing_tests = true
python
# test_junit_xml.py
import pytest
class TestJUnitXML:
"""
JUnit XML报告测试类
"""
def test_passing_test(self):
"""
通过的测试
"""
assert 1 + 1 == 2
def test_failing_test(self):
"""
失败的测试
"""
assert 1 + 1 == 3
@pytest.mark.skip(reason="这个测试被跳过")
def test_skipped_test(self):
"""
跳过的测试
"""
assert True
@pytest.mark.xfail(reason="这个测试预期失败")
def test_xfailed_test(self):
"""
预期失败的测试
"""
assert 1 + 1 == 3
6.9 测试报告最佳实践
使用测试报告时需要遵循一些最佳实践。
测试报告最佳实践示例
python
# conftest.py
import pytest
import os
from datetime import datetime
# 最佳实践1:使用环境变量控制报告生成
def pytest_addoption(parser):
"""
添加命令行选项
"""
parser.addoption("--generate-reports", action="store_true", default=False,
help="生成测试报告")
parser.addoption("--report-dir", action="store", default="reports",
help="报告输出目录")
# 最佳实践2:创建报告目录
@pytest.hookimpl(tryfirst=True)
def pytest_configure(config):
"""
配置钩子
"""
if config.getoption("--generate-reports"):
report_dir = config.getoption("--report-dir")
os.makedirs(report_dir, exist_ok=True)
print(f"\n报告输出目录: {report_dir}")
# 最佳实践3:添加时间戳到报告文件名
def get_report_filename(base_name, extension):
"""
获取带时间戳的报告文件名
"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"{base_name}_{timestamp}.{extension}"
# 最佳实践4:生成多种格式的报告
@pytest.hookimpl(trylast=True)
def pytest_sessionfinish(session, exitstatus):
"""
会话结束钩子
"""
config = session.config
if config.getoption("--generate-reports"):
report_dir = config.getoption("--report-dir")
# 生成HTML报告
html_report = os.path.join(report_dir, get_report_filename("report", "html"))
print(f"\nHTML报告: {html_report}")
# 生成JUnit XML报告
junit_report = os.path.join(report_dir, get_report_filename("junit", "xml"))
print(f"JUnit XML报告: {junit_report}")
# 生成覆盖率报告
cov_report = os.path.join(report_dir, get_report_filename("coverage", "html"))
print(f"覆盖率报告: {cov_report}")
# 最佳实践5:添加测试环境信息到报告
@pytest.hookimpl(tryfirst=True)
def pytest_report_header(config, startdir):
"""
报告头部信息
"""
lines = []
lines.append("=== 测试环境信息 ===")
lines.append(f"测试目录: {startdir}")
lines.append(f"Python版本: {config.pythonversion}")
lines.append(f"pytest版本: {pytest.__version__}")
lines.append(f"操作系统: {os.name}")
lines.append(f"测试时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
return "\n".join(lines)
# 最佳实践6:自定义测试状态显示
@pytest.hookimpl(trylast=True)
def pytest_report_teststatus(report, config):
"""
自定义测试状态显示
"""
if report.when == "call":
if report.passed:
return "✓", "PASSED", {"green": True}
elif report.failed:
return "✗", "FAILED", {"red": True}
elif report.skipped:
return "⊘", "SKIPPED", {"yellow": True}
return None
# 最佳实践7:添加测试摘要信息
@pytest.hookimpl(trylast=True)
def pytest_terminal_summary(terminalreporter, exitstatus, config):
"""
终端摘要报告
"""
stats = terminalreporter.stats
total = sum(len(stats.get(key, [])) for key in ["passed", "failed", "skipped"])
passed = len(stats.get("passed", []))
failed = len(stats.get("failed", []))
skipped = len(stats.get("skipped", []))
print("\n=== 测试摘要 ===")
print(f"总测试数: {total}")
print(f"通过: {passed}")
print(f"失败: {failed}")
print(f"跳过: {skipped}")
if total > 0:
print(f"通过率: {passed/total*100:.1f}%")
# 最佳实践8:记录测试执行时间
@pytest.hookimpl(trylast=True)
def pytest_runtest_logreport(report):
"""
记录测试执行时间
"""
if report.when == "call" and report.duration > 1.0:
print(f"\n警告: 测试 {report.nodeid} 执行时间过长 ({report.duration:.2f}秒)")
# 最佳实践9:添加失败测试的详细信息
@pytest.hookimpl(trylast=True)
def pytest_runtest_makereport(item, call):
"""
创建测试报告
"""
if call.when == "call" and call.excinfo:
# 添加失败测试的详细信息
print(f"\n失败测试: {item.nodeid}")
print(f"失败原因: {call.excinfo.value}")
print(f"失败位置: {call.excinfo.tb}")
# 最佳实践10:生成测试趋势报告
class TestTrendTracker:
"""
测试趋势跟踪器
"""
def __init__(self):
self.history = []
def add_record(self, total, passed, failed, skipped, duration):
"""
添加测试记录
"""
record = {
"timestamp": datetime.now().isoformat(),
"total": total,
"passed": passed,
"failed": failed,
"skipped": skipped,
"duration": duration
}
self.history.append(record)
def generate_trend_report(self, filename="test_trend.json"):
"""
生成趋势报告
"""
import json
with open(filename, "w", encoding="utf-8") as f:
json.dump(self.history, f, indent=2, ensure_ascii=False)
print(f"\n测试趋势报告已生成: {filename}")
trend_tracker = TestTrendTracker()
@pytest.hookimpl(trylast=True)
def pytest_sessionfinish_with_trend(session, exitstatus):
"""
会话结束钩子 - 趋势跟踪
"""
stats = session.config.pluginmanager.get_plugin("terminalreporter").stats
total = sum(len(stats.get(key, [])) for key in ["passed", "failed", "skipped"])
passed = len(stats.get("passed", []))
failed = len(stats.get("failed", []))
skipped = len(stats.get("skipped", []))
# 计算总耗时
duration = sum(report.duration for report in stats.get("passed", []) +
stats.get("failed", []) + stats.get("skipped", []))
# 添加记录
trend_tracker.add_record(total, passed, failed, skipped, duration)
# 生成趋势报告
trend_tracker.generate_trend_report()
6.10 报告集成与CI/CD
测试报告需要与CI/CD系统集成。
CI/CD集成示例
yaml
# .github/workflows/pytest.yml
name: Pytest Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8, 3.9, 3.10]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest pytest-cov pytest-html pytest-xdist
pip install -r requirements.txt
- name: Run tests with coverage
run: |
pytest --cov=src --cov-report=xml --cov-report=html --html=report.html --junitxml=junit.xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v2
with:
file: ./coverage.xml
flags: unittests
name: codecov-umbrella
- name: Upload test results
if: always()
uses: actions/upload-artifact@v2
with:
name: test-results-py${{ matrix.python-version }}
path: |
report.html
junit.xml
htmlcov/
python
# conftest.py
import pytest
import os
# CI/CD环境检测
def is_ci_environment():
"""
检测是否在CI/CD环境中运行
"""
return os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true"
@pytest.hookimpl(tryfirst=True)
def pytest_configure_ci(config):
"""
CI/CD环境配置
"""
if is_ci_environment():
print("\n检测到CI/CD环境")
# CI/CD环境下的特殊配置
config.addinivalue_line("markers", "ci: CI/CD专用测试")
# 设置更严格的覆盖率要求
os.environ["COVERAGE_THRESHOLD"] = "90"
@pytest.hookimpl(trylast=True)
def pytest_sessionfinish_ci(session, exitstatus):
"""
CI/CD环境会话结束
"""
if is_ci_environment():
print("\nCI/CD测试完成")
# 生成CI/CD专用报告
print(f"退出状态码: {exitstatus}")
# 检查测试结果
if exitstatus != 0:
print("测试失败,CI/CD流程将终止")
else:
print("测试通过,CI/CD流程继续")
7. pytest插件生态与高级特性
7.1 pytest插件生态概述
pytest拥有丰富的插件生态,提供了各种扩展功能。
pytest插件分类
pytest插件生态
测试增强插件
报告生成插件
并发执行插件
框架集成插件
性能测试插件
自定义插件
pytest-mock
pytest-cov
pytest-timeout
pytest-html
allure-pytest
pytest-reportlog
pytest-xdist
pytest-parallel
pytest-forked
pytest-django
pytest-flask
pytest-asyncio
pytest-benchmark
pytest-profiling
pytest-speed
自定义钩子
自定义标记
自定义断言
插件管理基础
python
# test_plugin_management.py
import pytest
# 查看已安装的插件
# 命令:pytest --version
# 输出示例:
# pytest 7.4.0
# plugins: xdist-3.3.1, cov-4.1.0, html-3.2.0
def test_plugin_info(request):
"""
测试插件信息
注意:pytest.config在pytest 7.0+中已被移除,需要使用request.config
"""
# 获取pytest配置
config = request.config
# 获取插件管理器
plugin_manager = config.pluginmanager
# 获取已安装的插件
plugins = plugin_manager.get_plugins()
print(f"\n已安装插件数量: {len(plugins)}")
for plugin in plugins:
print(f"插件名称: {plugin.__name__ if hasattr(plugin, '__name__') else plugin}")
assert len(plugins) > 0
7.2 常用pytest插件介绍
pytest生态系统中有许多常用插件,提供了丰富的功能。
pytest-xdist并发测试
python
# test_xdist_plugin.py
import pytest
import time
# 安装:pip install pytest-xdist
def test_xdist_basic_1():
"""
并发测试1
"""
time.sleep(0.5)
assert True
def test_xdist_basic_2():
"""
并发测试2
"""
time.sleep(0.5)
assert True
def test_xdist_basic_3():
"""
并发测试3
"""
time.sleep(0.5)
assert True
def test_xdist_basic_4():
"""
并发测试4
"""
time.sleep(0.5)
assert True
# 命令:pytest -n 4 test_xdist_plugin.py
# -n 或 --numprocesses:指定并发进程数
# -n auto:自动检测CPU核心数
# pytest-xdist配置
"""
在pytest.ini中配置:
[pytest]
addopts = -n auto
"""
pytest-asyncio异步测试
python
# test_asyncio_plugin.py
import pytest
import asyncio
# 安装:pip install pytest-asyncio
@pytest.mark.asyncio
async def test_async_basic():
"""
基础异步测试
"""
await asyncio.sleep(0.1)
assert True
@pytest.mark.asyncio
async def test_async_with_fixture(asyncio_loop):
"""
带fixture的异步测试
"""
await asyncio.sleep(0.1)
assert asyncio_loop is not None
@pytest.fixture
async def async_resource():
"""
异步fixture
"""
await asyncio.sleep(0.1)
return {"data": "async_data"}
@pytest.mark.asyncio
async def test_async_with_resource(async_resource):
"""
使用异步资源
"""
assert async_resource["data"] == "async_data"
# pytest-asyncio配置
"""
在pytest.ini中配置:
[pytest]
asyncio_mode = auto
"""
# 异步模式说明
"""
asyncio_mode参数:
- auto:自动检测async/await语法
- strict:严格模式,需要显式标记
- auto_strict:自动检测但严格模式
"""
pytest-timeout超时控制
python
# test_timeout_plugin.py
import pytest
import time
# 安装:pip install pytest-timeout
def test_timeout_fast():
"""
快速测试
"""
time.sleep(0.1)
assert True
def test_timeout_slow():
"""
慢速测试
"""
time.sleep(0.5)
assert True
@pytest.mark.timeout(1)
def test_timeout_with_marker():
"""
带超时标记的测试
"""
time.sleep(0.5)
assert True
# pytest-timeout配置
"""
在pytest.ini中配置:
[pytest]
timeout = 5 # 全局超时时间(秒)
timeout_method = thread # 超时方法:thread或signal
"""
# 超时方法说明
"""
timeout_method参数:
- thread:使用线程实现超时(跨平台)
- signal:使用信号实现超时(仅Unix)
"""
7.3 pytest-xdist并发测试详解
pytest-xdist提供了强大的并发测试功能。
pytest-xdist高级特性
python
# test_xdist_advanced.py
import pytest
# 并发测试分组
@pytest.mark.xdist_group("group1")
def test_xdist_group_1_1():
"""
并发测试组1-1
"""
assert True
@pytest.mark.xdist_group("group1")
def test_xdist_group_1_2():
"""
并发测试组1-2
"""
assert True
@pytest.mark.xdist_group("group2")
def test_xdist_group_2_1():
"""
并发测试组2-1
"""
assert True
@pytest.mark.xdist_group("group2")
def test_xdist_group_2_2():
"""
并发测试组2-2
"""
assert True
# 命令:pytest -n 4 test_xdist_advanced.py
# 同一组的测试会在同一个worker进程中执行
# 锁定测试顺序
"""
命令:pytest -n 4 --dist=loadscope test_xdist_advanced.py
--dist参数:
- each:每个测试独立分发
- loadscope:按模块/类分发,保持测试顺序
- loadfile:按文件分发
"""
# worker间通信
@pytest.fixture(scope="session")
def shared_data():
"""
共享数据fixture
在xdist中,session作用域的fixture在每个worker中独立创建
"""
return {"counter": 0}
def test_shared_data_1(shared_data):
"""
测试共享数据1
"""
shared_data["counter"] += 1
assert shared_data["counter"] == 1
def test_shared_data_2(shared_data):
"""
测试共享数据2
"""
shared_data["counter"] += 1
assert shared_data["counter"] == 1 # 每个worker独立计数
7.4 pytest-asyncio异步测试详解
pytest-asyncio提供了完整的异步测试支持。
pytest-asyncio高级特性
python
# test_asyncio_advanced.py
import pytest
import asyncio
# 异步事件循环配置
@pytest.fixture
def event_loop():
"""
自定义事件循环fixture
"""
loop = asyncio.new_event_loop()
yield loop
loop.close()
# 异步fixture
@pytest.fixture
async def async_database():
"""
异步数据库fixture
"""
# 模拟异步数据库连接
await asyncio.sleep(0.1)
db = {"connection": "active", "data": []}
yield db
# 清理
await asyncio.sleep(0.1)
db["connection"] = "closed"
@pytest.mark.asyncio
async def test_async_database(async_database):
"""
测试异步数据库
"""
assert async_database["connection"] == "active"
async_database["data"].append("test_data")
assert len(async_database["data"]) == 1
# 异步上下文管理器
class AsyncContextManager:
"""
异步上下文管理器
"""
async def __aenter__(self):
await asyncio.sleep(0.1)
return {"resource": "active"}
async def __aexit__(self, exc_type, exc_val, exc_tb):
await asyncio.sleep(0.1)
return False
@pytest.mark.asyncio
async def test_async_context_manager():
"""
测试异步上下文管理器
"""
async with AsyncContextManager() as resource:
assert resource["resource"] == "active"
# 异步生成器
async def async_generator():
"""
异步生成器
"""
for i in range(3):
await asyncio.sleep(0.1)
yield i
@pytest.mark.asyncio
async def test_async_generator():
"""
测试异步生成器
"""
results = []
async for value in async_generator():
results.append(value)
assert results == [0, 1, 2]
# 并发异步测试
@pytest.mark.asyncio
async def test_async_concurrent():
"""
测试并发异步操作
"""
async def async_task(name, delay):
await asyncio.sleep(delay)
return name
tasks = [
async_task("task1", 0.1),
async_task("task2", 0.2),
async_task("task3", 0.3)
]
results = await asyncio.gather(*tasks)
assert len(results) == 3
assert "task1" in results
7.5 pytest-django Django测试
pytest-django提供了Django框架的完整测试支持。
pytest-django基础使用
python
# test_django_plugin.py
import pytest
from django.test import TestCase
from django.contrib.auth.models import User
# 安装:pip install pytest-django
# pytest-django配置
"""
在pytest.ini中配置:
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings
python_files = test_*.py
python_classes = Test*
python_functions = test_*
# 或者在conftest.py中配置:
import pytest
@pytest.fixture(scope='session')
def django_db_setup():
"""
Django数据库设置
"""
pass
"""
@pytest.mark.django_db
def test_create_user():
"""
测试创建用户
"""
user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
)
assert user.username == 'testuser'
assert user.email == 'test@example.com'
@pytest.mark.django_db
def test_user_authentication():
"""
测试用户认证
"""
user = User.objects.create_user(
username='testuser',
password='testpass123'
)
authenticated = user.check_password('testpass123')
assert authenticated
# Django测试客户端
@pytest.mark.django_db
def test_client_request(client):
"""
测试客户端请求
"""
response = client.get('/')
assert response.status_code == 200
# Django URL测试
from django.urls import reverse
@pytest.mark.django_db
def test_url_resolution():
"""
测试URL解析
"""
url = reverse('admin:index')
assert url == '/admin/'
# Django模型测试
@pytest.mark.django_db
class TestUserModel(TestCase):
"""
用户模型测试类
"""
def setUp(self):
"""
测试设置
"""
self.user = User.objects.create_user(
username='testuser',
password='testpass123'
)
def test_user_creation(self):
"""
测试用户创建
"""
assert User.objects.count() == 1
assert self.user.username == 'testuser'
def test_user_str(self):
"""
测试用户字符串表示
"""
assert str(self.user) == 'testuser'
7.6 pytest-flask Flask测试
pytest-flask提供了Flask框架的完整测试支持。
pytest-flask基础使用
python
# test_flask_plugin.py
import pytest
from flask import Flask, jsonify
# 安装:pip install pytest-flask
# pytest-flask配置
"""
在conftest.py中配置:
import pytest
from myapp import create_app
@pytest.fixture
def app():
"""
应用fixture
"""
app = create_app('testing')
return app
@pytest.fixture
def client(app):
"""
客户端fixture
"""
return app.test_client()
@pytest.fixture
def runner(app):
"""
测试运行器fixture
"""
return app.test_cli_runner()
"""
def test_index(client):
"""
测试首页
"""
response = client.get('/')
assert response.status_code == 200
assert b'Hello' in response.data
def test_api_endpoint(client):
"""
测试API端点
"""
response = client.get('/api/data')
assert response.status_code == 200
data = response.get_json()
assert 'data' in data
def test_post_request(client):
"""
测试POST请求
"""
response = client.post('/api/users', json={
'name': 'Test User',
'email': 'test@example.com'
})
assert response.status_code == 201
data = response.get_json()
assert data['name'] == 'Test User'
def test_authentication(client, auth):
"""
测试认证
"""
# 登录
response = auth.login()
assert response.status_code == 200
# 访问受保护的资源
response = client.get('/protected')
assert response.status_code == 200
# Flask认证fixture
@pytest.fixture
def auth(client):
"""
认证fixture
"""
class AuthActions:
def __init__(self, client):
self._client = client
def login(self, username='test', password='test'):
return self._client.post('/auth/login', data={
'username': username,
'password': password
})
def logout(self):
return self._client.get('/auth/logout')
return AuthActions(client)
7.7 pytest-benchmark性能测试
pytest-benchmark提供了性能测试和基准测试功能。
pytest-benchmark基础使用
python
# test_benchmark_plugin.py
import pytest
# 安装:pip install pytest-benchmark
def test_list_append(benchmark):
"""
测试列表追加性能
"""
def append_to_list():
lst = []
for i in range(1000):
lst.append(i)
return lst
result = benchmark(append_to_list)
assert len(result) == 1000
def test_dict_lookup(benchmark):
"""
测试字典查找性能
"""
data = {i: i * 2 for i in range(1000)}
def lookup_dict():
return data[500]
result = benchmark(lookup_dict)
assert result == 1000
def test_string_concatenation(benchmark):
"""
测试字符串连接性能
"""
def concat_strings():
return ''.join(str(i) for i in range(1000))
result = benchmark(concat_strings)
assert len(result) == 2890
# 基准测试分组
class TestBenchmarkGroup:
"""
基准测试组
"""
def test_list_creation(self, benchmark):
"""
测试列表创建
"""
result = benchmark(list, range(1000))
assert len(result) == 1000
def test_set_creation(self, benchmark):
"""
测试集合创建
"""
result = benchmark(set, range(1000))
assert len(result) == 1000
# 基准测试配置
"""
命令行参数:
--benchmark-only:只运行基准测试
--benchmark-autosave:自动保存基准测试结果
--benchmark-save:保存基准测试结果到文件
--benchmark-compare:与之前的结果比较
--benchmark-columns:指定显示的列
"""
# 基准测试装饰器
@pytest.mark.benchmark(group="string_operations")
def test_string_operations(benchmark):
"""
字符串操作基准测试
"""
def string_operations():
s = "hello world"
s = s.upper()
s = s.lower()
s = s.replace(" ", "_")
return s
result = benchmark(string_operations)
assert result == "hello_world"
7.8 自定义pytest插件开发
可以开发自定义的pytest插件来扩展功能。
自定义插件开发示例
python
# my_pytest_plugin.py
import pytest
def pytest_configure(config):
"""
配置钩子
"""
config.addinivalue_line("markers", "custom: 自定义标记")
print("\n[自定义插件] pytest_configure: 插件已加载")
def pytest_collection_modifyitems(session, config, items):
"""
修改测试项钩子
"""
for item in items:
# 为所有测试添加自定义标记
if "custom" not in item.keywords:
item.add_marker(pytest.mark.custom)
def pytest_runtest_setup(item):
"""
测试设置钩子
"""
print(f"\n[自定义插件] pytest_runtest_setup: {item.name}")
def pytest_runtest_teardown(item, nextitem):
"""
测试清理钩子
"""
print(f"[自定义插件] pytest_runtest_teardown: {item.name}")
def pytest_report_header(config, startdir):
"""
报告头部钩子
"""
return "\n[自定义插件] pytest_report_header: 自定义测试报告"
def pytest_terminal_summary(terminalreporter, exitstatus, config):
"""
终端摘要钩子
"""
print("\n[自定义插件] pytest_terminal_summary: 测试完成")
# 插件入口点
"""
在setup.py中配置:
from setuptools import setup
setup(
name='my-pytest-plugin',
version='0.1.0',
packages=['my_pytest_plugin'],
entry_points={
'pytest11': [
'my_plugin = my_pytest_plugin',
],
},
)
"""
插件注册与安装
python
# setup.py
from setuptools import setup
setup(
name='my-pytest-plugin',
version='0.1.0',
description='My custom pytest plugin',
packages=['my_pytest_plugin'],
install_requires=[
'pytest>=7.0.0',
],
entry_points={
'pytest11': [
'my_plugin = my_pytest_plugin',
],
},
classifiers=[
'Framework :: Pytest',
'Programming Language :: Python :: 3',
],
)
# 安装插件
# pip install -e .
# 验证插件安装
# pytest --version
# 输出示例:
# pytest 7.4.0
# plugins: my-pytest-plugin-0.1.0, ...
7.9 插件管理与配置
有效的插件管理对于维护测试环境至关重要。
插件管理最佳实践
python
# conftest.py
import pytest
# 插件配置管理
def pytest_configure(config):
"""
插件配置管理
"""
# 检查必需插件
required_plugins = ['xdist', 'cov', 'html']
plugin_manager = config.pluginmanager
for plugin_name in required_plugins:
plugin_found = any(
plugin_name in str(plugin)
for plugin in plugin_manager.get_plugins()
)
if not plugin_found:
print(f"\n警告: 必需插件 '{plugin_name}' 未安装")
# 插件版本控制
def pytest_report_header(config, startdir):
"""
报告插件版本信息
"""
lines = ["=== 插件版本信息 ==="]
# 获取插件信息
plugin_manager = config.pluginmanager
plugins = plugin_manager.get_plugins()
for plugin in plugins:
if hasattr(plugin, '__version__'):
plugin_name = plugin.__name__
plugin_version = plugin.__version__
lines.append(f"{plugin_name}: {plugin_version}")
return "\n".join(lines)
# 插件依赖管理
"""
在requirements.txt中管理插件依赖:
pytest>=7.4.0
pytest-xdist>=3.3.0
pytest-cov>=4.1.0
pytest-html>=3.2.0
pytest-asyncio>=0.21.0
pytest-timeout>=2.1.0
pytest-benchmark>=4.0.0
"""
# 插件配置文件
"""
在pytest.ini中配置插件:
[pytest]
# 插件配置
addopts =
--strict-markers
--strict-config
-v
--tb=short
# 禁用特定插件
# addopts = -p no:xdist
# 启用特定插件
# addopts = -p my_plugin
"""
7.10 插件最佳实践
使用pytest插件时需要遵循一些最佳实践。
插件使用最佳实践
python
# conftest.py
import pytest
import os
# 最佳实践1:按需启用插件
def pytest_addoption(parser):
"""
添加插件控制选项
"""
parser.addoption("--enable-xdist", action="store_true", default=False,
help="启用xdist并发测试")
parser.addoption("--enable-cov", action="store_true", default=False,
help="启用覆盖率测试")
# 最佳实践2:条件加载插件
@pytest.hookimpl(tryfirst=True)
def pytest_configure_conditional(config):
"""
条件配置插件
"""
# 根据环境变量启用插件
if os.getenv("CI"):
print("CI环境:启用所有插件")
config.addinivalue_line("markers", "ci: CI专用测试")
else:
print("本地环境:启用基础插件")
# 最佳实践3:插件冲突解决
def pytest_configure_conflict_resolution(config):
"""
解决插件冲突
"""
# 检查插件冲突
xdist_enabled = config.getoption("--enable-xdist")
cov_enabled = config.getoption("--enable-cov")
if xdist_enabled and cov_enabled:
print("\n警告: xdist和cov可能存在冲突")
print("建议: 使用 --dist=loadscope 参数")
# 最佳实践4:插件性能优化
@pytest.hookimpl(tryfirst=True)
def pytest_configure_performance(config):
"""
插件性能优化
"""
# 在本地开发时禁用耗时插件
if not os.getenv("CI"):
print("本地开发:禁用耗时插件")
# 可以在这里禁用某些插件
# 最佳实践5:插件错误处理
@pytest.hookimpl(tryfirst=True)
def pytest_configure_error_handling(config):
"""
插件错误处理
"""
try:
# 尝试配置插件
config.addinivalue_line("markers", "custom: 自定义标记")
except Exception as e:
print(f"\n插件配置错误: {e}")
# 不抛出异常,避免影响pytest执行
# 最佳实践6:插件文档化
"""
在conftest.py中添加插件使用文档:
# 插件使用说明
# 1. pytest-xdist: 并发测试
# 命令: pytest -n 4
# 说明: 使用4个进程并发执行测试
#
# 2. pytest-cov: 覆盖率测试
# 命令: pytest --cov=src
# 说明: 生成代码覆盖率报告
#
# 3. pytest-html: HTML报告
# 命令: pytest --html=report.html
# 说明: 生成HTML格式的测试报告
"""
# 最佳实践7:插件版本兼容性
def pytest_configure_version_compatibility(config):
"""
插件版本兼容性检查
"""
# 检查pytest版本
pytest_version = config.pluginmanager.get_plugin("pytest").__version__
required_version = "7.0.0"
if pytest_version < required_version:
print(f"\n警告: pytest版本 {pytest_version} 低于推荐版本 {required_version}")
# 最佳实践8:插件配置验证
def pytest_configure_validation(config):
"""
插件配置验证
"""
# 验证必需的配置
required_config = [
("DJANGO_SETTINGS_MODULE", "Django设置模块"),
("TEST_DATABASE_URL", "测试数据库URL")
]
for env_var, description in required_config:
if not os.getenv(env_var):
print(f"\n警告: 缺少环境变量 {env_var} ({description})")
# 最佳实践9:插件监控
class PluginMonitor:
"""
插件监控器
"""
def __init__(self):
self.plugin_calls = {}
def record_call(self, plugin_name, hook_name):
"""
记录插件调用
"""
key = f"{plugin_name}.{hook_name}"
self.plugin_calls[key] = self.plugin_calls.get(key, 0) + 1
def report(self):
"""
报告插件使用情况
"""
print("\n=== 插件使用情况 ===")
for call, count in sorted(self.plugin_calls.items()):
print(f"{call}: {count} 次")
plugin_monitor = PluginMonitor()
@pytest.hookimpl(tryfirst=True)
def pytest_runtest_setup_with_monitor(item):
"""
带监控的测试设置
"""
plugin_monitor.record_call("pytest", "runtest_setup")
# 最佳实践10:插件卸载与清理
def pytest_unconfigure_cleanup(config):
"""
插件卸载与清理
"""
print("\n[插件清理] 开始清理插件资源")
# 清理插件创建的资源
# 关闭数据库连接
# 清理临时文件
# 释放内存
print("[插件清理] 插件资源清理完成")