python自动化测试:pytest(二)

2. 参数化测试(数据驱动)

Pytest 参数化与数据驱动测试详解

1. 基本参数化使用

@pytest.mark.parametrize 装饰器

import pytest

# 基本参数化示例
@pytest.mark.parametrize("input, expected", [
    (1, 2),
    (2, 4),
    (3, 6)
])
def test_multiply_by_two(input, expected):
    assert input * 2 == expected

# 多个参数示例
@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (5, 3, 8),
    (10, -2, 8)
])
def test_addition(a, b, expected):
    assert a + b == expected

2. 参数组合测试

import pytest

# 参数组合(笛卡尔积)
@pytest.mark.parametrize("x", [0, 1])
@pytest.mark.parametrize("y", [2, 3])
def test_combinations(x, y):
    print(f"Testing with x={x}, y={y}")
    # 测试逻辑

# 等价于测试以下组合:
# (0, 2), (0, 3), (1, 2), (1, 3)

3. 从函数动态生成参数

import pytest

def generate_test_data():
    """动态生成测试数据"""
    return [
        (i, i * 2) for i in range(5)
    ]

@pytest.mark.parametrize("input, expected", generate_test_data())
def test_dynamic_data(input, expected):
    assert input * 2 == expected

4. 从外部文件读取数据

JSON 文件数据驱动

// test_data.json
[
    {"input": 1, "expected": 2},
    {"input": 5, "expected": 10},
    {"input": 10, "expected": 20}
]
import pytest
import json

def load_test_data():
    with open('test_data.json', 'r') as f:
        return json.load(f)

@pytest.mark.parametrize("data", load_test_data())
def test_with_json_data(data):
    assert data["input"] * 2 == data["expected"]

CSV 文件数据驱动

import pytest
import csv

def load_csv_data():
    test_data = []
    with open('test_data.csv', 'r') as f:
        reader = csv.DictReader(f)
        for row in reader:
            test_data.append((
                int(row['input']), 
                int(row['expected'])
            ))
    return test_data

@pytest.mark.parametrize("input, expected", load_csv_data())
def test_with_csv_data(input, expected):
    assert input * 2 == expected

5. IDs 参数化测试命名

import pytest

def id_func(val):
    """ids 函数会分别接收每个参数的值"""
    if isinstance(val, int):
        return f"input_{val}"
    elif isinstance(val, str):
        return f"string_{val}"
    return str(val)

@pytest.mark.parametrize(
    "input, expected",
    [
        (1, 2),
        (5, 10),
        (10, 20)
    ],
    ids=id_func,   # 这里不是接受元组,而是每一个元素
)
def test_with_custom_ids(input, expected):
    assert input * 2 == expected

6. 类级别的参数化

import pytest

@pytest.mark.parametrize("input, expected", [
    (1, 2),
    (5, 10)
])
class TestMathOperations:
    """类级别的参数化 - 所有方法都会参数化"""
    
    def test_multiply(self, input, expected):
        assert input * 2 == expected
    
    def test_division(self, input, expected):
        assert expected / 2 == input

7. 间接参数化

import pytest

# 用于设置测试环境的fixture
@pytest.fixture
def setup_data(request):
    """根据参数设置不同的测试数据"""
    return request.param * 2

@pytest.mark.parametrize("setup_data, expected", [
    (1, 2),
    (5, 10)
], indirect=["setup_data"])  # setup_data参数通过fixture处理
def test_with_indirect_param(setup_data, expected):
    assert setup_data == expected

# 结构是setup,expected==》2, 2 和10,10
# 注意indirect可以放多个参数,但是要新建多个fixture,setup_data是上面的fixture的名字,注意fixture,parametrize,test_function的名字都是要统一的

8. 复杂数据结构的参数化

import pytest

@pytest.mark.parametrize("test_input, expected", [
    # 字典参数
    ({"a": 1, "b": 2}, 3),
    ({"a": 5, "b": -3}, 2),
    
    # 列表参数
    ([1, 2, 3], 6),
    ([10, 20, 30], 60),
    
    # 元组参数
    ((1, 2), 3),
    ((5, -3), 2)
])
def test_complex_data_structures(test_input, expected):
    if isinstance(test_input, dict):
        result = test_input['a'] + test_input['b']
    elif isinstance(test_input, (list, tuple)):
        result = sum(test_input)
    else:
        result = test_input
    
    assert result == expected

9. 条件参数化

import pytest

def should_run_test(condition):
    """根据条件决定是否运行测试"""
    return condition == "run"

test_data = [
    ("run", 2, 4),      # 运行
    ("skip", 3, 6),     # 跳过
    ("run", 5, 10)      # 运行
]

@pytest.mark.parametrize("condition, input, expected", 
    [case for case in test_data if should_run_test(case[0])]
)
def test_conditional_parameterization(condition, input, expected):
    assert input * 2 == expected

10. 完整的实战示例

import pytest
import json
from typing import Dict, Any

# 从JSON文件加载测试数据
def load_user_test_cases():
    with open('user_test_cases.json', 'r') as f:
        return json.load(f)

# 用户验证测试类
class TestUserValidation:
    
    @pytest.mark.parametrize("user_data, expected_result", [
        (
            {"username": "validuser", "email": "test@example.com", "age": 25},
            {"valid": True, "errors": []}
        ),
        (
            {"username": "ab", "email": "invalid-email", "age": 15},
            {"valid": False, "errors": ["用户名太短", "邮箱格式错误", "年龄不足"]}
        )
    ])
    def test_user_validation(self, user_data, expected_result):
        # 实际的验证逻辑
        is_valid, errors = self.validate_user(user_data)
        
        assert is_valid == expected_result["valid"]
        assert set(errors) == set(expected_result["errors"])
    
    @pytest.mark.parametrize("test_case", load_user_test_cases())
    def test_with_external_data(self, test_case):
        """使用外部数据文件进行测试"""
        result = self.process_user_data(test_case["input"])
        assert result == test_case["expected_output"]
    
    def validate_user(self, user_data: Dict[str, Any]) -> tuple:
        """模拟用户验证逻辑"""
        errors = []
        
        # 用户名验证
        if len(user_data.get("username", "")) < 3:
            errors.append("用户名太短")
        
        # 邮箱验证
        if "@" not in user_data.get("email", ""):
            errors.append("邮箱格式错误")
        
        # 年龄验证
        if user_data.get("age", 0) < 18:
            errors.append("年龄不足")
        
        return len(errors) == 0, errors
    
    def process_user_data(self, input_data):
        """模拟数据处理逻辑"""
        # 实际的数据处理代码
        return {"processed": True, **input_data}

# 运行特定参数化测试的示例
if __name__ == "__main__":
    # 只运行特定参数化的测试
    pytest.main([
        "-v",
        "-k", "test_user_validation",  # 只运行包含此名称的测试
        "--tb=short"  # 简短的错误回溯
    ])

11. 最佳实践和技巧

测试数据管理

import pytest

class TestData:
    """集中管理测试数据"""
    
    VALID_CASES = [
        (1, 1, 2),
        (5, 3, 8),
        (10, -5, 5)
    ]
    
    INVALID_CASES = [
        ("a", 1, TypeError),
        (1, "b", TypeError)
    ]

