pytest 完整学习指南

pytest 完整学习指南

目录

介绍

什么是 pytest

pytest 是一个成熟的、功能丰富的 Python 测试框架,它使编写小型测试变得简单,同时也能扩展以支持应用程序和库的复杂功能测试。

主要特性

  • 简单易用:编写测试用例非常简单,只需要使用 assert 语句
  • 自动发现:自动发现测试文件和测试函数
  • 丰富的断言:提供详细的断言失败信息
  • Fixture 系统:强大的测试夹具系统,支持依赖注入
  • 参数化测试:轻松创建数据驱动的测试
  • 插件生态:丰富的插件生态系统
  • 并行执行:支持并行运行测试
  • 详细报告:支持多种格式的测试报告

与其他测试框架对比

特性pytestunittestnose2
学习曲线简单中等中等
断言方式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 配置
  1. 设置测试运行器

    • File → Settings → Tools → Python Integrated Tools
    • 设置 Default test runner 为 pytest
  2. 配置测试模板

    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报告包含以下部分:

  1. 环境信息:Python版本、平台、插件等
  2. 测试摘要:总数、通过、失败、跳过等统计
  3. 测试结果表格:详细的测试结果列表
  4. 失败详情:失败测试的详细信息
  5. 额外信息:截图、日志、自定义数据等

报告特点:

  • 响应式设计,支持移动设备
  • 可排序和过滤的结果表格
  • 折叠/展开的详细信息
  • 支持自定义样式和内容

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 报告特性

报告内容
  1. 概览页面:测试执行统计、趋势图表
  2. 分类页面:按Feature、Story、Severity分类
  3. 套件页面:按测试套件组织
  4. 图表页面:各种统计图表
  5. 时间线:测试执行时间线
  6. 行为驱动: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完整学习指南涵盖了从基础到高级的所有重要概念,包括:

  1. 基础功能:安装、测试发现、执行
  2. 断言和异常:各种断言方式和异常测试
  3. 跳过和标记:灵活的测试控制
  4. Fixture系统:强大的测试夹具
  5. 参数化测试:数据驱动测试
  6. HTML报告:pytest-html集成
  7. Allure报告:专业的测试报告
  8. 高级功能:插件开发、钩子函数
  9. 最佳实践:项目结构、CI/CD集成
  10. 实战案例:真实的测试场景

通过这份文档,用户可以快速掌握pytest的使用,并在实际项目中应用这些知识。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值