pytest_自动化测试1

1. pytest基础入门与配置

1.1 安装与配置方法

pytest是Python生态中最流行的测试框架之一,其安装和配置相对简单,但理解其底层机制对于高效使用至关重要。

安装方法
bash 复制代码
# 基础安装
pip install pytest

# 安装特定版本
pip install pytest==7.4.0

# 安装常用插件组合
pip install pytest pytest-cov pytest-html pytest-xdist pytest-mock

# 使用requirements.txt安装
# requirements.txt内容:
# pytest>=7.4.0
# pytest-cov>=4.1.0
# pytest-html>=3.2.0
pip install -r requirements.txt
版本验证
python 复制代码
# test_version.py - 验证pytest版本
import pytest

def test_pytest_version():
    """
    测试pytest版本是否正确安装
    """
    import sys
    print(f"Python版本: {sys.version}")
    print(f"pytest版本: {pytest.__version__}")
    assert pytest.__version__ >= "7.0.0", "pytest版本过低,建议升级到7.0以上"

if __name__ == "__main__":
    pytest.main([__file__, "-v"])

1.2 测试文件结构与命名规范

pytest采用约定优于配置的原则,遵循特定的命名规范可以自动发现测试文件和测试函数。

测试文件命名规范
python 复制代码
# 推荐的测试文件命名模式
# test_*.py
# *_test.py

# 示例文件结构
project/
├── src/
│   ├── __init__.py
│   ├── calculator.py
│   └── user_service.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── test_calculator.py
│   ├── test_user_service.py
│   ├── integration/
│   │   ├── __init__.py
│   │   └── test_api_integration.py
│   └── unit/
│       ├── __init__.py
│       └── test_calculator_operations.py
├── pytest.ini
└── pyproject.toml
测试函数命名规范
python 复制代码
# test_naming_conventions.py
import pytest

def test_addition_basic():
    """
    基础加法测试 - 使用test_前缀
    测试函数必须以test_开头
    """
    result = 2 + 3
    assert result == 5

def test_subtraction_positive():
    """
    正数减法测试
    函数名应该清晰描述测试意图
    """
    result = 10 - 5
    assert result == 5

class TestCalculator:
    """
    测试类命名规范:使用Test前缀
    类中的测试方法也必须以test_开头
    """

    def test_multiplication_basic(self):
        """
        测试类中的乘法方法
        """
        result = 4 * 3
        assert result == 12

    def test_division_by_zero_raises_error(self):
        """
        测试除零异常
        """
        with pytest.raises(ZeroDivisionError):
            result = 10 / 0

1.3 基本测试用例编写方法

pytest提供了简洁的测试用例编写方式,无需继承任何基类或使用特定的装饰器。

简单测试用例
python 复制代码
# test_basic_examples.py
import pytest

def test_string_concatenation():
    """
    测试字符串拼接
    pytest使用assert语句进行断言,无需使用self.assert*
    """
    str1 = "Hello"
    str2 = "World"
    result = str1 + " " + str2
    assert result == "Hello World"
    assert len(result) == 11

def test_list_operations():
    """
    测试列表操作
    pytest提供了丰富的断言表达式
    """
    numbers = [1, 2, 3, 4, 5]

    # 基本断言
    assert len(numbers) == 5
    assert 3 in numbers
    assert numbers[0] == 1

    # 列表操作断言
    numbers.append(6)
    assert len(numbers) == 6
    assert 6 in numbers

    # 列表推导式断言
    doubled = [x * 2 for x in numbers]
    assert doubled == [2, 4, 6, 8, 10, 12]

def test_dictionary_operations():
    """
    测试字典操作
    """
    user = {
        "name": "张三",
        "age": 25,
        "email": "zhangsan@example.com"
    }

    # 字典断言
    assert user["name"] == "张三"
    assert "age" in user
    assert len(user) == 3

    # 字典更新断言
    user["city"] = "北京"
    assert user["city"] == "北京"
    assert len(user) == 4

def test_exception_handling():
    """
    测试异常处理
    使用pytest.raises上下文管理器验证异常
    """
    def divide(a, b):
        if b == 0:
            raise ValueError("除数不能为零")
        return a / b

    # 测试正常情况
    assert divide(10, 2) == 5.0

    # 测试异常情况
    with pytest.raises(ValueError) as exc_info:
        divide(10, 0)

    # 验证异常消息
    assert str(exc_info.value) == "除数不能为零"

def test_comparison_operations():
    """
    测试比较操作
    pytest支持各种比较操作符
    """
    assert 5 > 3
    assert 5 >= 5
    assert 3 < 5
    assert 3 <= 3
    assert 5 == 5
    assert 5 != 3

    # 链式比较
    assert 1 < 2 < 3 < 4

    # 近似比较(浮点数)
    assert 0.1 + 0.2 == pytest.approx(0.3, rel=1e-9)

1.4 断言机制与常用断言方法

pytest的断言机制是其核心特性之一,提供了直观且强大的断言方式。

基础断言
python 复制代码
# test_assertions.py
import pytest

def test_basic_assertions():
    """
    基础断言示例
    pytest重写了assert语句,提供详细的错误信息
    """
    # 相等断言
    assert 1 + 1 == 2
    assert "hello" == "hello"

    # 不等断言
    assert 1 + 1 != 3
    assert "hello" != "world"

    # 大小比较断言
    assert 5 > 3
    assert 5 >= 5
    assert 3 < 5
    assert 3 <= 3

    # 布尔断言
    assert True
    assert not False
    assert bool(1) is True
    assert bool(0) is False

    # 身份断言
    a = [1, 2, 3]
    b = a
    assert a is b
    assert a is not [1, 2, 3]

    # 成员断言
    assert 3 in [1, 2, 3, 4, 5]
    assert "python" in "python is awesome"
    assert "key" in {"key": "value"}

    # 类型断言
    assert isinstance(42, int)
    assert isinstance("hello", str)
    assert isinstance([1, 2, 3], list)