@pytest.mark.parametrize("a, b, expected", TestData.VALID_CASES)
def test_valid_addition(a, b, expected):
    assert a + b == expected

@pytest.mark.parametrize("a, b, exception", TestData.INVALID_CASES)
def test_invalid_addition(a, b, exception):
    with pytest.raises(exception):
        a + b

动态跳过测试

import pytest
import sys

@pytest.mark.parametrize("input, expected", [
    pytest.param(1, 2, id="normal_case"),
    pytest.param(0, 0, id="zero_case"),
    pytest.param(-1, -2, marks=pytest.mark.skip(reason="负数暂不支持"), id="negative_case"),
    pytest.param(1000, 2000, marks=pytest.mark.skipif(
        sys.version_info < (3, 8), 
        reason="需要Python 3.8+"
    ), id="large_number_case")
])
def test_with_skipping(input, expected):
    assert input * 2 == expected

这些示例展示了 pytest 参数化和数据驱动测试的各种用法,可以根据实际测试需求选择合适的方法。

3. 标记和过滤测试

标记之前要配置pytest.ini

解决方法

方法1:在 pytest.ini 中注册标记(推荐)

创建 pytest.ini文件:

# pytest.ini
[pytest]
markers =
    slow: marks tests as slow (deselect with '-m "not slow"')
    fast: marks tests as fast
    smoke: smoke tests
    integration: integration tests
    regression: regression tests
    windows: windows specific tests
    linux: linux specific tests

方法2:在 pyproject.toml 中注册(新版本推荐)

# pyproject.toml
[tool.pytest.ini_options]
markers = [
    "slow: marks tests as slow",
    "fast: marks tests as fast", 
    "smoke: smoke tests",
    "integration: integration tests",
    "regression: regression tests"
]

方法3:在 setup.cfg 中注册

# setup.cfg
[tool:pytest]
markers =
    slow: marks tests as slow
    fast: marks tests as fast
    smoke: smoke tests

完整的项目结构示例

project/
├── pytest.ini          # 配置文件
├── conftest.py         # 可选:pytest配置
└── tests/
    ├── __init__.py
    ├── test_math.py
    └── test_api.py

pytest.ini 内容示例:

# pytest.ini
[pytest]
markers =
    slow: 标记运行缓慢的测试
    fast: 标记运行快速的测试  
    smoke: 冒烟测试(核心功能)
    integration: 集成测试
    regression: 回归测试
    database: 需要数据库的测试
    api: API相关测试
    ui: 用户界面测试
    skip: 跳过测试
    windows: 仅Windows平台
    linux: 仅Linux平台

# 可选:其他配置
addopts = -v --tb=short
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*

验证配置是否生效

创建测试文件验证:

# test_example.py
import pytest
import time

@pytest.mark.slow
def test_slow_operation():
    """这个测试应该可以正常运行,不会报错"""
    time.sleep(0.1)  # 模拟慢速操作
    assert True

@pytest.mark.fast  
def test_fast_operation():
    """快速测试"""
    assert 1 + 1 == 2

@pytest.mark.smoke
def test_smoke():
    """冒烟测试"""
    assert True

运行测试:

# 查看所有已注册的标记
pytest --markers

# 运行特定标记的测试
pytest -m slow -v
pytest -m "smoke or fast" -v

# 运行除慢速测试外的所有测试
pytest -m "not slow" -v

如果没有配置文件时的临时解决方案

如果暂时不想创建配置文件,可以使用以下方法之一:

方法A:使用 -p no:warnings忽略警告

pytest -p no:warnings -m slow

方法B:在 conftest.py 中动态注册

# conftest.py
def pytest_configure(config):
    """动态注册标记"""
    config.addinivalue_line(
        "markers", "slow: marks tests as slow"
    )
    config.addinivalue_line(
        "markers", "fast: marks tests as fast" 
    )
    config.addinivalue_line(
        "markers", "smoke: smoke tests"
    )

方法C:使用内置标记代替自定义标记

import pytest
import time

# 使用内置的skipif标记来模拟slow标记的功能
@pytest.mark.skipif(True, reason="慢速测试,默认跳过")
def test_slow_operation():
    time.sleep(2)
    assert True

# 运行时选择是否执行慢速测试
def test_slow_conditional():
    if os.getenv('RUN_SLOW_TESTS'):
        time.sleep(2)
    assert True

最佳实践建议

  1. 总是创建 pytest.ini:即使是小项目也建议创建配置文件
  2. 详细描述标记:为每个标记提供清晰的描述
  3. 团队统一:确保团队使用相同的标记体系
  4. 定期清理:删除不再使用的标记
# 完整的 pytest.ini 示例
[pytest]
markers =
    # 速度相关
    slow: 运行时间超过1秒的测试
    fast: 运行时间少于100ms的测试
    
    # 测试类型
    unit: 单元测试
    integration: 集成测试
    e2e: 端到端测试
    smoke: 核心功能冒烟测试
    regression: 回归测试
    
    # 功能模块
    api: API相关测试
    database: 数据库相关测试
    auth: 认证授权测试
    ui: 用户界面测试
    
    # 环境相关  
    windows: 仅Windows平台
    linux: 仅Linux平台
    macos: 仅macOS平台

# 其他配置
addopts = -ra -q --strict-markers
filterwarnings =
    ignore:.*deprecated.*:DeprecationWarning

创建好配置文件后,你的 @pytest.mark.slow就不会再报错了!

pytest 提供了丰富的标记(mark)和过滤机制,让我详细介绍各种用法:

1. 基本标记语法

定义和使用标记

import pytest

# 标记单个测试函数
@pytest.mark.slow
def test_slow_operation():
    import time
    time.sleep(2)
    assert True

# 标记测试类
@pytest.mark.integration
class TestIntegrationSuite:
    def test_database(self):
        assert True
    
    def test_api(self):
        assert True

# 多个标记
@pytest.mark.slow
@pytest.mark.integration
def test_slow_integration():
    assert True

2. 自定义标记注册

在 pytest.ini 中注册标记

# pytest.ini
[pytest]
markers =
    slow: 标记运行缓慢的测试
    integration: 集成测试
    smoke: 冒烟测试
    regression: 回归测试
    windows: 仅Windows平台运行
    linux: 仅Linux平台运行
    skip: 跳过测试
    xfail: 预期失败的测试

3. 内置标记

跳过测试

import pytest
import sys

# 无条件跳过
@pytest.mark.skip(reason="功能尚未实现")
def test_unimplemented():
    assert False

# 条件跳过
@pytest.mark.skipif(sys.version_info < (3, 8), reason="需要Python 3.8+")
def test_python38_feature():
    assert True

# 跳过整个模块
pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="不在Windows上运行")

@pytest.mark.skipif(not hasattr(sys, 'getwindowsversion'), reason="仅Windows平台")
def test_windows_specific():
    assert True

预期失败标记

import pytest

@pytest.mark.xfail(reason="已知问题,正在修复")
def test_buggy_feature():
    assert False  # 预期会失败

@pytest.mark.xfail(run=False, reason="太耗时,不运行")
def test_very_slow():
    import time
    time.sleep(10)
    assert True

# 严格模式:如果测试通过则报错
@pytest.mark.xfail(strict=True, reason="应该失败但通过了")
def test_should_fail_but_passes():
    assert True  # 这会报错,因为预期失败但实际通过了

