764 lines
14 KiB
Markdown
764 lines
14 KiB
Markdown
# Pytest 完整使用指南
|
||
|
||
Pytest 是 Python 最流行的测试框架,以其简洁的 API 和强大的功能被广泛应用于各种项目中。
|
||
|
||
## 目录
|
||
|
||
1. [快速开始](#1-快速开始)
|
||
2. [编写测试](#2-编写测试)
|
||
3. [Fixtures](#3-fixtures)
|
||
4. [异步测试](#4-异步测试)
|
||
5. [Mocking](#5-mocking)
|
||
6. [参数化测试](#6-参数化测试)
|
||
7. [跳过测试](#7-跳过测试)
|
||
8. [测试夹具作用域](#8-测试夹具作用域)
|
||
9. [测试组织](#9-测试组织)
|
||
10. [常用命令](#10-常用命令)
|
||
11. [配置文件](#11-配置文件)
|
||
|
||
---
|
||
|
||
## 1. 快速开始
|
||
|
||
### 安装
|
||
|
||
```bash
|
||
pip install pytest pytest-asyncio
|
||
```
|
||
|
||
### 运行测试
|
||
|
||
```bash
|
||
# 运行所有测试
|
||
pytest
|
||
|
||
# 运行指定文件
|
||
pytest tests/test_api.py
|
||
|
||
# 运行指定测试函数
|
||
pytest tests/test_api.py::test_health_check
|
||
|
||
# 显示详细输出
|
||
pytest -v
|
||
|
||
# 显示打印内容
|
||
pytest -s
|
||
|
||
# 在第一个失败处停止
|
||
pytest -x
|
||
```
|
||
|
||
---
|
||
|
||
## 2. 编写测试
|
||
|
||
### 基本结构
|
||
|
||
```python
|
||
# test_example.py
|
||
def test_basic_assertion():
|
||
"""简单的断言测试"""
|
||
assert 1 + 1 == 2
|
||
|
||
def test_string_operations():
|
||
"""字符串操作测试"""
|
||
text = "hello world"
|
||
assert text.upper() == "HELLO WORLD"
|
||
assert text.startswith("hello")
|
||
assert len(text) == 11
|
||
|
||
def test_list_operations():
|
||
"""列表操作测试"""
|
||
items = [1, 2, 3]
|
||
assert len(items) == 3
|
||
assert items.pop() == 3
|
||
assert items == [1, 2]
|
||
```
|
||
|
||
### 测试类
|
||
|
||
```python
|
||
class TestMathOperations:
|
||
def test_addition(self):
|
||
assert 2 + 3 == 5
|
||
|
||
def test_subtraction(self):
|
||
assert 5 - 3 == 2
|
||
|
||
def test_multiplication(self):
|
||
assert 3 * 4 == 12
|
||
|
||
|
||
class TestStringOperations:
|
||
def test_reverse(self):
|
||
assert "hello"[::-1] == "olleh"
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Fixtures
|
||
|
||
Fixtures 是 pytest 最强大的特性之一,用于提供测试所需的依赖和数据。
|
||
|
||
### 基本 Fixture
|
||
|
||
```python
|
||
import pytest
|
||
|
||
@pytest.fixture
|
||
def sample_data():
|
||
"""返回一个简单的数据字典"""
|
||
return {"name": "test", "value": 100}
|
||
|
||
def test_using_fixture(sample_data):
|
||
"""使用 fixture 的测试"""
|
||
assert sample_data["value"] > 50
|
||
```
|
||
|
||
### Fixture with Setup/Teardown
|
||
|
||
```python
|
||
import pytest
|
||
import tempfile
|
||
import shutil
|
||
|
||
@pytest.fixture
|
||
def temp_directory():
|
||
"""创建临时目录,测试后清理"""
|
||
temp_path = tempfile.mkdtemp()
|
||
yield temp_path # 测试在此运行
|
||
shutil.rmtree(temp_path, ignore_errors=True) # 清理
|
||
|
||
def test_write_file(temp_directory):
|
||
"""使用临时目录的测试"""
|
||
import os
|
||
file_path = os.path.join(temp_directory, "test.txt")
|
||
with open(file_path, "w") as f:
|
||
f.write("hello")
|
||
|
||
assert os.path.exists(file_path)
|
||
# 测试结束后 temp_path 会被自动清理
|
||
```
|
||
|
||
### Fixture 参数化
|
||
|
||
```python
|
||
@pytest.fixture
|
||
def user_data():
|
||
return {"username": "testuser", "email": "test@example.com"}
|
||
|
||
|
||
def test_user_creation(user_data):
|
||
assert user_data["username"] == "testuser"
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 异步测试
|
||
|
||
### 安装异步支持
|
||
|
||
```bash
|
||
pip install pytest-asyncio
|
||
```
|
||
|
||
### 配置 pytest.ini
|
||
|
||
```ini
|
||
[pytest]
|
||
asyncio_mode = auto
|
||
```
|
||
|
||
### 编写异步测试
|
||
|
||
```python
|
||
import pytest
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_operation():
|
||
"""异步测试函数"""
|
||
import asyncio
|
||
|
||
async def fetch_data():
|
||
await asyncio.sleep(0.1)
|
||
return {"data": "result"}
|
||
|
||
result = await fetch_data()
|
||
assert result["data"] == "result"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_with_db():
|
||
"""模拟异步数据库操作"""
|
||
import asyncio
|
||
|
||
async def connect_db():
|
||
await asyncio.sleep(0.1)
|
||
return "database_connected"
|
||
|
||
result = await connect_db()
|
||
assert "connected" in result
|
||
```
|
||
|
||
### Fixture 返回协程
|
||
|
||
```python
|
||
import pytest
|
||
import asyncio
|
||
|
||
@pytest.fixture
|
||
async def async_database():
|
||
"""异步 fixture - 连接数据库"""
|
||
await asyncio.sleep(0.1) # 模拟连接
|
||
db = {"connected": True}
|
||
yield db
|
||
# Teardown
|
||
await asyncio.sleep(0.05)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_database_query(async_database):
|
||
assert async_database["connected"] is True
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Mocking
|
||
|
||
### 使用 unittest.mock
|
||
|
||
```python
|
||
from unittest.mock import Mock, patch, AsyncMock
|
||
import pytest
|
||
|
||
def test_mock_function():
|
||
"""Mock 函数调用"""
|
||
mock = Mock(return_value=42)
|
||
|
||
result = mock()
|
||
assert result == 42
|
||
mock.assert_called_once()
|
||
|
||
|
||
def test_patch_environment():
|
||
"""Patch 环境变量"""
|
||
with patch("os.environ", {"TEST": "value"}):
|
||
import os
|
||
assert os.environ.get("TEST") == "value"
|
||
|
||
|
||
def test_mock_class():
|
||
"""Mock 类"""
|
||
mock_class = Mock()
|
||
mock_class.method.return_value = "mocked"
|
||
|
||
result = mock_class.method()
|
||
assert result == "mocked"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_mock():
|
||
"""Mock 异步函数"""
|
||
mock_async = AsyncMock(return_value="async result")
|
||
|
||
result = await mock_async()
|
||
assert result == "async result"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_patch_async():
|
||
"""Patch 异步函数"""
|
||
async def real_async_func():
|
||
return "real"
|
||
|
||
with patch("__main__.real_async_func", new_callable=AsyncMock) as mock_func:
|
||
mock_func.return_value = "mocked"
|
||
result = await mock_func()
|
||
assert result == "mocked"
|
||
```
|
||
|
||
### Mocking 第三方库
|
||
|
||
```python
|
||
from unittest.mock import patch, MagicMock
|
||
import pytest
|
||
|
||
# Mock HTTP 请求
|
||
@pytest.mark.asyncio
|
||
async def test_api_call():
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = {"status": "ok"}
|
||
|
||
with patch("httpx.AsyncClient") as mock_client:
|
||
mock_instance = AsyncMock()
|
||
mock_instance.get = AsyncMock(return_value=mock_response)
|
||
mock_client.return_value = mock_instance
|
||
|
||
# 实际测试代码会使用 httpx.AsyncClient
|
||
client = mock_client.get("http://api.example.com")
|
||
# ...
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 参数化测试
|
||
|
||
一个测试函数使用不同参数多次运行。
|
||
|
||
### @pytest.mark.parametrize
|
||
|
||
```python
|
||
import pytest
|
||
|
||
@pytest.mark.parametrize("input,expected", [
|
||
(1, 2),
|
||
(2, 4),
|
||
(3, 6),
|
||
])
|
||
def test_double(input, expected):
|
||
"""测试倍数函数"""
|
||
assert input * 2 == expected
|
||
|
||
|
||
@pytest.mark.parametrize("a,b,result", [
|
||
(1, 1, 2),
|
||
(2, 3, 5),
|
||
(10, 20, 30),
|
||
])
|
||
def test_addition(a, b, result):
|
||
assert a + b == result
|
||
|
||
|
||
# 字符串参数
|
||
@pytest.mark.parametrize("text,upper", [
|
||
("hello", "HELLO"),
|
||
("World", "WORLD"),
|
||
("pytest", "PYTEST"),
|
||
])
|
||
def test_uppercase(text, upper):
|
||
assert text.upper() == upper
|
||
```
|
||
|
||
### 多参数组合
|
||
|
||
```python
|
||
import pytest
|
||
|
||
@pytest.mark.parametrize("x", [1, 2, 3])
|
||
@pytest.mark.parametrize("y", [10, 20])
|
||
def test_combinations(x, y):
|
||
"""x=1,2,3 与 y=10,20 的所有组合"""
|
||
assert x * y > 0
|
||
# 运行 6 次: (1,10), (1,20), (2,10), (2,20), (3,10), (3,20)
|
||
```
|
||
|
||
### 使用 pytest 对象的 param
|
||
|
||
```python
|
||
import pytest
|
||
|
||
@pytest.fixture
|
||
def pytest_param_user():
|
||
return pytest.param("user1", marks=pytest.mark.slow)
|
||
|
||
|
||
def test_user_stuff():
|
||
pass
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 跳过测试
|
||
|
||
### 跳过条件
|
||
|
||
```python
|
||
import pytest
|
||
import sys
|
||
|
||
@pytest.mark.skip(reason="功能未实现")
|
||
def test_not_implemented():
|
||
pass
|
||
|
||
|
||
@pytest.mark.skipif(sys.version_info < (3, 8), reason="需要 Python 3.8+")
|
||
def test_new_feature():
|
||
pass
|
||
|
||
|
||
# 动态跳过
|
||
def test_conditionally():
|
||
if some_condition:
|
||
pytest.skip("条件不满足")
|
||
assert True
|
||
```
|
||
|
||
### 预期失败
|
||
|
||
```python
|
||
@pytest.mark.xfail(reason="已知 bug")
|
||
def test_known_bug():
|
||
assert False # 预期失败
|
||
```
|
||
|
||
---
|
||
|
||
## 8. 测试夹具作用域
|
||
|
||
Fixture 可以指定不同的作用域:
|
||
|
||
```python
|
||
# function: 每个测试函数执行一次(默认)
|
||
@pytest.fixture
|
||
def function_fixture():
|
||
print("function scope")
|
||
return "function"
|
||
|
||
# class: 每个测试类执行一次
|
||
@pytest.fixture(scope="class")
|
||
def class_fixture():
|
||
print("class scope")
|
||
return "class"
|
||
|
||
# module: 每个模块执行一次
|
||
@pytest.fixture(scope="module")
|
||
def module_fixture():
|
||
print("module scope")
|
||
return "module"
|
||
|
||
# session: 整个测试会话执行一次
|
||
@pytest.fixture(scope="session")
|
||
def session_fixture():
|
||
print("session scope")
|
||
return "session"
|
||
```
|
||
|
||
---
|
||
|
||
## 9. 测试组织
|
||
|
||
### 目录结构建议
|
||
|
||
```
|
||
project/
|
||
├── tests/
|
||
│ ├── __init__.py
|
||
│ ├── conftest.py # 共享 fixtures
|
||
│ ├── test_api.py # API 测试
|
||
│ ├── test_models.py # 模型测试
|
||
│ ├── test_services/ # 服务测试
|
||
│ │ ├── __init__.py
|
||
│ │ ├── test_session.py
|
||
│ │ └── test_scheduler.py
|
||
│ └── test_integration/ # 集成测试
|
||
│ └── test_workflow.py
|
||
├── src/
|
||
│ └── your_code.py
|
||
└── pytest.ini
|
||
```
|
||
|
||
### conftest.py 共享配置
|
||
|
||
```python
|
||
# tests/conftest.py
|
||
import pytest
|
||
|
||
@pytest.fixture
|
||
def common_setup():
|
||
"""所有测试共享的设置"""
|
||
print("Setting up test environment")
|
||
yield
|
||
print("Tearing down test environment")
|
||
|
||
|
||
# 自动使用某些 fixtures
|
||
@pytest.fixture(autouse=True)
|
||
def reset_state():
|
||
"""每个测试自动重置状态"""
|
||
yield
|
||
```
|
||
|
||
---
|
||
|
||
## 10. 常用命令
|
||
|
||
```bash
|
||
# 基本运行
|
||
pytest # 运行所有测试
|
||
pytest tests/ # 运行指定目录
|
||
pytest test_file.py # 运行指定文件
|
||
pytest -k "test_name" # 按名称过滤
|
||
pytest -k "test_api" # 运行包含 "test_api" 的测试
|
||
|
||
# 输出控制
|
||
pytest -v # 详细输出
|
||
pytest -s # 显示 print 输出
|
||
pytest --tb=short # 简短 traceback
|
||
pytest --tb=long # 详细 traceback
|
||
|
||
# 调试
|
||
pytest -x # 第一个失败后停止
|
||
pytest --pdb # 失败时进入 debugger
|
||
pytest -l # 显示失败的局部变量
|
||
|
||
# 覆盖率
|
||
pytest --cov=src # 运行并计算覆盖率
|
||
pytest --cov-report=html # 生成 HTML 报告
|
||
|
||
# 标记
|
||
pytest -m "slow" # 运行带 slow 标记的测试
|
||
pytest -m "not slow" # 排除 slow 标记
|
||
|
||
# 异步
|
||
pytest --asyncio-mode=auto # 自动检测异步测试
|
||
```
|
||
|
||
---
|
||
|
||
## 11. 配置文件
|
||
|
||
### pytest.ini
|
||
|
||
```ini
|
||
[pytest]
|
||
minversion = 6.0
|
||
testpaths = tests
|
||
python_files = test_*.py
|
||
python_classes = Test*
|
||
python_functions = test_*
|
||
|
||
# 异步配置
|
||
asyncio_mode = auto
|
||
|
||
# 标记定义
|
||
markers =
|
||
slow: 耗时较长的测试
|
||
integration: 集成测试
|
||
unit: 单元测试
|
||
|
||
# 忽略路径
|
||
norecursedirs = .git .tox build dist
|
||
|
||
# 自定义选项
|
||
addopts = -v --tb=short
|
||
```
|
||
|
||
### pyproject.toml
|
||
|
||
```toml
|
||
[tool.pytest.ini_options]
|
||
minversion = "6.0"
|
||
testpaths = ["tests"]
|
||
pythonpath = ["."]
|
||
|
||
[tool.pytest.markers]
|
||
slow = "marks tests as slow"
|
||
integration = "marks tests as integration tests"
|
||
|
||
[tool.coverage.run]
|
||
source = ["src"]
|
||
|
||
[tool.coverage.report]
|
||
exclude_lines = [
|
||
"pragma: no cover",
|
||
"def __repr__",
|
||
"raise AssertionError",
|
||
]
|
||
```
|
||
|
||
---
|
||
|
||
## 高级技巧
|
||
|
||
### 1. 自定义标记
|
||
|
||
```python
|
||
# 在代码中使用
|
||
@pytest.mark.slow
|
||
def test_slow_operation():
|
||
import time
|
||
time.sleep(5)
|
||
assert True
|
||
|
||
|
||
@pytest.mark.integration
|
||
def test_api_workflow():
|
||
pass
|
||
|
||
|
||
# 在 pytest.ini 中注册
|
||
[pytest]
|
||
markers =
|
||
slow: marks tests as slow (deselect with '-m "not slow"')
|
||
integration: marks tests as integration tests
|
||
```
|
||
|
||
### 2. 条件跳过
|
||
|
||
```python
|
||
import pytest
|
||
|
||
# 基于环境变量
|
||
import os
|
||
|
||
@pytest.mark.skipif(
|
||
os.getenv("CI") == "true",
|
||
reason="跳过 CI 环境"
|
||
)
|
||
def test_dev_only():
|
||
pass
|
||
|
||
|
||
# 基于平台
|
||
@pytest.mark.skipif(
|
||
sys.platform == "win32",
|
||
reason="不适用于 Windows"
|
||
)
|
||
def test_linux_only():
|
||
pass
|
||
```
|
||
|
||
### 3. Fixture 依赖
|
||
|
||
```python
|
||
@pytest.fixture
|
||
def database():
|
||
return {"connected": False}
|
||
|
||
|
||
@pytest.fixture
|
||
def db_session(database):
|
||
"""依赖另一个 fixture"""
|
||
session = {"db": database, "active": True}
|
||
return session
|
||
|
||
|
||
def test_database_connection(db_session):
|
||
assert db_session["active"] is True
|
||
```
|
||
|
||
### 4. 工厂 Fixture
|
||
|
||
```python
|
||
@pytest.fixture
|
||
def user_factory():
|
||
"""工厂 fixture - 每次返回新实例"""
|
||
created = []
|
||
|
||
def _create_user(name):
|
||
user = {"id": len(created) + 1, "name": name}
|
||
created.append(user)
|
||
return user
|
||
|
||
return _create_user
|
||
|
||
|
||
def test_create_users(user_factory):
|
||
user1 = user_factory("Alice")
|
||
user2 = user_factory("Bob")
|
||
|
||
assert user1["id"] == 1
|
||
assert user2["id"] == 2
|
||
```
|
||
|
||
### 5. 临时文件
|
||
|
||
```python
|
||
import pytest
|
||
from pathlib import Path
|
||
import tempfile
|
||
import shutil
|
||
|
||
|
||
@pytest.fixture
|
||
def temp_dir():
|
||
temp_path = Path(tempfile.mkdtemp())
|
||
yield temp_path
|
||
shutil.rmtree(temp_path, ignore_errors=True)
|
||
|
||
|
||
def test_file_operations(temp_dir):
|
||
file_path = temp_dir / "test.txt"
|
||
file_path.write_text("Hello, World!")
|
||
assert file_path.read_text() == "Hello, World!"
|
||
```
|
||
|
||
---
|
||
|
||
## 测试最佳实践
|
||
|
||
1. **测试名称要描述性**: `test_user_can_login` 比 `test_login` 更好
|
||
|
||
2. **AAA 模式**: Arrange (准备) -> Act (执行) -> Assert (断言)
|
||
|
||
3. **每个测试一个关注点**: 保持测试简短和专注
|
||
|
||
4. **使用描述性断言消息**:
|
||
```python
|
||
assert result == expected, f"Expected {expected}, got {result}"
|
||
```
|
||
|
||
5. **避免测试间的依赖**: 每个测试应该独立运行
|
||
|
||
6. **保持测试快速**: 单元测试应该毫秒级完成
|
||
|
||
7. **合理使用 Mock**: 不要过度 Mock,保持测试接近真实场景
|
||
|
||
8. **定期运行测试**: 建议配置 CI/CD 自动运行
|
||
|
||
---
|
||
|
||
## 常见问题
|
||
|
||
### Q: 如何测试异常?
|
||
|
||
```python
|
||
import pytest
|
||
|
||
def test_exception():
|
||
with pytest.raises(ValueError) as exc_info:
|
||
raise ValueError("error message")
|
||
|
||
assert str(exc_info.value) == "error message"
|
||
```
|
||
|
||
### Q: 如何测试警告?
|
||
|
||
```python
|
||
import warnings
|
||
import pytest
|
||
|
||
def test_warning():
|
||
with warnings.catch_warnings(record=True) as w:
|
||
warnings.warn("deprecated", DeprecationWarning)
|
||
|
||
assert len(w) == 1
|
||
assert str(w[0].message) == "deprecated"
|
||
```
|
||
|
||
### Q: 如何测试日志输出?
|
||
|
||
```python
|
||
import logging
|
||
import pytest
|
||
|
||
def test_logging(caplog):
|
||
logger = logging.getLogger("test")
|
||
logger.warning("test message")
|
||
|
||
assert "test message" in caplog.text
|
||
```
|
||
|
||
---
|
||
|
||
## 资源链接
|
||
|
||
- 官方文档: https://docs.pytest.org/
|
||
- Pytest 插件索引: https://plugincompat.herokuapp.com/
|
||
- Awesome Pytest: https://github.com/pytest-dev/pytest
|
||
|
||
---
|
||
|
||
> 提示: 在本项目中,所有测试文件位于 `tests/` 目录,运行 `pytest` 即可执行全部测试。
|