def test_collection_assertions():
    """
    集合断言示例
    """
    # 列表断言
    numbers = [1, 2, 3, 4, 5]
    assert len(numbers) == 5
    assert numbers[0] == 1
    assert numbers[-1] == 5

    # 字典断言
    person = {"name": "张三", "age": 25}
    assert "name" in person
    assert person["age"] == 25

    # 集合断言
    set1 = {1, 2, 3}
    set2 = {3, 4, 5}
    assert set1 & set2 == {3}  # 交集
    assert set1 | set2 == {1, 2, 3, 4, 5}  # 并集

def test_custom_object_assertions():
    """
    自定义对象断言
    """
    class User:
        def __init__(self, name, age):
            self.name = name
            self.age = age

        def __eq__(self, other):
            return self.name == other.name and self.age == other.age

    user1 = User("张三", 25)
    user2 = User("张三", 25)
    user3 = User("李四", 30)

    assert user1 == user2  # 自定义相等性
    assert user1 != user3

def test_approximate_assertions():
    """
    近似断言
    用于浮点数比较,避免精度问题
    """
    # 基本近似比较
    assert 0.1 + 0.2 == pytest.approx(0.3)

    # 相对误差
    assert 1000.0 == pytest.approx(1001.0, rel=0.001)  # 0.1%误差

    # 绝对误差
    assert 0.1 + 0.2 == pytest.approx(0.3, abs=1e-9)

    # 复杂数学运算
    import math
    assert math.pi == pytest.approx(3.141592653589793, rel=1e-10)

def test_string_assertions():
    """
    字符串断言
    """
    text = "Python Programming"

    # 基本字符串断言
    assert "Python" in text
    assert text.startswith("Python")
    assert text.endswith("ming")

    # 字符串长度
    assert len(text) == 18

    # 字符串方法
    assert text.lower() == "python programming"
    assert text.upper() == "PYTHON PROGRAMMING"

    # 正则表达式断言
    import re
    assert re.search(r"Python", text) is not None
    assert re.match(r"Python", text) is not None

1.5 高级断言

pytest提供了高级断言功能,用于处理异常、警告和弃用情况。

异常断言
python 复制代码
# test_advanced_assertions.py
import pytest
import warnings

def test_raises_basic():
    """
    基本异常断言
    使用pytest.raises验证代码是否抛出预期异常
    """
    def divide(a, b):
        if b == 0:
            raise ZeroDivisionError("除数不能为零")
        return a / b

    # 验证抛出ZeroDivisionError
    with pytest.raises(ZeroDivisionError) as exc_info:
        divide(10, 0)

    # 验证异常消息
    assert str(exc_info.value) == "除数不能为零"

def test_raises_with_match():
    """
    使用match参数匹配异常消息
    match参数接受正则表达式
    """
    def validate_age(age):
        if age < 0:
            raise ValueError("年龄不能为负数")
        if age > 150:
            raise ValueError("年龄不能超过150岁")
        return age

    # 使用正则表达式匹配异常消息
    with pytest.raises(ValueError, match=r"年龄不能为负数"):
        validate_age(-5)

    with pytest.raises(ValueError, match=r"年龄不能超过\d+岁"):
        validate_age(200)

def test_raises_with_message():
    """
    验证异常消息内容
    """
    class CustomError(Exception):
        pass

    def process_data(data):
        if not data:
            raise CustomError("数据不能为空")
        return data

    with pytest.raises(CustomError) as exc_info:
        process_data(None)

    # 访问异常对象
    assert exc_info.type is CustomError
    assert str(exc_info.value) == "数据不能为空"
    assert exc_info.traceback is not None

def test_warns():
    """
    警告断言
    使用pytest.warns验证代码是否产生预期警告
    """
    def deprecated_function():
        warnings.warn("这个函数已经弃用", DeprecationWarning)
        return "result"

    # 验证产生DeprecationWarning
    with pytest.warns(DeprecationWarning) as warning_list:
        result = deprecated_function()

    assert result == "result"
    assert len(warning_list) == 1
    assert str(warning_list[0].message) == "这个函数已经弃用"

def test_warns_with_match():
    """
    使用match参数匹配警告消息
    """
    def function_with_warning():
        warnings.warn("警告:参数值超出范围", UserWarning)

    with pytest.warns(UserWarning, match=r"警告:参数值超出范围"):
        function_with_warning()

def test_deprecated_call():
    """
    弃用调用断言
    用于测试已弃用的函数调用
    """
    def old_function():
        warnings.warn("old_function已弃用,请使用new_function", DeprecationWarning)
        return "old_result"

    # 验证弃用警告
    with pytest.deprecated_call():
        old_function()

def test_multiple_warnings():
    """
    多个警告断言
    """
    def function_with_multiple_warnings():
        warnings.warn("第一个警告", UserWarning)
        warnings.warn("第二个警告", DeprecationWarning)
        return "result"

    with pytest.warns(UserWarning) as user_warnings:
        with pytest.warns(DeprecationWarning):
            result = function_with_multiple_warnings()

    assert result == "result"
    assert len(user_warnings) == 1

def test_no_exception():
    """
    测试不抛出异常的情况
    """
    def safe_divide(a, b):
        if b == 0:
            return None
        return a / b

    # 验证不抛出异常
    with pytest.raises(pytest.fail.Exception) as exc_info:
        with pytest.raises(ZeroDivisionError):
            safe_divide(10, 2)

    assert "DID NOT RAISE" in str(exc_info.value)

1.6 断言重写机制详解

pytest的断言重写机制是其核心特性之一,它通过在导入时重写assert语句来提供详细的错误信息。