4. 参数化标记

import pytest

# 为参数化测试的特定用例添加标记
@pytest.mark.parametrize(
    "input,expected",
    [
        pytest.param(1, 2, marks=pytest.mark.normal),
        pytest.param(5, 10, marks=pytest.mark.fast),
        pytest.param(10, 20, marks=pytest.mark.slow),
        pytest.param(-1, -2, marks=pytest.mark.xfail(reason="负数不支持")),
    ]
)
def test_parameterized_with_marks(input, expected):
    assert input * 2 == expected

5. 自定义标记与过滤

运行特定标记的测试

# 运行所有slow标记的测试
pytest -m slow

# 运行integration或smoke标记的测试
pytest -m "integration or smoke"

# 运行slow但不是integration的测试
pytest -m "slow and not integration"

# 运行smoke和regression的测试
pytest -m "smoke and regression"

复杂逻辑组合

import pytest

@pytest.mark.smoke
@pytest.mark.regression
class TestCriticalFunctionality:
    @pytest.mark.fast
    def test_quick_check(self):
        assert True
    
    @pytest.mark.slow
    @pytest.mark.integration
    def test_comprehensive(self):
        assert True

运行命令:

# 运行同时有smoke和regression标记的测试
pytest -m "smoke and regression"

# 运行有smoke标记,但没有slow标记的测试
pytest -m "smoke and not slow"

6. 基于测试名称的过滤

# test_math_operations.py
def test_addition():
    assert 1 + 1 == 2

def test_subtraction():
    assert 5 - 3 == 2

def test_multiplication():
    assert 2 * 3 == 6

class TestAdvancedMath:
    def test_division(self):
        assert 6 / 2 == 3
    
    def test_power(self):
        assert 2 ** 3 == 8

运行命令:

# 运行名称包含"add"的测试
pytest -k "add"

# 运行名称包含"TestAdvancedMath"的测试
pytest -k "TestAdvancedMath"

# 运行名称包含"test"但不包含"Advanced"的测试
pytest -k "test and not Advanced"

# 使用正则表达式
pytest -k "test_.*ion"  # 匹配test_addition, test_subtraction等

7. 基于目录和文件的过滤

# 运行特定目录下的测试
pytest tests/integration/

# 运行特定文件中的测试
pytest tests/unit/test_math.py

# 运行特定文件中的特定测试类
pytest tests/unit/test_math.py::TestAdvancedMath

# 运行特定测试方法
pytest tests/unit/test_math.py::TestAdvancedMath::test_division

8. 复杂过滤策略

结合多种过滤方式

# 多条件组合过滤
pytest -m "smoke and not slow" -k "api" tests/integration/

# 运行特定标记且名称包含特定字符串的测试
pytest -m regression -k "user" --tb=short

# 使用配置文件定义默认过滤

9. 动态标记

import pytest
import os

def pytest_runtest_setup(item):
    """动态为测试添加标记"""
    # 根据环境变量动态标记
    if os.getenv('TEST_ENV') == 'CI':
        item.add_marker(pytest.mark.ci)
    
    # 根据测试名称动态标记
    if 'integration' in item.nodeid:
        item.add_marker(pytest.mark.integration)

# 使用自定义标记装饰器
def conditional_mark(condition, mark):
    """条件性标记装饰器"""
    def decorator(func):
        if condition:
            return pytest.mark.__getattr__(mark)(func)
        return func
    return decorator

# 使用示例
@conditional_mark(os.getenv('ENVIRONMENT') == 'production', 'production')
def test_production_only():
    assert True

10. 标记的作用域和继承

import pytest

# 模块级别标记
pytestmark = pytest.mark.module_marker

# 类级别标记 - 会被所有方法继承
@pytest.mark.class_marker
class TestExample:
    # 这个方法会有 module_marker 和 class_marker
    def test_inherits_marks(self):
        assert True
    
    # 这个方法会有 module_marker, class_marker 和 method_marker
    @pytest.mark.method_marker
    def test_with_additional_mark(self):
        assert True

# 覆盖类级别标记,这里说的是覆盖了上面的类mark
@pytest.mark.new_class_marker
class TestOverride:
    @pytest.mark.override_marker
    def test_override(self):
        assert True

11. 实际项目中的标记策略

示例:完整的测试标记体系

# conftest.py
import pytest
import os

def pytest_configure(config):
    """配置自定义标记"""
    config.addinivalue_line(
        "markers", "smoke: 核心功能冒烟测试"
    )
    config.addinivalue_line(
        "markers", "integration: 集成测试"
    )
    config.addinivalue_line(
        "markers", "slow: 运行缓慢的测试"
    )
    config.addinivalue_line(
        "markers", "database: 需要数据库的测试"
    )

# test_suite.py
import pytest

@pytest.mark.smoke
class TestCoreFeatures:
    @pytest.mark.fast
    def test_user_login(self):
        assert True
    
    @pytest.mark.database
    def test_user_profile(self):
        assert True

@pytest.mark.integration
@pytest.mark.slow  
class TestIntegration:
    @pytest.mark.database
    def test_order_workflow(self):
        assert True
    
    def test_payment_integration(self):
        assert True

@pytest.mark.regression
class TestBugFixes:
    def test_fixed_issue_123(self):
        assert True

常用运行命令示例

# 日常开发:快速运行核心测试
pytest -m "smoke and not slow" -v

# CI流水线:运行所有非慢速测试
pytest -m "not slow" --tb=short

# 集成测试环境:运行集成测试
pytest -m integration -x  # 遇到失败立即停止

# 回归测试:运行所有回归测试
pytest -m regression --maxfail=5  # 最多允许5个失败

# 按目录和标记组合过滤
pytest tests/api/ -m "not database" -k "test_get"

# 生成测试报告
pytest -m smoke --junitxml=report.xml --html=report.html

12. 高级过滤技巧

使用pytest钩子进行自定义过滤

# conftest.py
def pytest_collection_modifyitems(config, items):
    """自定义测试收集逻辑"""
    # 如果设置了ONLY_CRITICAL环境变量,只运行smoke测试
    if os.getenv('ONLY_CRITICAL'):
        selected = []
        deselected = []
        
        for item in items:
            if item.get_closest_marker('smoke'):
                selected.append(item)
            else:
                deselected.append(item)
        
        config.hook.pytest_deselected(items=deselected)
        items[:] = selected
    
    # 根据平台过滤
    if sys.platform == "win32":
        for item in items[:]:
            if item.get_closest_marker('linux_only'):
                items.remove(item)

这些标记和过滤机制让 pytest 具有极强的灵活性,可以根据不同场景运行不同的测试集合。

mark这个东西不会被覆盖,只会累加

四、阶段3:高级特性(5-7天)

1. conftest.py 共享 Fixture

conftest.py是 pytest 的核心特性之一,用于在测试目录结构中共享 fixture 和配置。让我详细解释它的用法:

1. conftest.py 的基本概念

文件位置和作用域

project/
├── conftest.py                    # 项目根目录,对所有测试可见
├── tests/
│   ├── conftest.py               #  tests目录,对tests/下所有测试可见
│   ├── unit/
│   │   ├── conftest.py           # unit目录,对unit/下测试可见
│   │   ├── test_math.py
│   │   └── test_strings.py
│   ├── integration/
│   │   ├── conftest.py           # integration目录,对integration/下测试可见
│   │   ├── test_api.py
│   │   └── test_database.py
│   └── e2e/
│       ├── conftest.py           # e2e目录,对e2e/下测试可见
│       └── test_workflows.py
└── src/

