跳转至

pytest 高级用法

一句话概述:pytest 是 Python 最流行的测试框架,用简单的 assert 语句写测试,支持 fixture 依赖注入、参数化测试、插件扩展,比 unittest 简洁得多。

核心知识点

概念白话解释
fixture测试前的准备工作(比如创建数据库连接),用 @pytest.fixture 装饰
参数化一个测试函数用不同参数跑多次,@pytest.mark.parametrize
标记(mark)给测试打标签,比如 @pytest.mark.slow 标记慢测试
conftest.py共享 fixture 的文件,同目录及子目录自动可用
插件扩展 pytest 功能,比如 pytest-cov(覆盖率)、pytest-mock(mock)
夹具作用域fixture 的生命周期:function/class/module/session

安装配置

# 安装 pytest 和常用插件
pip install pytest  # 核心框架
pip install pytest-cov  # 代码覆盖率
pip install pytest-mock  # Mock 工具
pip install pytest-xdist  # 并行执行测试
pip install pytest-asyncio  # 异步测试支持
pip install pytest-timeout  # 超时控制
pip install pytest-html  # HTML 报告

配置文件

# pyproject.toml - 推荐的配置方式
[tool.pytest.ini_options]
testpaths = ["tests"]  # 测试文件目录
python_files = ["test_*.py"]  # 测试文件名模式
python_functions = ["test_*"]  # 测试函数名模式
addopts = "-v --tb=short --strict-markers"  # 默认参数:详细输出、短回溯、严格标记
markers = [  # 自定义标记
    "slow: 慢速测试",
    "integration: 集成测试",
    "e2e: 端到端测试",
]

基本使用

简单测试

# tests/test_math.py
def add(a, b):
    """加法函数"""
    return a + b  # 返回两数之和

def test_add():
    """测试加法"""
    assert add(1, 2) == 3  # 用 assert 断言结果
    assert add(-1, 1) == 0  # 测试负数
    assert add(0, 0) == 0  # 测试零

def test_add_float():
    """测试浮点数加法"""
    result = add(0.1, 0.2)
    assert abs(result - 0.3) < 1e-9  # 浮点数比较要用近似

运行测试

pytest  # 运行所有测试
pytest tests/test_math.py  # 运行指定文件
pytest -k "test_add"  # 按名称过滤
pytest -v  # 详细输出
pytest -x  # 遇到第一个失败就停止
pytest --tb=long  # 详细的错误回溯
pytest -s  # 显示 print 输出
pytest --co  # 只列出测试,不执行

高级用法

Fixture 夹具

# tests/conftest.py - 共享 fixture(自动被同目录及子目录的测试发现)
import pytest  # 导入 pytest

@pytest.fixture  # 声明为 fixture
def sample_user():
    """创建一个测试用户"""
    return {"id": 1, "name": "张三", "email": "zhang@test.com"}  # 返回测试数据

@pytest.fixture
def db_connection():
    """创建数据库连接(带清理)"""
    conn = create_connection()  # 测试前:建立连接
    yield conn  # yield 之前是 setup,之后是 teardown
    conn.close()  # 测试后:关闭连接

@pytest.fixture(scope="session")  # session 作用域:整个测试会话只执行一次
def app():
    """创建测试应用(整个测试会话复用)"""
    app = create_app(testing=True)  # 创建测试应用
    yield app

@pytest.fixture(scope="module")  # module 作用域:每个模块执行一次
def shared_data():
    """模块级共享数据"""
    return load_test_data()

# 使用 fixture(参数名和 fixture 函数名一致就自动注入)
def test_user_name(sample_user):  # 自动注入 sample_user fixture
    assert sample_user["name"] == "张三"  # 使用 fixture 返回的数据

def test_db_query(db_connection):  # 自动注入 db_connection fixture
    result = db_connection.execute("SELECT 1")  # 使用数据库连接
    assert result is not None

Fixture 参数化

@pytest.fixture(params=["sqlite", "postgresql", "mysql"])  # 每个参数都会运行一遍测试
def db_engine(request):  # request.param 获取当前参数
    """测试多种数据库引擎"""
    engine = create_engine(request.param)  # 创建对应的引擎
    yield engine  # 返回引擎
    engine.dispose()  # 测试后清理

def test_insert(db_engine):  # 这个测试会跑 3 次(sqlite/postgresql/mysql)
    db_engine.insert({"name": "test"})
    assert db_engine.count() == 1

参数化测试

import pytest

# 基本参数化:一个测试跑多组数据
@pytest.mark.parametrize("input_val, expected", [  # 参数名和值列表
    (1, 1),        # 正数
    (0, 0),        # 零
    (-1, 1),       # 负数
    (100, 100),    # 大数
])
def test_abs(input_val, expected):
    """测试绝对值"""
    assert abs(input_val) == expected  # 每组参数跑一次

# 多参数组合
@pytest.mark.parametrize("a", [1, 2, 3])  # a 有 3 个值
@pytest.mark.parametrize("b", [10, 20])   # b 有 2 个值
def test_multiply(a, b):  # 会跑 3×2=6 次
    assert a * b == a * b