断言重写原理
python 复制代码
# test_assertion_rewriting.py
import pytest

def test_assertion_rewriting_detailed():
    """
    断言重写提供详细的错误信息
    """
    data = {
        "users": [
            {"name": "张三", "age": 25},
            {"name": "李四", "age": 30}
        ],
        "count": 2
    }

    # 这个断言会失败,但pytest会提供详细的错误信息
    # 显示实际值和期望值的差异
    assert data["users"][0]["age"] == 30
    # 错误信息会显示:
    # assert 25 == 30
    #  +  where 25 = <dict object>["age"]
    #  +    where <dict object> = {'name': '张三', 'age': 25}
    #  +      where {'name': '张三', 'age': 25} = <dict object>[0]
    #  +        where <dict object> = [{'name': '张三', 'age': 25}, {'name': '李四', 'age': 30}]
    #  +          where [{'name': '张三', 'age': 25}, {'name': '李四', 'age': 30}] = <dict object>["users"]
    #  +            where <dict object> = {'users': [{'name': '张三', 'age': 25}, {'name': '李四', 'age': 30}], 'count': 2}
    #  +              where {'users': [{'name': '张三', 'age': 25}, {'name': '李四', 'age': 30}], 'count': 2} = data["users"]

def test_assertion_rewriting_complex():
    """
    复杂数据结构的断言重写
    """
    class Person:
        def __init__(self, name, age, hobbies):
            self.name = name
            self.age = age
            self.hobbies = hobbies

        def __repr__(self):
            return f"Person(name='{self.name}', age={self.age}, hobbies={self.hobbies})"

    people = [
        Person("张三", 25, ["读书", "运动"]),
        Person("李四", 30, ["音乐", "旅行"]),
        Person("王五", 28, ["编程", "游戏"])
    ]

    # 复杂断言会显示详细的差异
    assert people[1].hobbies[0] == "绘画"
    # 错误信息会显示:
    # assert '音乐' == '绘画'
    #  +  where '音乐' = <list object>[0]
    #  +    where <list object> = ['音乐', '旅行']
    #  +      where ['音乐', '旅行'] = Person(name='李四', age=30, hobbies=['音乐', '旅行']).hobbies
    #  +        where Person(name='李四', age=30, hobbies=['音乐', '旅行']) = <list object>[1]
    #  +          where <list object> = [Person(name='张三', age=25, hobbies=['读书', '运动']), Person(name='李四', age=30, hobbies=['音乐', '旅行']), Person(name='王五', age=28, hobbies=['编程', '游戏'])]

def test_assertion_rewriting_custom():
    """
    自定义对象的断言重写
    """
    class Calculator:
        def __init__(self):
            self.history = []

        def add(self, a, b):
            result = a + b
            self.history.append(f"{a} + {b} = {result}")
            return result

        def subtract(self, a, b):
            result = a - b
            self.history.append(f"{a} - {b} = {result}")
            return result

    calc = Calculator()
    calc.add(5, 3)
    calc.subtract(10, 4)

    # 断言历史记录
    assert calc.history[1] == "10 - 4 = 7"
    # 错误信息会显示:
    # assert '10 - 4 = 6' == '10 - 4 = 7'
    #  +  where '10 - 4 = 6' = <list object>[1]
    #  +    where <list object> = ['5 + 3 = 8', '10 - 4 = 6']
    #  +      where ['5 + 3 = 8', '10 - 4 = 6'] = Calculator.history

1.7 测试发现机制原理

pytest的测试发现机制是其自动化测试的核心,它能够自动发现和收集测试文件和测试函数。

测试发现流程图

test_*.py
*test.py
其他文件
test
*
Test*
其他
pytest启动
读取配置文件
扫描测试目录
文件名匹配
收集测试文件
跳过
导入测试模块
函数/类名匹配
收集测试函数
收集测试类
跳过
应用标记和过滤
生成测试集合
执行测试

测试发现示例
python 复制代码
# test_discovery_example.py
import pytest

def test_discovery_function_1():
    """
    会被发现的测试函数
    """
    assert True

def test_discovery_function_2():
    """
    会被发现的测试函数
    """
    assert True

class TestDiscoveryClass:
    """
    会被发现的测试类
    """

    def test_method_1(self):
        """
        会被发现的测试方法
        """
        assert True

    def test_method_2(self):
        """
        会被发现的测试方法
        """
        assert True

def not_a_test_function():
    """
    不会被发现的函数(不以test_开头)
    """
    pass

class NotATestClass:
    """
    不会被发现的类(不以Test开头)
    """
    pass

1.8 pytest.ini配置文件详解

pytest.ini是pytest的主要配置文件,用于设置全局测试配置。

pytest.ini配置示例
ini 复制代码
# pytest.ini - pytest主配置文件

[pytest]
# 基础配置
minversion = 7.0                    # 最低pytest版本要求
testpaths = tests                   # 测试目录
python_files = test_*.py *_test.py  # 测试文件匹配模式
python_classes = Test*              # 测试类匹配模式
python_functions = test_*            # 测试函数匹配模式

# 命令行参数
addopts =
    -v                              # 详细输出
    -s                              # 显示print输出
    --strict-markers                # 严格标记模式
    --tb=short                      # 简短回溯信息
    --cov=src                       # 覆盖率分析
    --cov-report=html               # HTML覆盖率报告
    --cov-report=term-missing      # 终端覆盖率报告
    --cov-fail-under=80             # 覆盖率阈值
    --html=reports/test_report.html # HTML测试报告
    --self-contained-html           # 自包含HTML报告

# 标记定义
markers =
    slow: 慢速测试,运行时间较长
    integration: 集成测试
    unit: 单元测试
    smoke: 冒烟测试
    regression: 回归测试
    api: API测试
    ui: UI测试
    database: 数据库测试
    external: 外部依赖测试
    requires_network: 需要网络连接