2. 基本 Fixture 共享

项目级共享 fixture

# conftest.py (项目根目录)
import pytest
import requests
from datetime import datetime

@pytest.fixture
def api_base_url():
    """API基础URL - 所有测试都可以使用"""
    return "https://api.example.com"

@pytest.fixture
def timestamp():
    """当前时间戳"""
    return datetime.now().isoformat()

@pytest.fixture
def default_headers():
    """默认请求头"""
    return {
        "User-Agent": "pytest-tests",
        "Content-Type": "application/json"
    }

3. 作用域不同的 conftest.py

项目级 conftest.py

# conftest.py (项目根目录)
import pytest
import os

@pytest.fixture(scope="session")
def database_url():
    """会话级别的数据库URL"""
    return os.getenv("DATABASE_URL", "sqlite:///:memory:")

@pytest.fixture
def app_config():
    """应用配置"""
    return {
        "debug": True,
        "testing": True,
        "secret_key": "test-secret-key"
    }

测试目录级 conftest.py

# tests/conftest.py
import pytest

@pytest.fixture
def test_client():
    """测试客户端 - 只在tests目录下可用"""
    from myapp import create_app
    app = create_app()
    app.config['TESTING'] = True
    return app.test_client()

@pytest.fixture
def authenticated_client(test_client):
    """已认证的测试客户端"""
    # 使用test_client fixture,自动处理依赖
    test_client.post('/login', json={'username': 'test', 'password': 'test'})
    return test_client

子目录级 conftest.py

# tests/unit/conftest.py
import pytest

@pytest.fixture
def mock_database():
    """单元测试专用的模拟数据库"""
    from unittest.mock import Mock
    mock_db = Mock()
    mock_db.query.return_value.all.return_value = []
    return mock_db

@pytest.fixture
def sample_user_data():
    """用户测试数据"""
    return {
        "username": "testuser",
        "email": "test@example.com",
        "age": 25
    }
# tests/integration/conftest.py  
import pytest
import tempfile
import os

@pytest.fixture(scope="module")
def temp_database():
    """集成测试用的临时数据库"""
    with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
        db_path = f.name
    
    # 设置测试数据库
    yield f"sqlite:///{db_path}"
    
    # 清理
    if os.path.exists(db_path):
        os.unlink(db_path)

@pytest.fixture
def api_client(temp_database):
    """API测试客户端"""
    from myapp import create_app
    app = create_app()
    app.config['SQLALCHEMY_DATABASE_URI'] = temp_database
    with app.test_client() as client:
        yield client

4. 实际测试用例使用

单元测试使用 fixtures

# tests/unit/test_math.py
def test_addition():
    assert 1 + 1 == 2

def test_user_creation(sample_user_data, mock_database):
    """使用unit/conftest.py中的fixtures"""
    from myapp import User
    user = User(**sample_user_data)
    mock_database.add(user)
    mock_database.commit.assert_called_once()

# tests/unit/test_strings.py  
def test_string_operations():
    assert "hello".upper() == "HELLO"

集成测试使用 fixtures

# tests/integration/test_api.py
def test_api_endpoint(api_client, authenticated_client):
    """使用integration/conftest.py中的fixtures"""
    # 测试未认证访问
    response = api_client.get('/api/protected')
    assert response.status_code == 401
    
    # 测试认证访问  
    response = authenticated_client.get('/api/protected')
    assert response.status_code == 200

def test_database_integration(temp_database):
    """测试数据库集成"""
    from myapp.models import init_db
    db = init_db(temp_database)
    # 数据库操作测试

端到端测试

# tests/e2e/test_workflows.py
def test_complete_workflow(test_client, api_base_url):
    """使用项目级fixtures测试完整流程"""
    # 完整的端到端测试
    response = test_client.post('/api/orders', json={
        'items': ['product1', 'product2']
    })
    assert response.status_code == 201

5. Fixture 依赖和组合

复杂的 Fixture 依赖链

# tests/conftest.py
import pytest

@pytest.fixture(scope="session")
def database_engine():
    """数据库引擎"""
    from sqlalchemy import create_engine
    engine = create_engine("sqlite:///:memory:")
    return engine

@pytest.fixture
def database_session(database_engine):
    """数据库会话"""
    from sqlalchemy.orm import sessionmaker
    Session = sessionmaker(bind=database_engine)
    session = Session()
    yield session
    session.close()

@pytest.fixture
def user_factory(database_session):
    """用户工厂"""
    def create_user(username, email):
        from myapp.models import User
        user = User(username=username, email=email)
        database_session.add(user)
        database_session.commit()
        return user
    return create_user

@pytest.fixture
def sample_user(user_factory):
    """样例用户"""
    return user_factory("testuser", "test@example.com")

6. 自动使用 Fixture (autouse)

# tests/conftest.py
import pytest

@pytest.fixture(autouse=True)
def setup_test_environment():
    """自动为每个测试设置环境"""
    print("\n=== 设置测试环境 ===")
    # 设置代码
    yield
    print("=== 清理测试环境 ===")
    # 清理代码

@pytest.fixture(autouse=True, scope="session")  
def setup_logging():
    """会话级别的日志设置"""
    import logging
    logging.basicConfig(level=logging.INFO)
    print("=== 设置日志 ===")

7. 参数化 Fixture

# tests/conftest.py
import pytest

@pytest.fixture(params=["mysql", "postgresql", "sqlite"])
def database_type(request):
    """参数化的数据库类型fixture"""
    return request.param

@pytest.fixture
def database_url(database_type):
    """根据数据库类型返回URL"""
    urls = {
        "mysql": "mysql://user:pass@localhost/test",
        "postgresql": "postgresql://user:pass@localhost/test", 
        "sqlite": "sqlite:///:memory:"
    }
    return urls[database_type]

# 使用参数化fixture的测试
def test_database_compatibility(database_url, database_type):
    print(f"Testing {database_type} with {database_url}")
    # 测试逻辑

8. 动态 Fixture

# tests/conftest.py
import pytest

def create_user_fixture(role, permissions):
    """动态创建用户fixture的工厂函数"""
    @pytest.fixture
    def user_fixture():
        return {
            "username": f"test_{role}",
            "role": role,
            "permissions": permissions
        }
    return user_fixture

# 创建不同的用户fixture
admin_user = create_user_fixture("admin", ["read", "write", "delete"])
editor_user = create_user_fixture("editor", ["read", "write"]) 
viewer_user = create_user_fixture("viewer", ["read"])

# 使这些fixture在conftest.py中可用
@pytest.fixture
def admin_user_fixture():
    return admin_user()

@pytest.fixture  
def editor_user_fixture():
    return editor_user()

@pytest.fixture
def viewer_user_fixture():
    return viewer_user()

9. 完整的实战示例

项目结构

myproject/
├── conftest.py
├── tests/
│   ├── conftest.py
│   ├── unit/
│   │   ├── conftest.py
│   │   ├── test_models.py
│   │   └── test_services.py
│   ├── integration/
│   │   ├── conftest.py
│   │   ├── test_api.py
│   │   └── test_database.py
│   └── e2e/
│       ├── conftest.py
│       └── test_workflows.py
└── src/

