Files
XCClaw/docs/pytest-guide.md

14 KiB
Raw Blame History

Pytest 完整使用指南

Pytest 是 Python 最流行的测试框架,以其简洁的 API 和强大的功能被广泛应用于各种项目中。

目录

  1. 快速开始
  2. 编写测试
  3. Fixtures
  4. 异步测试
  5. Mocking
  6. 参数化测试
  7. 跳过测试
  8. 测试夹具作用域
  9. 测试组织
  10. 常用命令
  11. 配置文件

1. 快速开始

安装

pip install pytest pytest-asyncio

运行测试

# 运行所有测试
pytest

# 运行指定文件
pytest tests/test_api.py

# 运行指定测试函数
pytest tests/test_api.py::test_health_check

# 显示详细输出
pytest -v

# 显示打印内容
pytest -s

# 在第一个失败处停止
pytest -x

2. 编写测试

基本结构

# 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]

测试类

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

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

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 参数化

@pytest.fixture
def user_data():
    return {"username": "testuser", "email": "test@example.com"}


def test_user_creation(user_data):
    assert user_data["username"] == "testuser"

4. 异步测试

安装异步支持

pip install pytest-asyncio

配置 pytest.ini

[pytest]
asyncio_mode = auto

编写异步测试

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 返回协程

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

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 第三方库

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

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

多参数组合

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

import pytest

@pytest.fixture
def pytest_param_user():
    return pytest.param("user1", marks=pytest.mark.slow)


def test_user_stuff():
    pass

7. 跳过测试

跳过条件

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

预期失败

@pytest.mark.xfail(reason="已知 bug")
def test_known_bug():
    assert False  # 预期失败

8. 测试夹具作用域

Fixture 可以指定不同的作用域:

# 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 共享配置

# 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. 常用命令

# 基本运行
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

[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

[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. 自定义标记

# 在代码中使用
@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. 条件跳过

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 依赖

@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

@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. 临时文件

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_logintest_login 更好

  2. AAA 模式: Arrange (准备) -> Act (执行) -> Assert (断言)

  3. 每个测试一个关注点: 保持测试简短和专注

  4. 使用描述性断言消息:

    assert result == expected, f"Expected {expected}, got {result}"
    
  5. 避免测试间的依赖: 每个测试应该独立运行

  6. 保持测试快速: 单元测试应该毫秒级完成

  7. 合理使用 Mock: 不要过度 Mock保持测试接近真实场景

  8. 定期运行测试: 建议配置 CI/CD 自动运行


常见问题

Q: 如何测试异常?

import pytest

def test_exception():
    with pytest.raises(ValueError) as exc_info:
        raise ValueError("error message")
    
    assert str(exc_info.value) == "error message"

Q: 如何测试警告?

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: 如何测试日志输出?

import logging
import pytest

def test_logging(caplog):
    logger = logging.getLogger("test")
    logger.warning("test message")
    
    assert "test message" in caplog.text

资源链接


提示: 在本项目中,所有测试文件位于 tests/ 目录,运行 pytest 即可执行全部测试。