# 日志配置
log_cli = true                     # 启用命令行日志
log_cli_level = INFO               # 日志级别
log_cli_format = %(asctime)s [%(levelname)8s] %(message)s
log_cli_date_format = %Y-%m-%d %H:%M:%S

# 覆盖率配置
[coverage:run]
source = src
omit =
    */tests/*
    */venv/*
    */__pycache__/*

[coverage:report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError
    if __name__ == .__main__.:
    if TYPE_CHECKING:

1.9 pyproject.toml配置方法

pyproject.toml是现代Python项目的标准配置文件格式,支持多种工具的配置。

pyproject.toml配置示例
toml 复制代码
# pyproject.toml - 现代Python项目配置文件

[build-system]
requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"]
build-backend = "setuptools.build_meta"

[project]
name = "my-project"
version = "1.0.0"
description = "My awesome project"
readme = "README.md"
requires-python = ">=3.8"
license = {text = "MIT"}
authors = [
    {name = "Your Name", email = "your.email@example.com"}
]
classifiers = [
    "Development Status :: 4 - Beta",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.8",
    "Programming Language :: Python :: 3.9",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
]
dependencies = [
    "requests>=2.28.0",
    "numpy>=1.21.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.4.0",
    "pytest-cov>=4.1.0",
    "pytest-html>=3.2.0",
    "pytest-xdist>=3.3.0",
    "pytest-mock>=3.11.0",
    "pytest-asyncio>=0.21.0",
    "black>=23.0.0",
    "flake8>=6.0.0",
    "mypy>=1.0.0",
]
test = [
    "pytest>=7.4.0",
    "pytest-cov>=4.1.0",
    "pytest-html>=3.2.0",
    "pytest-xdist>=3.3.0",
    "pytest-mock>=3.11.0",
]

[tool.pytest.ini_options]
# pytest配置
minversion = "7.0"
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]

# 命令行参数
addopts = [
    "-v",
    "-s",
    "--strict-markers",
    "--tb=short",
    "--cov=src",
    "--cov-report=html",
    "--cov-report=term-missing",
    "--cov-fail-under=80",
    "--html=reports/test_report.html",
    "--self-contained-html",
]

# 标记定义
markers = [
    "slow: 慢速测试,运行时间较长",
    "integration: 集成测试",
    "unit: 单元测试",
    "smoke: 冒烟测试",
    "regression: 回归测试",
    "api: API测试",
    "ui: UI测试",
    "database: 数据库测试",
    "external: 外部依赖测试",
    "requires_network: 需要网络连接",
]

# 日志配置
log_cli = true
log_cli_level = "INFO"
log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s"
log_cli_date_format = "%Y-%m-%d %H:%M:%S"

# 异步测试配置
asyncio_mode = "auto"

[tool.coverage.run]
source = ["src"]
omit = [
    "*/tests/*",
    "*/venv/*",
    "*/__pycache__/*",
]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "raise AssertionError",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
    "if TYPE_CHECKING:",
]

[tool.black]
line-length = 88
target-version = ['py38', 'py39', 'py310', 'py311']
include = '\.pyi?$'
extend-exclude = '''
/(
  # directories
  \.eggs
  | \.git
  | \.hg
  | \.mypy_cache
  | \.tox
  | \.venv
  | build
  | dist
)/
'''

[tool.mypy]
python_version = "3.8"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true

1.10 常用配置参数详解

pytest提供了丰富的配置参数,用于定制测试行为。

配置参数详解
python 复制代码
# test_configuration_parameters.py
import pytest

# 配置参数说明文档
"""
pytest常用配置参数详解:

1. minversion
   - 作用:指定最低pytest版本要求
   - 取值范围:版本号字符串,如"7.0.0"
   - 使用场景:确保项目使用足够新的pytest版本

2. testpaths
   - 作用:指定测试目录
   - 取值范围:目录路径列表
   - 使用场景:限制pytest搜索测试的目录

3. python_files
   - 作用:指定测试文件匹配模式
   - 取值范围:glob模式,如"test_*.py", "*_test.py"
   - 使用场景:自定义测试文件命名规范

4. python_classes
   - 作用:指定测试类匹配模式
   - 取值范围:类名模式,如"Test*"
   - 使用场景:自定义测试类命名规范

5. python_functions
   - 作用:指定测试函数匹配模式
   - 取值范围:函数名模式,如"test_*"
   - 使用场景:自定义测试函数命名规范

6. addopts
   - 作用:添加默认命令行选项
   - 取值范围:命令行参数列表
   - 使用场景:设置全局测试选项

7. markers
   - 作用:定义测试标记
   - 取值范围:标记名称和描述
   - 使用场景:组织和分类测试

8. log_cli
   - 作用:启用命令行日志输出
   - 取值范围:true/false
   - 使用场景:实时查看测试日志

9. log_cli_level
   - 作用:设置日志级别
   - 取值范围:CRITICAL, ERROR, WARNING, INFO, DEBUG
   - 使用场景:控制日志详细程度

10. log_cli_format
    - 作用:设置日志格式
    - 取值范围:日志格式字符串
    - 使用场景:自定义日志显示格式

11. log_cli_date_format
    - 作用:设置日志日期格式
    - 取值范围:日期格式字符串
    - 使用场景:自定义日期显示格式
"""

def test_configuration_example():
    """
    配置参数使用示例
    """
    import os

    # 读取环境变量(配置参数的另一种设置方式)
    pytest_min_version = os.getenv("PYTEST_MINVERSION", "7.0.0")
    test_timeout = int(os.getenv("TEST_TIMEOUT", "30"))

    print(f"pytest最低版本: {pytest_min_version}")
    print(f"测试超时时间: {test_timeout}秒")

    assert pytest.__version__ >= pytest_min_version
    assert test_timeout > 0

1.11 配置文件优先级与覆盖规则

pytest支持多种配置文件,了解其优先级对于正确配置至关重要。

配置文件优先级
python 复制代码
# test_configuration_priority.py
import pytest

"""
pytest配置文件优先级(从高到低):

1. 命令行参数(最高优先级)
   - 直接在命令行指定的参数
   - 例如:pytest -v -s --tb=short

2. pyproject.toml
   - 现代Python项目的标准配置文件
   - 位于项目根目录
   - 使用[tool.pytest.ini_options]节

3. pytest.ini
   - pytest传统配置文件
   - 位于项目根目录
   - 使用[pytest]节

4. tox.ini
   - tox工具配置文件
   - 位于项目根目录
   - 使用[pytest]节

5. setup.cfg(不推荐)
   - setuptools配置文件
   - 位于项目根目录
   - 使用[tool:pytest]节
   - 注意:在pytest 7.0+中,setup.cfg的pytest配置已不推荐使用
   - 推荐使用pytest.ini或pyproject.toml替代

6. conftest.py
   - 测试配置文件
   - 可以在测试目录的任何层级
   - 使用pytest_configure钩子函数

注意:配置文件的优先级遵循"就近原则",即离测试文件越近的配置文件优先级越高。

**配置文件选择建议:**
- 新项目:推荐使用pyproject.toml(现代Python项目标准)
- 现有项目:继续使用pytest.ini(稳定可靠)
- 不推荐:setup.cfg(已废弃,应迁移到pytest.ini或pyproject.toml)
"""

def test_configuration_priority():
    """
    配置优先级测试
    """
    import os

    # 检查当前目录下的配置文件
    config_files = [
        "pytest.ini",
        "pyproject.toml",
        "tox.ini",
        "setup.cfg",
        "conftest.py"
    ]

    existing_configs = [f for f in config_files if os.path.exists(f)]

    print(f"存在的配置文件: {existing_configs}")

    # pytest会按照优先级顺序读取配置
    # 后读取的配置会覆盖先读取的配置
    assert len(existing_configs) >= 0

1.12 环境变量配置与管理

pytest支持通过环境变量进行配置,提供了灵活的配置方式。

环境变量配置示例
python 复制代码
# test_environment_variables.py
import pytest
import os

def test_pytest_environment_variables():
    """
    pytest环境变量测试
    """
    # pytest相关的环境变量
    env_vars = {
        "PYTEST_ADDOPTS": os.getenv("PYTEST_ADDOPTS", ""),
        "PYTEST_CURRENT_TEST": os.getenv("PYTEST_CURRENT_TEST", ""),
        "PYTEST_DISABLE_PLUGIN_AUTOLOAD": os.getenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", ""),
    }

    print("pytest环境变量:")
    for key, value in env_vars.items():
        print(f"  {key}: {value}")

    # PYTEST_ADDOPTS: 添加默认命令行选项
    # 例如:export PYTEST_ADDOPTS="-v -s --tb=short"

    # PYTEST_CURRENT_TEST: 当前正在运行的测试
    # pytest会自动设置这个变量

    # PYTEST_DISABLE_PLUGIN_AUTOLOAD: 禁用插件自动加载
    # 设置为任何非空值都会禁用插件自动加载

def test_custom_environment_variables():
    """
    自定义环境变量测试
    """
    # 设置测试环境
    test_env = os.getenv("TEST_ENV", "development")

    if test_env == "development":
        print("开发环境测试")
        assert True
    elif test_env == "staging":
        print("预发布环境测试")
        assert True
    elif test_env == "production":
        print("生产环境测试")
        assert True
    else:
        print(f"未知环境: {test_env}")
        assert False

def test_database_connection_from_env():
    """
    从环境变量读取数据库配置
    """
    # 模拟从环境变量读取数据库配置
    db_config = {
        "host": os.getenv("DB_HOST", "localhost"),
        "port": int(os.getenv("DB_PORT", "5432")),
        "database": os.getenv("DB_NAME", "test_db"),
        "user": os.getenv("DB_USER", "test_user"),
        "password": os.getenv("DB_PASSWORD", "test_password"),
    }

    print(f"数据库配置: {db_config}")

    # 验证配置
    assert db_config["host"] is not None
    assert db_config["port"] > 0
    assert db_config["database"] is not None
    assert db_config["user"] is not None

1.13 配置继承与覆盖策略

pytest支持配置继承,允许在不同层级定义和覆盖配置。

配置继承示例
python 复制代码
# conftest.py - 项目根目录的配置文件
import pytest

def pytest_configure(config):
    """
    项目级别的配置钩子
    这个函数在pytest启动时调用,可以用于设置全局配置
    """
    # 添加自定义配置选项
    config.addinivalue_line(
        "markers", "project_level: 项目级别的标记"
    )

    # 设置项目级别的环境变量
    import os
    os.environ.setdefault("PROJECT_ENV", "production")

    print(f"项目级别配置已加载,环境: {os.getenv('PROJECT_ENV')}")

# tests/integration/conftest.py - 集成测试目录的配置文件
# 这个文件会继承根目录的配置,并添加集成测试特定的配置
import pytest

def pytest_configure(config):
    """
    集成测试级别的配置
    """
    # 集成测试级别的配置
    config.addinivalue_line(
        "markers", "integration_level: 集成测试级别的标记"
    )
    print("集成测试级别配置已加载")
python 复制代码
# test_configuration_inheritance.py
import pytest
import os

@pytest.mark.project_level
def test_project_level_config():
    """
    测试项目级别配置
    """
    project_env = os.getenv("PROJECT_ENV", "development")
    print(f"项目环境: {project_env}")
    assert project_env in ["development", "staging", "production"]

@pytest.mark.integration_level
def test_integration_level_config():
    """
    测试集成测试级别配置
    """
    # 这个测试会同时使用项目级别和集成测试级别的配置
    project_env = os.getenv("PROJECT_ENV", "development")
    print(f"集成测试环境: {project_env}")
    assert project_env in ["development", "staging", "production"]

def test_config_inheritance():
    """
    测试配置继承
    """
    # pytest会按照以下顺序加载配置:
    # 1. 项目根目录的conftest.py
    # 2. 子目录的conftest.py
    # 3. 测试文件

    # 子目录的配置会覆盖父目录的配置
    # 但不会影响其他子目录的配置

    assert True

1.14 pytest测试环境变量(pytest-env参数详解)

pytest-env插件提供了便捷的环境变量管理方式。

pytest-env使用示例
python 复制代码
# test_pytest_env.py
import pytest
import os

def test_env_variables():
    """
    测试环境变量
    假设在pytest.ini中配置了:
    [pytest]
    env =
        TEST_ENV=development
        DB_HOST=localhost
        DB_PORT=5432
    """
    # 读取环境变量
    test_env = os.getenv("TEST_ENV", "production")
    db_host = os.getenv("DB_HOST", "unknown")
    db_port = os.getenv("DB_PORT", "unknown")

    print(f"测试环境: {test_env}")
    print(f"数据库主机: {db_host}")
    print(f"数据库端口: {db_port}")

    # 根据环境变量执行不同的测试逻辑
    if test_env == "development":
        print("执行开发环境测试")
        assert db_host == "localhost"
    elif test_env == "production":
        print("执行生产环境测试")
        assert db_host != "localhost"
    else:
        print(f"未知环境: {test_env}")

@pytest.mark.skipif(os.getenv("SKIP_NETWORK_TESTS") == "1", reason="网络测试被跳过")
def test_network_request():
    """
    根据环境变量跳过网络测试
    """
    import requests

    response = requests.get("https://httpbin.org/get")
    assert response.status_code == 200

def test_database_connection():
    """
    根据环境变量配置数据库连接
    """
    db_config = {
        "host": os.getenv("DB_HOST", "localhost"),
        "port": int(os.getenv("DB_PORT", "5432")),
        "database": os.getenv("DB_NAME", "test_db"),
        "user": os.getenv("DB_USER", "test_user"),
    }

    print(f"数据库配置: {db_config}")

    # 验证配置
    assert db_config["host"] is not None
    assert db_config["port"] > 0
    assert db_config["database"] is not None

1.15 pytest 7.0+新特性与API变更

pytest 7.0版本引入了许多重要的新特性和API变更,了解这些变化对于升级和维护测试代码至关重要。

pytest 7.0主要变更

1. 移除的API

python 复制代码
# 以下API在pytest 7.0中已被移除

# pytest.config已移除
# 错误用法:
# config = pytest.config

# 正确用法:
def test_example(request):
    config = request.config

# item.fspath已废弃
# 旧用法:
# file_path = str(item.fspath)

# 新用法:
# file_path = str(item.path)

# item.lineno已废弃
# 旧用法:
# line_number = item.lineno

# 新用法:
# line_number = item.obj.__code__.co_firstlineno

2. 新增的API

python 复制代码
# test_new_api.py
import pytest

def test_new_api_usage(request):
    """
    pytest 7.0+新增API使用示例
    """
    # 获取测试路径
    test_path = request.path
    print(f"测试路径: {test_path}")
    
    # 获取测试节点ID
    node_id = request.node.nodeid
    print(f"节点ID: {node_id}")
    
    # 获取测试配置
    config = request.config
    print(f"配置对象: {config}")

def test_fixture_request_api(request):
    """
    request fixture的新API
    """
    # 获取测试模块
    test_module = request.module
    print(f"测试模块: {test_module}")
    
    # 获取测试类
    test_class = request.cls
    print(f"测试类: {test_class}")
    
    # 获取测试函数
    test_function = request.function
    print(f"测试函数: {test_function}")

3. 改进的错误消息

python 复制代码
# test_improved_errors.py
def test_improved_assertion_messages():
    """
    pytest 7.0+改进了断言错误消息
    """
    data = {
        "name": "Alice",
        "age": 30,
        "city": "Beijing"
    }
    
    # 改进的错误消息会显示详细的差异
    assert data["age"] == 25, "年龄不匹配"
    
    # 改进的错误消息会显示缺失的键
    assert data["email"] == "alice@example.com", "邮箱不匹配"

4. 性能改进

python 复制代码
# test_performance_improvements.py
import pytest

def test_collection_performance():
    """
    pytest 7.0+改进了测试收集性能
    """
    # 测试收集速度更快
    assert True

@pytest.mark.parametrize("value", range(1000))
def test_parametrized_performance(value):
    """
    参数化测试性能改进
    """
    assert value >= 0

5. 配置文件改进

python 复制代码
# pytest 7.0+改进了配置文件处理

# pyproject.toml(推荐)
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-v --strict-markers"
markers = [
    "slow: marks tests as slow (deselect with '-m \"not slow\"')",
    "integration: marks tests as integration tests",
]

# pytest.ini(仍然支持)
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --strict-markers
markers =
    slow: marks tests as slow (deselect with '-m "not slow"')
    integration: marks tests as integration tests

6. 类型提示支持

python 复制代码
# test_type_hints.py
from typing import List, Dict

def test_with_type_hints(data: List[Dict[str, int]]) -> None:
    """
    pytest 7.0+更好地支持类型提示
    """
    assert all(isinstance(item, dict) for item in data)
    assert all("value" in item for item in data)

@pytest.fixture
def typed_fixture() -> Dict[str, int]:
    """
    带类型提示的fixture
    """
    return {"value": 42}

def test_typed_fixture_usage(typed_fixture: Dict[str, int]) -> None:
    """
    使用带类型提示的fixture
    """
    assert typed_fixture["value"] == 42
pytest 7.0+迁移指南

迁移检查清单:

python 复制代码
# migration_checklist.py
"""
pytest 7.0+迁移检查清单

1. 替换pytest.config为request.config
2. 替换item.fspath为item.path
3. 替换item.lineno为item.obj.__code__.co_firstlineno
4. 更新setup.cfg配置为pytest.ini或pyproject.toml
5. 检查插件兼容性
6. 更新自定义钩子函数
7. 测试所有测试用例
8. 更新CI/CD配置
"""

def test_migration_example(request):
    """
    迁移示例
    """
    # 旧代码:
    # config = pytest.config
    
    # 新代码:
    config = request.config
    
    # 旧代码:
    # file_path = str(item.fspath)
    
    # 新代码:
    file_path = str(request.path)
    
    assert config is not None
    assert file_path is not None
pytest 8.0最新特性(预览)
python 复制代码
# test_pytest8_features.py
"""
pytest 8.0最新特性(预览版本)

注意:pytest 8.0可能还在开发中,以下特性可能会有变化
"""

def test_pytest8_features():
    """
    pytest 8.0可能的新特性
    """
    # 1. 更好的异步测试支持
    # 2. 改进的性能
    # 3. 新的钩子函数
    # 4. 改进的错误消息
    # 5. 更好的类型提示支持
    
    assert True
升级建议
python 复制代码
# upgrade_recommendations.py
"""
pytest升级建议

1. 逐步升级:
   - 先升级到pytest 6.x
   - 再升级到pytest 7.x
   - 最后考虑pytest 8.x

2. 测试兼容性:
   - 在测试环境中先测试
   - 检查所有插件兼容性
   - 运行完整的测试套件

3. 更新文档:
   - 更新API使用文档
   - 更新配置文件
   - 更新CI/CD配置

4. 监控问题:
   - 关注pytest发布说明
   - 监控测试失败率
   - 及时修复问题
"""

def test_upgrade_readiness():
    """
    测试升级准备情况
    """
    # 检查是否使用了已废弃的API
    # 检查插件兼容性
    # 检查配置文件格式
    
    assert True
实际项目案例:从零开始构建测试套件

下面通过一个完整的电商系统项目,展示如何从零开始构建pytest测试套件。

python 复制代码
# ecommerce_project/src/models/user.py
"""
用户模型
"""
class User:
    """
    用户类
    """
    
    def __init__(self, user_id: int, name: str, email: str):
        """
        初始化用户
        """
        self.user_id = user_id
        self.name = name
        self.email = email
    
    def validate_email(self) -> bool:
        """
        验证邮箱格式
        """
        return "@" in self.email and "." in self.email
    
    def to_dict(self) -> dict:
        """
        转换为字典
        """
        return {
            "user_id": self.user_id,
            "name": self.name,
            "email": self.email
        }

# ecommerce_project/src/models/product.py
"""
产品模型
"""
class Product:
    """
    产品类
    """
    
    def __init__(self, product_id: int, name: str, price: float, stock: int):
        """
        初始化产品
        """
        self.product_id = product_id
        self.name = name
        self.price = price
        self.stock = stock
    
    def is_available(self) -> bool:
        """
        检查产品是否有库存
        """
        return self.stock > 0
    
    def calculate_discount(self, discount_rate: float) -> float:
        """
        计算折扣价格
        """
        if not 0 <= discount_rate <= 1:
            raise ValueError("折扣率必须在0到1之间")
        return self.price * (1 - discount_rate)

# ecommerce_project/src/services/user_service.py
"""
用户服务
"""
from typing import List, Optional
from ..models.user import User

class UserService:
    """
    用户服务类
    """
    
    def __init__(self):
        """
        初始化用户服务
        """
        self.users: List[User] = []
        self.next_id = 1
    
    def create_user(self, name: str, email: str) -> User:
        """
        创建用户
        """
        if not name or not email:
            raise ValueError("用户名和邮箱不能为空")
        
        user = User(self.next_id, name, email)
        if not user.validate_email():
            raise ValueError("邮箱格式不正确")
        
        self.users.append(user)
        self.next_id += 1
        return user
    
    def get_user(self, user_id: int) -> Optional[User]:
        """
        获取用户
        """
        for user in self.users:
            if user.user_id == user_id:
                return user
        return None
    
    def update_user(self, user_id: int, name: str = None, email: str = None) -> Optional[User]:
        """
        更新用户
        """
        user = self.get_user(user_id)
        if not user:
            return None
        
        if name:
            user.name = name
        if email:
            user.email = email
            if not user.validate_email():
                raise ValueError("邮箱格式不正确")
        
        return user
    
    def delete_user(self, user_id: int) -> bool:
        """
        删除用户
        """
        user = self.get_user(user_id)
        if not user:
            return False
        
        self.users.remove(user)
        return True

# ecommerce_project/tests/conftest.py
"""
项目级配置文件
"""
import pytest
import sys
from pathlib import Path

# 添加项目根目录到Python路径
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))

@pytest.fixture(scope="session")
def test_config():
    """
    测试配置fixture
    """
    return {
        "test_mode": True,
        "database_url": "sqlite:///:memory:",
        "api_timeout": 30
    }

@pytest.fixture
def user_service():
    """
    用户服务fixture
    每个测试函数都会获得一个新的用户服务实例
    """
    from src.services.user_service import UserService
    return UserService()

# ecommerce_project/tests/test_user_service.py
"""
用户服务测试
"""
import pytest
from src.services.user_service import UserService
from src.models.user import User

class TestUserService:
    """
    用户服务测试类
    """
    
    def test_create_user_with_valid_data(self, user_service):
        """
        使用有效数据创建用户应该成功
        """
        # Arrange
        name = "张三"
        email = "zhangsan@example.com"
        
        # Act
        user = user_service.create_user(name, email)
        
        # Assert
        assert user is not None
        assert user.name == name
        assert user.email == email
        assert user.user_id == 1
    
    def test_create_user_with_invalid_email(self, user_service):
        """
        使用无效邮箱创建用户应该失败
        """
        # Arrange & Act & Assert
        with pytest.raises(ValueError, match="邮箱格式不正确"):
            user_service.create_user("张三", "invalid_email")
    
    def test_create_user_with_empty_name(self, user_service):
        """
        使用空用户名创建用户应该失败
        """
        # Arrange & Act & Assert
        with pytest.raises(ValueError, match="用户名和邮箱不能为空"):
            user_service.create_user("", "test@example.com")
    
    def test_get_user_with_valid_id(self, user_service):
        """
        使用有效ID获取用户应该成功
        """
        # Arrange
        created_user = user_service.create_user("李四", "lisi@example.com")
        
        # Act
        found_user = user_service.get_user(created_user.user_id)
        
        # Assert
        assert found_user is not None
        assert found_user.user_id == created_user.user_id
        assert found_user.name == "李四"
    
    def test_get_user_with_invalid_id(self, user_service):
        """
        使用无效ID获取用户应该返回None
        """
        # Act
        found_user = user_service.get_user(999)
        
        # Assert
        assert found_user is None
    
    def test_update_user_name(self, user_service):
        """
        更新用户名应该成功
        """
        # Arrange
        user = user_service.create_user("王五", "wangwu@example.com")
        
        # Act
        updated_user = user_service.update_user(user.user_id, name="王五修改")
        
        # Assert
        assert updated_user is not None
        assert updated_user.name == "王五修改"
        assert updated_user.email == "wangwu@example.com"
    
    def test_update_user_with_invalid_email(self, user_service):
        """
        使用无效邮箱更新用户应该失败
        """
        # Arrange
        user = user_service.create_user("赵六", "zhaoliu@example.com")
        
        # Act & Assert
        with pytest.raises(ValueError, match="邮箱格式不正确"):
            user_service.update_user(user.user_id, email="invalid_email")
    
    def test_delete_user_with_valid_id(self, user_service):
        """
        使用有效ID删除用户应该成功
        """
        # Arrange
        user = user_service.create_user("钱七", "qianqi@example.com")
        
        # Act
        result = user_service.delete_user(user.user_id)
        
        # Assert
        assert result is True
        assert user_service.get_user(user.user_id) is None
    
    def test_delete_user_with_invalid_id(self, user_service):
        """
        使用无效ID删除用户应该失败
        """
        # Act
        result = user_service.delete_user(999)
        
        # Assert
        assert result is False

# ecommerce_project/tests/test_product.py
"""
产品测试
"""
import pytest
from src.models.product import Product

class TestProduct:
    """
    产品测试类
    """
    
    @pytest.fixture
    def product(self):
        """
        产品fixture
        """
        return Product(1, "测试产品", 99.99, 10)
    
    def test_product_initialization(self, product):
        """
        测试产品初始化
        """
        assert product.product_id == 1
        assert product.name == "测试产品"
        assert product.price == 99.99
        assert product.stock == 10
    
    def test_is_available_with_stock(self, product):
        """
        有库存的产品应该可用
        """
        assert product.is_available() is True
    
    def test_is_available_without_stock(self):
        """
        无库存的产品应该不可用
        """
        product = Product(2, "缺货产品", 99.99, 0)
        assert product.is_available() is False
    
    def test_calculate_discount_with_valid_rate(self, product):
        """
        使用有效折扣率计算折扣应该成功
        """
        discounted_price = product.calculate_discount(0.2)
        assert discounted_price == 79.992
    
    def test_calculate_discount_with_invalid_rate(self, product):
        """
        使用无效折扣率计算折扣应该失败
        """
        with pytest.raises(ValueError, match="折扣率必须在0到1之间"):
            product.calculate_discount(1.5)
    
    def test_to_dict(self, product):
        """
        测试转换为字典
        """
        product_dict = product.to_dict()
        assert product_dict["product_id"] == 1
        assert product_dict["name"] == "测试产品"
        assert product_dict["price"] == 99.99
        assert product_dict["stock"] == 10

# ecommerce_project/pytest.ini
"""
pytest配置文件
"""
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = 
    -v
    --strict-markers
    --tb=short
markers =
    unit: 单元测试
    integration: 集成测试
    slow: 慢速测试
    smoke: 冒烟测试

# ecommerce_project/requirements.txt
"""
项目依赖文件
"""
pytest>=7.4.0
pytest-cov>=4.1.0
pytest-mock>=3.11.0
pytest-xdist>=3.3.0
相关推荐
姚青&2 小时前
Pytest 测试用例生命周期管理-自动生效(autouse)
测试用例·pytest
酷酷的橙子2 小时前
python 学习
python
2301_818419012 小时前
C++中的状态模式实战
开发语言·c++·算法
姚青&2 小时前
Pytest 测试用例执行顺序自定义 Pytest-ordering
测试用例·pytest
Sakuraba Ema2 小时前
Attention Residuals:把固定残差换成“跨层注意力”
python·llm·attention
独隅2 小时前
Python 标准库 (Standard Library) 全面使用指南
android·开发语言·python
姓王名礼2 小时前
模拟发票,发票PDF
python
yuzhuanhei2 小时前
C++进阶(上)
开发语言·c++
@我漫长的孤独流浪2 小时前
Python精选480题带解析
python