根目录 conftest.py

# conftest.py
import pytest
import os
from datetime import datetime

@pytest.fixture(scope="session")
def project_root():
    """项目根目录路径"""
    return os.path.dirname(os.path.abspath(__file__))

@pytest.fixture(scope="session")
def test_start_time():
    """测试开始时间"""
    return datetime.now()

@pytest.fixture(autouse=True)
def print_test_info(request, test_start_time):
    """自动打印测试信息"""
    test_name = request.node.name
    print(f"\n🚀 开始测试: {test_name}")
    yield
    duration = (datetime.now() - test_start_time).total_seconds()
    print(f"✅ 测试完成: {test_name} (耗时: {duration:.2f}s)")

tests/conftest.py

# tests/conftest.py
import pytest
import tempfile
import json

@pytest.fixture
def temp_config_file():
    """临时配置文件"""
    with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
        config = {
            "database": "sqlite:///:memory:",
            "debug": True,
            "secret_key": "test-secret"
        }
        json.dump(config, f)
        temp_path = f.name
    
    yield temp_path
    
    # 清理
    import os
    os.unlink(temp_path)

@pytest.fixture
def app(temp_config_file):
    """应用实例"""
    from myapp import create_app
    return create_app(config_file=temp_config_file)

@pytest.fixture
def client(app):
    """测试客户端"""
    return app.test_client()

tests/unit/conftest.py

# tests/unit/conftest.py
import pytest
from unittest.mock import Mock

@pytest.fixture
def mock_database():
    """模拟数据库"""
    db = Mock()
    db.session = Mock()
    return db

@pytest.fixture
def sample_data():
    """样例数据"""
    return {
        "users": [
            {"id": 1, "name": "Alice", "email": "alice@example.com"},
            {"id": 2, "name": "Bob", "email": "bob@example.com"}
        ],
        "products": [
            {"id": 1, "name": "Product A", "price": 100},
            {"id": 2, "name": "Product B", "price": 200}
        ]
    }

测试用例示例

# tests/unit/test_models.py
def test_user_model(mock_database, sample_data):
    """使用unit级别的fixtures"""
    from myapp.models import User
    user_data = sample_data["users"][0]
    user = User(**user_data)
    mock_database.session.add(user)
    mock_database.session.commit.assert_called_once()

# tests/integration/test_api.py
def test_api_integration(client, temp_config_file):
    """使用integration级别的fixtures"""
    response = client.get('/api/users')
    assert response.status_code == 200

10. 最佳实践

  1. 按作用域组织:将fixture放在合适作用域的conftest.py中
  2. 避免循环依赖:注意fixture之间的依赖关系
  3. 使用适当的scope:session > module > class > function
  4. 命名清晰:fixture名称要能清晰表达其用途
  5. 文档完善:为每个fixture添加docstring

这样组织conftest.py可以让你的测试代码更加模块化和可维护!

2. 插件系统使用

这些是 pytest 最常用的插件,每个都有特定的用途。让我详细介绍它们的作用和用法:

1. pytest-html - 生成HTML测试报告

安装和基本使用

pip install pytest-html
pytest --html=report.html

配置和定制

# pytest.ini
[pytest]
addopts = --html=reports/report.html --self-contained-html

# 生成带时间戳的报告
pytest --html=reports/report-{datetime}.html

# 添加环境信息
pytest --html=report.html --title="我的测试报告" --self-contained-html

在代码中自定义报告

# conftest.py
import pytest
from py.xml import html

def pytest_html_results_table_header(cells):
    """自定义报告表头"""
    cells.insert(2, html.th("Description"))
    cells.insert(1, html.th("Time", class_="sortable time", col="time"))

def pytest_html_results_table_row(report, cells):
    """自定义报告行"""
    cells.insert(2, html.td(report.description))
    cells.insert(1, html.td(report.duration, class_="col-time"))

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    """添加测试描述"""
    outcome = yield
    report = outcome.get_result()
    report.description = str(item.function.__doc__)

2. pytest-sugar - 美化测试进度显示

安装和效果

pip install pytest-sugar
pytest  # 自动使用美化输出

特性

  • ✅ 进度条显示
  • ✅ 彩色输出
  • ✅ 即时显示进度(不用等到测试结束)
  • ✅ 显示失败测试的即时反馈

禁用sugar(如果需要)

pytest -p no:sugar  # 临时禁用

3. pytest-xdist - 并行测试

安装和基本使用

pip install pytest-xdist

# 使用所有CPU核心
pytest -n auto

# 指定进程数
pytest -n 4

# 每CPU一个进程
pytest -n logical

高级用法

# 分布式测试到其他机器
pytest -d --tx ssh=user@server1 --tx ssh=user@server2

# 指定测试分发方式
pytest -n 4 --dist=loadscope  # 按模块分发
pytest -n 4 --dist=loadfile   # 按文件分发

# 限制每个测试的执行时间
pytest -n 4 --timeout=300  # 5分钟超时

处理并行测试的问题

# conftest.py
import pytest
import tempfile
import os

@pytest.fixture(scope="session")
def session_temp_dir():
    """会话级临时目录,xdist安全"""
    with tempfile.TemporaryDirectory() as tmpdir:
        yield tmpdir

@pytest.fixture
def worker_temp_file(request, session_temp_dir):
    """每个工作进程的临时文件"""
    worker_id = getattr(request.config, 'workerinput', {}).get('workerid', 'master')
    temp_file = os.path.join(session_temp_dir, f"temp_{worker_id}.txt")
    
    with open(temp_file, 'w') as f:
        f.write(f"Worker {worker_id} data")
    
    yield temp_file
    # 清理
    if os.path.exists(temp_file):
        os.unlink(temp_file)

4. pytest-marks - 标记管理(实际是内置功能)

# 查看所有标记
pytest --markers

# 标记使用示例
pytest -m "slow and not integration"

5. pytest-django - Django项目测试

安装和配置

pip install pytest-django

# 创建pytest.ini
# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings
python_files = tests.py test_*.py *_tests.py
addopts = --reuse-db

常用fixtures和功能

# conftest.py
import pytest

@pytest.fixture
def admin_user(django_user_model):
    """创建管理员用户"""
    return django_user_model.objects.create_superuser(
        username='admin',
        email='admin@example.com',
        password='password'
    )

@pytest.fixture
def authenticated_client(client, admin_user):
    """已认证的客户端"""
    client.force_login(admin_user)
    return client

# test_views.py
import pytest
from django.urls import reverse

@pytest.mark.django_db
def test_homepage(client):
    """测试首页"""
    response = client.get(reverse('home'))
    assert response.status_code == 200

def test_admin_view(authenticated_client):
    """测试需要认证的视图"""
    response = authenticated_client.get('/admin/')
    assert response.status_code == 200

@pytest.mark.django_db
class TestUserViews:
    def test_user_list(self, client, admin_user):
        response = client.get('/users/')
        assert response.status_code == 200
    
    def test_user_create(self, authenticated_client):
        response = authenticated_client.post('/users/create/', {
            'username': 'newuser',
            'email': 'new@example.com'
        })
        assert response.status_code == 302  # 重定向

