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