# 带 ID 的参数化(让测试报告更清晰)
@pytest.mark.parametrize("email, valid", [
    pytest.param("test@example.com", True, id="正常邮箱"),
    pytest.param("invalid", False, id="无效格式"),
    pytest.param("", False, id="空字符串"),
    pytest.param("a@b.c", True, id="最短合法邮箱"),
])
def test_email_validation(email, valid):
    assert is_valid_email(email) == valid

标记(Mark)

import pytest

@pytest.mark.slow  # 标记为慢测试
def test_large_data():
    """处理大量数据的测试"""
    data = generate_large_dataset(1000000)
    result = process(data)
    assert len(result) > 0

@pytest.mark.integration  # 标记为集成测试
def test_api_call():
    """需要真实 API 的测试"""
    response = call_external_api()
    assert response.status == 200

@pytest.mark.skip(reason="功能还没实现")  # 跳过测试
def test_future_feature():
    pass

@pytest.mark.skipif(  # 条件跳过
    sys.platform == "win32",
    reason="只在 Linux 上跑"
)
def test_linux_only():
    pass

@pytest.mark.xfail(reason="已知 bug,等修复")  # 预期失败
def test_known_bug():
    assert buggy_function() == "correct"
# 按标记运行
pytest -m slow  # 只跑标记为 slow 的
pytest -m "not slow"  # 跳过 slow 的
pytest -m "integration or e2e"  # 跑集成或 E2E 测试

Mock 模拟

from unittest.mock import patch, MagicMock
import pytest

# 方式一:pytest-mock(推荐)
def test_api_call(mocker):  # mocker 是 pytest-mock 提供的 fixture
    # 模拟 requests.get 返回假数据
    mock_response = mocker.Mock()  # 创建 mock 对象
    mock_response.json.return_value = {"name": "张三"}  # 设置返回值
    mock_response.status_code = 200

    mocker.patch("requests.get", return_value=mock_response)  # 替换 requests.get

    result = fetch_user(1)  # 这里调用的 requests.get 是 mock 的
    assert result["name"] == "张三"

# 方式二:标准库 patch
@patch("myapp.db.get_user")  # 替换指定函数
def test_with_patch(mock_get_user):
    mock_get_user.return_value = {"id": 1, "name": "李四"}  # 设置返回值
    user = get_user_info(1)
    assert user["name"] == "李四"
    mock_get_user.assert_called_once_with(1)  # 验证被调用了一次且参数是 1

异步测试

import pytest
import asyncio

@pytest.mark.asyncio  # 标记为异步测试
async def test_async_function():
    """测试异步函数"""
    result = await async_fetch_data()  # await 异步函数
    assert result["status"] == "ok"

@pytest.mark.asyncio
async def test_async_timeout():
    """测试异步超时"""
    with pytest.raises(asyncio.TimeoutError):  # 断言抛出超时错误
        await asyncio.wait_for(slow_function(), timeout=1.0)

覆盖率

# 运行测试并生成覆盖率报告
pytest --cov=src  # 统计 src 目录的覆盖率
pytest --cov=src --cov-report=html  # 生成 HTML 报告
pytest --cov=src --cov-report=term-missing  # 终端显示未覆盖的行号
pytest --cov=src --cov-fail-under=80  # 覆盖率低于 80% 则失败

常见报错

报错信息原因解决方案
ModuleNotFoundError导入路径不对在项目根目录跑 pytest,或加 conftest.py
fixture 'xxx' not foundfixture 没定义或不在作用域检查 conftest.py 位置和 fixture 名称
PytestUnraisableExceptionWarning异步资源没清理在 fixture 的 yield 后加清理代码
FAILED (xfail)标记了 xfail 但实际通过了移除 xfail 标记(bug 已修复)
marker not defined自定义标记没注册pyproject.toml 的 markers 里注册
测试发现不了文件名/函数名不符合约定文件名 test_*.py,函数名 test_*

速查表

# CLI 命令
pytest                              # 运行所有测试
pytest -v                           # 详细输出
pytest -x                           # 第一个失败就停
pytest -k "关键字"                   # 按名称过滤
pytest -m "标记"                     # 按标记过滤
pytest --lf                         # 只跑上次失败的
pytest --ff                         # 先跑上次失败的
pytest -n auto                      # 并行执行(需要 pytest-xdist)
pytest --cov=src                    # 覆盖率
pytest --html=report.html           # HTML 报告
pytest --timeout=60                 # 超时控制

# 常用断言
assert x == y               # 等于
assert x != y               # 不等于
assert x in collection      # 在集合中
assert x is None            # 是 None
assert isinstance(x, Type)  # 是某类型

# 异常断言
with pytest.raises(ValueError):       # 断言抛出 ValueError
    raise ValueError("xxx")
with pytest.raises(ValueError, match="xxx"):  # 还要匹配消息

# 近似断言
assert x == pytest.approx(3.14, abs=0.01)  # 近似相等

# fixture 作用域
@pytest.fixture(scope="function")   # 每个测试函数(默认)
@pytest.fixture(scope="class")      # 每个测试类
@pytest.fixture(scope="module")     # 每个模块
@pytest.fixture(scope="session")    # 整个测试会话

参考:pytest 官方文档 | pytest-cov | pytest-mock