数据库管理

# 重用测试数据库(加速测试)
pytest --reuse-db

# 创建测试数据库
pytest --create-db

# 并行测试时的数据库设置
pytest -n auto --reuse-db

6. pytest-flask - Flask项目测试

安装和配置

pip install pytest-flask

基本用法

# conftest.py
import pytest
from myapp import create_app

@pytest.fixture
def app():
    """Flask应用fixture"""
    app = create_app()
    app.config['TESTING'] = True
    app.config['WTF_CSRF_ENABLED'] = False  # 禁用CSRF用于测试
    return app

# test_views.py
import pytest
import json

def test_index(client):
    """测试首页"""
    response = client.get('/')
    assert response.status_code == 200
    assert b'Welcome' in response.data

def test_api_endpoint(client):
    """测试API端点"""
    response = client.get('/api/data')
    assert response.status_code == 200
    data = json.loads(response.data)
    assert 'result' in data

def test_post_request(client):
    """测试POST请求"""
    response = client.post('/api/users', json={
        'username': 'testuser',
        'email': 'test@example.com'
    })
    assert response.status_code == 201

@pytest.fixture
def authenticated_client(client, auth_header):
    """已认证的客户端"""
    client.environ_base['HTTP_AUTHORIZATION'] = auth_header
    return client

def test_protected_endpoint(authenticated_client):
    """测试需要认证的端点"""
    response = authenticated_client.get('/api/protected')
    assert response.status_code == 200

7. 插件组合使用示例

完整的测试命令示例

# 并行运行测试,生成HTML报告,使用美化输出
pytest -n auto --html=report.html --self-contained-html -v

# Django项目:并行测试+HTML报告+数据库重用
pytest -n 4 --html=reports/test_report.html --reuse-db -m "not slow"

# Flask项目:特定标记测试
pytest -m "api and not slow" --html=api_test_report.html

配置文件示例

# pytest.ini
[pytest]
addopts = -v --strict-markers --html=reports/report.html -n auto
markers =
    slow: marks tests as slow
    fast: marks tests as fast
    api: API related tests
    integration: integration tests
    smoke: smoke tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*

# Django特定
DJANGO_SETTINGS_MODULE = myproject.settings

# 或者Flask特定
flask_app = myapp:create_app()

在CI/CD中使用的示例

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: Set up Python
      uses: actions/setup-python@v2
      with:
        python-version: '3.9'
    - name: Install dependencies
      run: |
        pip install -r requirements.txt
        pip install pytest pytest-html pytest-xdist pytest-django
    - name: Run tests
      run: |
        pytest -n auto --html=report.html --self-contained-html --junitxml=report.xml
    - name: Upload test results
      uses: actions/upload-artifact@v2
      with:
        name: test-report
        path: report.html

8. 其他有用的插件

# 测试覆盖率
pip install pytest-cov
pytest --cov=myapp --cov-report=html

# 随机化测试顺序
pip install pytest-randomly
pytest --randomly-dont-reorganize  # 保持模块顺序

# 异步测试支持
pip install pytest-asyncio

# Mock工具集成
pip install pytest-mock

# 基准测试
pip install pytest-benchmark

# 测试排序控制
pip install pytest-ordering

这些插件的组合使用可以大幅提升测试效率和质量,特别是在大型项目中。

3. 钩子函数(Hooks)

pytest 的钩子函数(hook functions)是强大的扩展机制,允许你在测试过程的不同阶段插入自定义逻辑。让我详细解释它们的作用和用法:

1. 钩子函数的基本概念

什么是钩子函数

钩子函数是 pytest 在测试执行的特定阶段调用的函数,允许你:

修改测试行为

添加额外功能

收集自定义信息

控制测试流程

钩子函数的放置位置

# conftest.py - 钩子函数通常放在这里
import pytest
import sys

def pytest_configure(config):
    """在配置阶段调用"""
    print("=== pytest配置开始 ===")

def pytest_unconfigure(config):
    """在退出阶段调用"""
    print("=== pytest退出 ===")

2. 测试收集阶段的钩子

修改测试收集

# conftest.py
def pytest_collection_modifyitems(config, items):
    """
    修改收集到的测试项
    config: pytest配置对象
    items: 收集到的测试项列表
    """
    print(f"收集到 {len(items)} 个测试")
    
    # 示例1: 按测试名称排序
    items.sort(key=lambda item: item.nodeid)
    
    # 示例2: 跳过名称包含"slow"的测试
    if config.getoption("--skip-slow"):
        skip_slow = pytest.mark.skip(reason="跳过慢速测试")
        for item in items:
            if "slow" in item.nodeid:
                item.add_marker(skip_slow)
    
    # 示例3: 为所有测试添加标记
    for item in items:
        if "integration" in item.nodeid:
            item.add_marker(pytest.mark.integration)

def pytest_collect_file(file_path, path, parent):
    """决定是否收集某个文件"""
    if file_path.suffix == ".yaml":
        # 自定义收集YAML文件作为测试
        return YamlFile.from_parent(parent, path=file_path)

3. 测试运行阶段的钩子

测试设置和清理

# conftest.py
def pytest_runtest_setup(item):
    """在每个测试运行前调用"""
    print(f"准备运行: {item.nodeid}")

def pytest_runtest_teardown(item, nextitem):
    """在每个测试运行后调用"""
    print(f"清理: {item.nodeid}")

def pytest_runtest_call(item):
    """在测试函数调用时执行"""
    print(f"调用测试: {item.nodeid}")

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_protocol(item, nextitem):
    """
    包装测试执行协议
    可以完全控制测试执行流程
    """
    print(f"开始测试协议: {item.nodeid}")
    outcome = yield  # 在这里实际执行测试
    print(f"结束测试协议: {item.nodeid}")

4. 测试报告生成钩子

自定义测试报告

# conftest.py
def pytest_runtest_makereport(item, call):
    """
    为每个测试生成报告
    call: 包含测试调用信息(setup/call/teardown)
    """
    if call.when == "call":  # 只关注测试调用阶段
        if call.excinfo is not None:  # 测试失败
            print(f"测试失败: {item.nodeid}")
            print(f"错误: {call.excinfo.value}")
        
        # 添加自定义属性到报告
        setattr(item, "test_duration", call.stop - call.start)

def pytest_report_teststatus(report, config):
    """自定义测试状态报告"""
    if report.when == "call":
        if report.passed:
            return "✅", "PASS", "绿色"
        elif report.failed:
            return "❌", "FAIL", "红色"
    
    return None  # 使用默认行为

5. 配置和初始化钩子

初始化和配置

# conftest.py
def pytest_configure(config):
    """pytest配置初始化时调用"""
    # 添加自定义配置
    config.addinivalue_line(
        "markers", "slow: 标记慢速测试"
    )
    
    # 添加自定义命令行选项
    config.addinivalue_line(
        "addopts", "--strict-markers"
    )
    
    print("pytest配置完成")

def pytest_sessionstart(session):
    """测试会话开始时调用"""
    print("=== 测试会话开始 ===")
    # 初始化资源:数据库连接、API客户端等

def pytest_sessionfinish(session, exitstatus):
    """测试会话结束时调用"""
    print(f"=== 测试会话结束,退出状态: {exitstatus} ===")
    # 清理资源

def pytest_unconfigure(config):
    """pytest退出时调用"""
    print("pytest退出清理")

6. 自定义命令行选项

添加命令行参数

# conftest.py
def pytest_addoption(parser):
    """添加自定义命令行选项"""
    parser.addoption(
        "--env", 
        action="store", 
        default="test",
        help="测试环境: test, staging, production"
    )
    
    parser.addoption(
        "--run-slow", 
        action="store_true",
        default=False,
        help="运行慢速测试"
    )
    
    parser.addoption(
        "--browser",
        action="store",
        choices=["chrome", "firefox", "safari"],
        default="chrome",
        help="浏览器选择"
    )

def pytest_configure(config):
    """使用命令行选项"""
    env = config.getoption("--env")
    print(f"测试环境: {env}")
    
    # 根据环境设置不同的配置
    if env == "production":
        os.environ["DATABASE_URL"] = "prod_connection_string"

7. 插件管理钩子

插件加载和管理

# conftest.py
def pytest_plugin_registered(plugin, manager):
    """当插件注册时调用"""
    print(f"插件已注册: {plugin}")

def pytest_addhooks(pluginmanager):
    """添加自定义钩子"""
    # 注册自定义钩子
    pluginmanager.add_hookspecs(MyHooks)

8. 完整的实战示例

示例1:自定义测试过滤和排序

# conftest.py
import pytest
import os

def pytest_collection_modifyitems(config, items):
    """自定义测试收集和排序"""
    
    # 1. 根据环境变量过滤测试
    if os.getenv("CI") == "true":
        # CI环境中跳过慢速测试
        skip_slow = pytest.mark.skip(reason="CI环境中跳过慢速测试")
        for item in items:
            if item.get_closest_marker("slow"):
                item.add_marker(skip_slow)
    
    # 2. 按优先级排序测试
    def get_test_priority(item):
        if item.get_closest_marker("smoke"):
            return 1  # 冒烟测试优先
        elif item.get_closest_marker("integration"):
            return 3  # 集成测试次之
        else:
            return 2  # 普通单元测试
        
    items.sort(key=get_test_priority)
    
    # 3. 为特定测试添加超时标记
    for item in items:
        if "api" in item.nodeid:
            item.add_marker(pytest.mark.timeout(30))

def pytest_runtest_setup(item):
    """测试设置"""
    # 记录测试开始时间
    item.start_time = pytest.time.time()

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    """生成自定义测试报告"""
    outcome = yield
    report = outcome.get_result()
    
    # 计算测试耗时
    if hasattr(item, 'start_time'):
        duration = pytest.time.time() - item.start_time
        report.duration = duration
        
        # 记录慢速测试
        if duration > 1.0:  # 超过1秒算慢速
            print(f"⚠️  慢速测试: {item.nodeid} ({duration:.2f}s)")

示例2:测试环境管理

# conftest.py
import pytest
import requests
from datetime import datetime

def pytest_configure(config):
    """配置阶段:环境检查"""
    # 检查测试环境是否可用
    try:
        response = requests.get("http://test-api:8000/health", timeout=5)
        if response.status_code != 200:
            pytest.exit("测试环境不可用")
    except requests.ConnectionError:
        pytest.exit("无法连接到测试环境")

def pytest_sessionstart(session):
    """会话开始:资源准备"""
    print(f"测试开始时间: {datetime.now()}")
    
    # 初始化测试数据库
    init_test_database()
    
    # 创建测试用户
    create_test_users()

def pytest_sessionfinish(session, exitstatus):
    """会话结束:资源清理"""
    print(f"测试结束时间: {datetime.now()}")
    
    # 清理测试数据
    cleanup_test_data()
    
    # 生成测试报告
    generate_test_report(session)

def pytest_unconfigure(config):
    """pytest退出:最终清理"""
    # 关闭数据库连接
    close_database_connections()

示例3:自定义测试报告生成

# conftest.py
import pytest
import json
from pathlib import Path

class CustomTestReporter:
    def __init__(self):
        self.results = []
    
    def pytest_runtest_logreport(self, report):
        """收集测试结果"""
        if report.when == "call":  # 只关注测试执行阶段
            test_result = {
                "name": report.nodeid,
                "outcome": report.outcome,
                "duration": getattr(report, 'duration', 0),
                "when": report.when,
                "screenshot": getattr(report, 'screenshot', None)
            }
            self.results.append(test_result)
    
    def pytest_sessionfinish(self, session, exitstatus):
        """生成自定义报告"""
        report_data = {
            "session_id": session.startdir,
            "exit_status": exitstatus,
            "total_tests": len(self.results),
            "passed": len([r for r in self.results if r["outcome"] == "passed"]),
            "failed": len([r for r in self.results if r["outcome"] == "failed"]),
            "results": self.results
        }
        
        # 保存为JSON文件
        report_file = Path("custom_test_report.json")
        report_file.write_text(json.dumps(report_data, indent=2, ensure_ascii=False))

# 注册自定义报告器
@pytest.hookimpl(trylast=True)
def pytest_configure(config):
    config.custom_reporter = CustomTestReporter()

def pytest_runtest_makereport(item, call):
    """为报告添加额外信息"""
    if call.when == "call" and call.excinfo:
        # 失败时截图
        if hasattr(item.obj, 'screenshot'):
            call.screenshot = item.obj.screenshot()

示例4:动态跳过测试

# conftest.py
import pytest
import os

def pytest_collection_modifyitems(config, items):
    """动态跳过测试"""
    
    # 跳过条件1:特定平台
    if sys.platform != "linux":
        skip_linux = pytest.mark.skip(reason="仅Linux平台支持")
        for item in items:
            if item.get_closest_marker("linux_only"):
                item.add_marker(skip_linux)
    
    # 跳过条件2:环境变量控制
    if not os.getenv("RUN_SLOW_TESTS"):
        skip_slow = pytest.mark.skip(reason="未设置RUN_SLOW_TESTS环境变量")
        for item in items:
            if item.get_closest_marker("slow"):
                item.add_marker(skip_slow)
    
    # 跳过条件3:功能开关
    if not is_feature_enabled("new_ui"):
        skip_new_ui = pytest.mark.skip(reason="新UI功能未启用")
        for item in items:
            if "new_ui" in item.nodeid:
                item.add_marker(skip_new_ui)

def is_feature_enabled(feature_name):
    """检查功能是否启用"""
    # 从配置文件、数据库或环境变量读取
    return os.getenv(f"FEATURE_{feature_name.upper()}", "false").lower() == "true"

9. 钩子函数执行顺序

# conftest.py
def pytest_configure(config):
    print("1. pytest_configure")

def pytest_sessionstart(session):
    print("2. pytest_sessionstart")

def pytest_collection_modifyitems(session, config, items):
    print("3. pytest_collection_modifyitems")

def pytest_runtest_protocol(item, nextitem):
    print("4. pytest_runtest_protocol")

def pytest_runtest_setup(item):
    print("5. pytest_runtest_setup")

def pytest_runtest_call(item):
    print("6. pytest_runtest_call")

def pytest_runtest_teardown(item, nextitem):
    print("7. pytest_runtest_teardown")

def pytest_sessionfinish(session, exitstatus):
    print("8. pytest_sessionfinish")

def pytest_unconfigure(config):
    print("9. pytest_unconfigure")

10. 最佳实践

  1. 合理放置钩子:将钩子函数放在合适的conftest.py中
  2. 避免副作用:钩子函数应该尽量减少全局副作用
  3. 错误处理:在钩子中添加适当的错误处理
  4. 性能考虑:避免在钩子中执行耗时操作
  5. 文档完善:为自定义钩子添加清晰的文档

钩子函数是 pytest 最强大的功能之一,通过它们可以实现高度定制化的测试流程和行为。


五、阶段4:项目实战(1-2周)

1. API 测试项目结构

api_test_project/
├── conftest.py                 # 共享 fixture
├── pytest.ini                  # 配置文件
├── requirements.txt            # 依赖包
├── test_data/                  # 测试数据
│   ├── users.json
│   └── test_cases.csv
├── tests/                      # 测试用例
│   ├── __init__.py
│   ├── conftest.py            # 测试目录配置
│   ├── test_user_api.py       # 用户 API 测试
│   ├── test_product_api.py    # 商品 API 测试
│   └── test_order_api.py      # 订单 API 测试
└── utils/                      # 工具函数
    ├── __init__.py
    ├── api_client.py          # API 客户端
    └── data_loader.py          # 数据加载

2. 完整的 API 测试示例

# tests/test_user_api.py
import pytest
import requests

class TestUserAPI:
    """用户 API 测试套件"""
    
    @pytest.mark.smoke
    @pytest.mark.parametrize("user_data", [
        {"name": "张三", "email": "zhang@test.com"},
        {"name": "李四", "email": "li@test.com"}
    ])
    def test_create_user(self, api_client, user_data):
        """测试创建用户"""
        response = api_client.post("/users", json=user_data)
        
        assert response.status_code == 201
        assert "id" in response.json()
        assert response.json()["name"] == user_data["name"]
    
    def test_get_user(self, api_client, create_test_user):
        """测试获取用户信息"""
        user_id = create_test_user["id"]
        response = api_client.get(f"/users/{user_id}")
        
        assert response.status_code == 200
        assert response.json()["id"] == user_id
    
    def test_update_user(self, api_client, create_test_user):
        """测试更新用户"""
        user_id = create_test_user["id"]
        update_data = {"name": "更新后的姓名"}
        
        response = api_client.put(f"/users/{user_id}", json=update_data)
        assert response.status_code == 200
        assert response.json()["name"] == "更新后的姓名"
    
    def test_delete_user(self, api_client, create_test_user):
        """测试删除用户"""
        user_id = create_test_user["id"]
        response = api_client.delete(f"/users/{user_id}")
        assert response.status_code == 204
        
        # 验证用户已删除
        get_response = api_client.get(f"/users/{user_id}")
        assert get_response.status_code == 404

3. conftest.py 完整配置

# tests/conftest.py
import pytest
import requests
import json

@pytest.fixture(scope="session")
def api_base_url():
    """API 基础地址配置"""
    return "https://jsonplaceholder.typicode.com"  # 示例 API

@pytest.fixture
def api_client(api_base_url):
    """API 客户端 fixture"""
    session = requests.Session()
    session.base_url = api_base_url
    
    # 设置公共请求头
    session.headers.update({
        "Content-Type": "application/json",
        "User-Agent": "pytest-api-tests/1.0"
    })
    
    yield session
    session.close()

@pytest.fixture
def create_test_user(api_client):
    """创建测试用户并返回用户数据"""
    user_data = {
        "name": "测试用户",
        "email": "test@example.com",
        "username": "testuser"
    }
    
    response = api_client.post("/users", json=user_data)
    assert response.status_code == 201
    
    user = response.json()
    yield user  # 提供给测试使用
    
    # 测试完成后清理
    cleanup_response = api_client.delete(f"/users/{user['id']}")
    # 忽略清理失败,因为示例 API 可能不支持删除

def load_test_data(filename):
    """加载测试数据"""
    with open(f"test_data/{filename}", "r", encoding="utf-8") as f:
        if filename.endswith(".json"):
            return json.load(f)
        # 可以添加其他格式支持

@pytest.fixture
def user_test_data():
    """用户测试数据"""
    return load_test_data("users.json")

六、阶段5:最佳实践和高级技巧

1. 配置文件 pytest.ini

# pytest.ini
[pytest]
# 测试文件匹配模式
python_files = test_*.py
python_classes = Test*
python_functions = test_*

# 自定义标记
markers =
    smoke: 冒烟测试
    api: API 测试
    slow: 慢速测试
    integration: 集成测试

# 默认选项
addopts = -v --strict-markers --tb=short

# 测试目录
testpaths = tests

# 忽略目录
norecursedirs = .git build dist *.egg-info

# 最小版本要求
minversion = 6.0

2. 自定义命令行选项

# conftest.py
def pytest_addoption(parser):
    """添加自定义命令行选项"""
    parser.addoption(
        "--env", 
        action="store", 
        default="test", 
        help="测试环境: dev, test, prod"
    )
    parser.addoption(
        "--browser",
        action="store",
        default="chrome",
        help="浏览器: chrome, firefox, safari"
    )

@pytest.fixture(scope="session")
def env_config(request):
    """根据命令行参数返回环境配置"""
    env = request.config.getoption("--env")
    configs = {
        "dev": {"base_url": "https://dev-api.example.com"},
        "test": {"base_url": "https://test-api.example.com"},
        "prod": {"base_url": "https://api.example.com"}
    }
    return configs.get(env, configs["test"])

使用自定义选项

# 指定测试环境
pytest --env=dev

# 指定浏览器
pytest --browser=firefox

# 组合使用
pytest --env=test --browser=chrome -m smoke

3. CI/CD 集成

GitHub Actions 示例

# .github/workflows/test.yml
name: Python Tests

on: [push, pull_request]

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 -r requirements.txt
        pip install pytest pytest-html pytest-xdist
    
    - name: Run tests with pytest
      run: |
        pytest tests/ -v --html=report.html --self-contained-html -n auto
    
    - name: Upload test results
      uses: actions/upload-artifact@v2
      with:
        name: test-report-${{ matrix.python-version }}
        path: report.html

七、学习资源推荐

1. 官方文档

2. 实践项目

# 1. 创建练习项目
mkdir pytest-learning && cd pytest-learning

# 2. 初始化虚拟环境
python -m venv venv
source venv/bin/activate  # Linux/Mac
# venv\Scripts\activate  # Windows

# 3. 安装依赖
pip install pytest requests selenium

# 4. 创建测试结构并开始练习

3. 学习顺序建议

  1. 第1周:基础语法 + 简单项目练习
  2. 第2周:Fixture + 参数化实战
  3. 第3周:插件系统 + 自定义配置
  4. 第4周:完整项目实战 + CI/CD 集成

总结

pytest 学习核心要点

  1. 掌握 Fixture - pytest 的灵魂功能
  2. 熟练参数化 - 数据驱动测试的关键
  3. 理解作用域 - 优化测试执行效率
  4. 善用标记 - 灵活控制测试执行
  5. 项目实战 - 真实场景应用练习

按照这个路径,你可以在 1个月 内系统掌握 pytest,并能够在实际项目中熟练使用!

posted @ 2025-11-30 19:25  GDms  阅读(0)  评论(0)    收藏  举报