pytest全攻略:从安装到高级用法

pytest 全面用法详解

pytest 是一个功能强大、灵活且易于上手的 Python 测试框架,它支持简单的单元测试到复杂的功能测试,并提供了丰富的插件生态系统 。

一、 基础入门

1.1 安装与项目结构

bash 复制代码
# 安装 pytest
pip install pytest# 安装常用插件
pip install pytest-html  # 生成HTML报告
pip install pytest-xdist  # 分布式/并行测试
pip install pytest-rerunfailures  # 失败重试
pip install pytest-cov  # 覆盖率报告

推荐的项目测试目录结构

复制代码
project_root/
├── src/                    # 源代码目录
│ └── your_module.py
├── tests/                  # 测试目录
│   ├── conftest.py        # 全局 fixture 配置
│   ├── test_module_a.py   # 测试模块
│   ├── test_module_b.py
│   └── subpackage/        # 子包测试
│       └── test_sub.py
├── pytest.ini             # pytest 配置文件
└── requirements.txt

1.2 测试发现与命名规则

pytest 会自动发现并运行测试,遵循以下命名规则 :

  • 测试文件 :应以 test_ 开头或 _test 结尾(如 test_example.pyexample_test.py)。
  • 测试类 :应以 Test 开头,且不能有 __init__ 方法。
  • 测试函数/方法 :应以 test_ 开头。

示例测试文件

python 复制代码
# test_sample.py
def test_addition():
    """测试加法"""
    assert 1 + 2 == 3

class TestMathOperations:
    """测试数学运算类"""
    
    def test_multiplication(self):
        """测试乘法"""
        assert 3 * 4 == 12
    
    def test_division(self):
        """测试除法"""
        assert 8 / 2 == 4

二、 核心运行方式与配置

2.1 运行测试命令

命令 说明
pytest 运行当前目录及子目录下所有测试
pytest tests/ 运行指定目录下的测试
pytest test_file.py 运行指定文件内的测试
pytest test_file.py::test_func 运行指定文件的特定测试函数
pytest test_file.py::TestClass::test_method 运行指定类的特定测试方法
pytest -k "keyword" 运行名称包含关键字的测试(模糊匹配)
pytest -m marker_name 运行带有特定标记的测试
pytest -v 显示详细输出,包括每个测试用例的名称和结果
pytest -s 允许测试中的 print 语句输出到控制台
pytest -x 遇到第一个失败后立即停止测试
pytest --maxfail=2 允许的最大失败次数,达到后停止
pytest --lf--last-failed 只重新运行上次失败的测试
pytest --ff--failed-first 先运行失败的测试,再运行其他的

2.2 配置文件 pytest.ini

pytest.ini 文件用于配置 pytest 的默认行为,应放在项目根目录 。

ini 复制代码
# pytest.ini 示例
[pytest]
# 指定测试文件搜索的路径
testpaths = tests

# 指定测试文件名的模式
python_files = test_*.py

# 指定测试类名的模式
python_classes = Test*

# 指定测试函数/方法名的模式
python_functions = test_*

# 自定义命令行选项的默认值
addopts = -v -s --strict-markers# 注册自定义标记,防止拼写错误
markers =
    slow: marks tests as slow (deselect with '-m "not slow"')
    integration: marks tests as integration tests smoke: marks tests as smoke tests

# 设置日志格式和级别
log_format = %(asctime)s [%(levelname)s] %(message)s
log_date_format = %Y-%m-%d %H:%M:%S
log_cli = true
log_cli_level = INFO

# 设置 junitxml 报告(用于 CI/CD)
junit_family = xunit2

2.3 Exit Code 含义

pytest 运行结束后会返回一个退出码,常用于 CI/CD 流程判断 :

Exit Code 含义
0 所有测试通过
1 测试运行失败(有测试用例失败)
2 测试被用户中断(Ctrl+C)
3 测试执行过程中发生内部错误
4 pytest 命令行使用错误
5 未收集到任何测试用例

三、断言与测试报告

3.1 强大的断言机制

pytest 使用 Python 原生的 assert 语句进行断言,失败时会提供详细的上下文信息 。

python 复制代码
def test_complex_assertions():
    # 比较    assert "hello" == "hello"
    assert 5 > 3 # 成员检查 assert "lo" in "hello"
    assert 1 in [1, 2, 3]
    
    # 类型检查
    assert isinstance(42, int)
 # 异常断言 import pytest with pytest.raises(ValueError, match="invalid literal"):
        int("not_a_number")
    
    # 近似相等(用于浮点数比较)
    assert 0.1 + 0.2 == pytest.approx(0.3)

3.2 测试报告生成

bash 复制代码
# 生成 JUnit XML 格式报告(常用于 Jenkins等 CI 工具)
pytest --junitxml=report.xml

# 生成 HTML 报告(需安装 pytest-html)
pytest --html=report.html --self-contained-html

# 生成 Allure 报告(需安装 allure-pytest)
pytest --alluredir=./allure-results
# 然后使用 allure serve ./allure-results 查看报告

# 生成覆盖率报告(需安装 pytest-cov)
pytest --cov=src --cov-report=html --cov-report=term-missing

四、 Fixture:测试夹具Fixture 是 pytest 最强大的特性之一,用于为测试提供固定的、可重用的上下文环境 。

4.1 基本用法

python 复制代码
import pytest

# 定义一个简单的 fixture
@pytest.fixture
def sample_data():
    """提供测试数据"""
    return {"name": "Alice", "age": 30}

# 在测试函数中使用 fixture
def test_user_age(sample_data):
    assert sample_data["age"] == 30

# fixture 也可以返回更复杂的设置/清理逻辑
@pytest.fixture
def database_connection():
    """模拟数据库连接"""
    print("
建立数据库连接...")
    conn = {"connected": True, "db": "test_db"}
 yield conn  #测试执行时使用此处的值
    print("关闭数据库连接...")  # 清理代码在 yield 之后
    conn["connected"] = False

def test_db_query(database_connection):
    assert database_connection["connected"] is True

4.2 Fixture 作用域 (scope)

Fixture 可以有不同的作用域,控制其创建和销毁的频率 。

作用域 说明 适用场景
function 默认值,每个测试函数运行一次 独立的测试数据
class 每个测试类运行一次 类级别的共享设置
module 每个模块运行一次 模块级别的共享资源
package 每个包运行一次 包级别的配置
session 一次测试会话运行一次 全局数据库连接、登录状态
python 复制代码
import pytest

@pytest.fixture(scope="session")
def global_config():
    """会话级别的配置,整个测试过程只初始化一次"""
    config = {"env": "test", "timeout": 30}
    print("初始化全局配置")
    yield config
    print("清理全局配置")

@pytest.fixture(scope="module")
def shared_resource():
    """模块级别的共享资源"""
    resource = {"data": [1, 2, 3]}
    print("初始化模块资源")
    yield resource
    print("清理模块资源")

4.3 Fixture 参数化Fixture 本身也可以参数化,为不同的测试提供不同的数据 。

python 复制代码
import pytest

@pytest.fixture(params=["chrome", "firefox", "edge"])
def browser(request):
    """参数化的浏览器 fixture"""
    browser_name = request.param
    print(f"
启动 {browser_name} 浏览器")
    yield browser_name print(f"关闭 {browser_name} 浏览器")

def test_browser_compatibility(browser):
    """这个测试会使用三种浏览器各运行一次"""
    assert browser in ["chrome", "firefox", "edge"]
    # 这里可以编写针对不同浏览器的测试逻辑

4.4自动使用 Fixture (autouse)

设置 autouse=True 可以让 fixture 自动应用于所有测试,无需显式声明 。

python 复制代码
import pytest
import time

@pytest.fixture(autouse=True)
def timer():
    """自动为每个测试计时"""
    start = time.time()
    yield
    end = time.time()
    print(f"
测试耗时: {end - start:.3f} 秒")

def test_fast():
    time.sleep(0.1)
    assert True

def test_slow():
    time.sleep(0.5)
    assert True

4.5 conftest.py:共享 Fixture

conftest.py 文件用于存放被多个测试文件共享的 fixture,pytest 会自动发现该文件 。

python 复制代码
# tests/conftest.py
import pytest
import json

@pytest.fixture(scope="session")
def api_base_url():
    """共享的 API 基础 URL"""
    return "https://api.example.com/v1"

@pytest.fixture
def read_test_data():
    """读取测试数据的共享 fixture"""
    def _read_data(filename):
        with open(f"tests/data/{filename}", "r") as f:
            return json.load(f)
    return _read_data
python 复制代码
# tests/test_api.py
def test_user_endpoint(api_base_url, read_test_data):
    """使用 conftest.py 中定义的共享 fixture"""
    base_url = api_base_url
    user_data = read_test_data("user_data.json")
    # 测试逻辑...

五、 参数化测试

参数化测试允许使用不同的输入数据多次运行同一测试逻辑 。

5.1 基本参数化

python 复制代码
import pytest

# 使用 @pytest.mark.parametrize 装饰器
@pytest.mark.parametrize("input_val, expected", [
    (1, 2),    # 第一次运行: input_val=1, expected=2 (2, 4),    # 第二次运行: input_val=2, expected=4 (3, 6),    # 第三次运行: input_val=3, expected=6
])
def test_double(input_val, expected):
    """测试输入值翻倍"""
    assert input_val * 2 == expected

# 参数化类中的测试方法
class TestMath:
    @pytest.mark.parametrize("a,b,expected", [
        (1, 2, 3),
        (5, 5, 10),
        (-1, 1, 0),
    ])
    def test_addition(self, a, b, expected):
        assert a + b == expected

5.2 参数化组合

python 复制代码
import pytest

# 多个参数化装饰器会产生组合
@pytest.mark.parametrize("x", [0, 1])
@pytest.mark.parametrize("y", [2, 3])
def test_combinations(x, y):
    """这个测试会运行 2×2=4 次"""
    print(f"测试组合: x={x}, y={y}")
    assert isinstance(x + y, int)

5.3 为参数化用例添加 ID

python 复制代码
import pytest

@pytest.mark.parametrize(
    "input_val, expected",
    [
        pytest.param(1, 2, id="正整数"),
        pytest.param(0, 0, id="零"),
        pytest.param(-1, -2, id="负整数"),
        pytest.param(2.5, 5.0, id="浮点数"),
    ]
)
def test_with_ids(input_val, expected):
    """每个参数化用例都有描述性 ID"""
    assert input_val * 2 == expected

六、 标记 (Markers) 与跳过测试

6.1 内置标记

标记 说明 示例
@pytest.mark.skip 无条件跳过测试 @pytest.mark.skip
@pytest.mark.skipif 条件跳过测试 @pytest.mark.skipif(sys.version_info < (3, 8), reason="需要 Python 3.8+")
@pytest.mark.xfail 预期测试会失败 @pytest.mark.xfail
@pytest.mark.parametrize 参数化测试 见上一节
@pytest.mark.usefixtures 强制使用 fixture @pytest.mark.usefixtures("cleandir")

6.2 自定义标记

python 复制代码
import pytest
import time

# 使用在 pytest.ini 中注册的标记
@pytest.mark.slow
def test_slow_operation():
    """标记为慢测试,可以通过 -m "not slow" 跳过"""
    time.sleep(2)
    assert True

@pytest.mark.integration
def test_api_integration():
    """集成测试标记"""
    # 调用外部 API 的测试
    assert True

# 运行命令示例:
# pytestm "slow" # 只运行慢测试
# pytest -m "integration"    # 只运行集成测试
# pytest -m "not slow"       # 运行除慢测试外的所有测试
# pytest -m "slow and integration"  # 运行同时满足两个标记的测试

6.3 跳过与预期失败

python 复制代码
import pytest
import sys

# 无条件跳过
@pytest.mark.skip(reason="功能尚未实现")
def test_unimplemented():
    assert False

# 条件跳过
@pytest.mark.skipif(
    sys.platform != "linux",
    reason="此测试仅在 Linux 系统上运行"
)
def test_linux_specific():
    assert True

# 预期失败(测试通过反而会报错)
@pytest.mark.xfail(reason="已知问题,正在修复")
def test_buggy_feature():
    assert 1 == 2  # 这个断言会失败,但符合预期

@pytest.mark.xfail
def test_should_fail_but_passes():
    """这个测试预期失败但实际通过了,会报告为 XPASS"""
    assert True  # 这不符合预期!

七、 钩子函数 (Hooks)

钩子函数允许你自定义和扩展 pytest 的行为,需要在 conftest.py 中实现 。

7.1 常用钩子函数| 钩子阶段 | 常用钩子函数 | 用途 |

| :--- | :--- | :--- |

| 初始化 | pytest_configure(config) | pytest 配置初始化时调用 |

| 测试收集 | pytest_collection_modifyitems(items) | 修改收集到的测试项(如重新排序) |

| | pytest_collection_finish(session) | 测试收集完成后调用 |

| 测试执行 | pytest_runtest_setup(item) | 单个测试执行前调用 |

| | pytest_runtest_call(item) | 执行测试函数时调用 |

| | pytest_runtest_teardown(item) | 单个测试执行后调用 |

| | pytest_runtest_makereport(item, call) | 为测试创建报告时调用 |

| 报告与日志 | pytest_report_teststatus(report) | 自定义测试状态显示 |

| | pytest_report_header(config) | 在报告头部添加自定义信息 |

| Fixture 生命周期 | pytest_fixture_setup(fixturedef) | fixture 设置时调用 |

| | pytest_fixture_post_finalizer(fixturedef) | fixture 清理后调用 |

7.2 钩子函数示例

python 复制代码
# conftest.py
import pytest
from datetime import datetime

def pytest_configure(config):
    """pytest 配置初始化钩子"""
    config.addinivalue_line(
        "markers", "smoke: 冒烟测试"
    )
    print("pytest 配置初始化完成")

def pytest_report_header(config):
    """在报告头部添加自定义信息"""
    return [
        "项目: My Test Suite",
        f"开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
        "运行环境: Python " + sys.version.split()[0]
    ]

def pytest_collection_modifyitems(items):
    """修改收集到的测试项"""
    # 将标记为 smoke 的测试移到最前面
    smoke_items = []
    other_items = []
 for item in items:
        if "smoke" in item.keywords:
            smoke_items.append(item)
        else:
            other_items.append(item)
    
    items[:] = smoke_items + other_items # 为所有测试项添加超时标记(示例)
    for item in items:
        item.add_marker(pytest.mark.timeout(30))

def pytest_runtest_makereport(item, call):
    """测试报告生成钩子"""
    if call.when == "call":  # 仅关注测试执行阶段
        if call.excinfo is not None:  # 测试失败 print(f"
测试失败: {item.name}")
            print(f"错误类型: {call.excinfo.typename}")
            print(f"错误信息: {call.excinfo.value}")

八、 常用插件与高级功能

8.1 并行测试 (pytest-xdist)

bash 复制代码
# 使用所有 CPU 核心并行运行测试
pytest -n auto

# 指定并行进程数
pytest -n 4

# 并行运行并显示每个测试的输出
pytest -n 2 -v

# 按模块并行(避免测试间依赖问题)
pytest -n auto --dist=loadscope

8.2 失败重试 (pytest-rerunfailures)

bash 复制代码
# 失败时重试 3 次pytest --reruns 3

# 失败时重试3 次,每次间隔 2 秒
pytest --reruns 3 --reruns-delay 2

# 仅对某些标记的测试重试
@pytest.mark.flaky(reruns=3, reruns_delay=1)
def test_flaky_api():
    # 不稳定的 API 测试
    pass

8.3 测试覆盖率 (pytest-cov)

bash 复制代码
# 测量整个项目的覆盖率
pytest --cov=.

# 测量特定模块的覆盖率
pytest --cov=src.my_module

# 生成 HTML 报告并显示缺失的代码行
pytest --cov=src --cov-report=html --cov-report=term-missing

# 设置覆盖率阈值,低于阈值则测试失败
pytest --cov=src --cov-fail-under=80

8.4 测试排序 (pytest-order)

python 复制代码
import pytest

@pytest.mark.order(1)
def test_first():
    print("第一个运行")
    assert True

@pytest.mark.order(3)
def test_third():
    print("第三个运行")
    assert True

@pytest.mark.order(2)
def test_second():
    print("第二个运行")
    assert True

# 运行命令: pytest --order-scope=function

九、 与 unittest 兼容

pytest 可以直接运行 unittest 风格的测试用例 。

python 复制代码
import unittest

# unittest 风格的测试类
class TestUnittestStyle(unittest.TestCase):
 def setUp(self):
        """每个测试方法前运行"""
        self.data = [1, 2, 3]
 def tearDown(self):
        """每个测试方法后运行"""
        self.data = None def test_length(self):
        self.assertEqual(len(self.data), 3)
 def test_contains(self):
        self.assertIn(2, self.data)
 @unittest.skip("跳过示例")
    def test_skip_example(self):
        self.fail("这个测试不会运行")

# 可以直接用 pytest 命令运行
# pytest test_unittest_style.py

十、 最佳实践与常见问题

10.1 测试组织最佳实践

  1. 保持测试独立:每个测试应该独立运行,不依赖其他测试的状态或顺序。
  2. 使用有意义的名称:测试函数/方法名应该描述测试的目的。
  3. 一个断言一个概念:每个测试最好只验证一个逻辑概念。
  4. 合理使用 fixture:将通用的设置和清理逻辑提取到 fixture 中。
  5. 避免测试中的逻辑:测试代码应该简单直接,避免复杂的条件或循环。
  6. 测试正面和负面情况:既要测试正常路径,也要测试异常和边界情况。

10.2 常见问题解决

问题1:测试发现失败

bash 复制代码
# 确保测试文件、类、函数命名符合规则
# 检查 pytest.ini 中的配置
# 使用 pytest --collect-only 查看哪些测试被收集到

问题2:Fixture 作用域问题

python 复制代码
# 如果 fixture 状态在测试间意外共享,检查 scope 设置
# 考虑使用 function 作用域而不是 session/module 作用域
# 或者确保 fixture 返回的是不可变数据或深拷贝

问题3:测试依赖外部资源

python 复制代码
# 使用 mocking 替代真实外部调用
from unittest.mock import Mock, patch

def test_external_api():
    with patch('requests.get') as mock_get:
        mock_get.return_value.status_code = 200
        mock_get.return_value.json.return_value = {"key": "value"}
        # 测试代码...

问题4:测试执行顺序依赖

bash 复制代码
# 使用 pytest-randomly 插件随机化测试顺序,发现隐藏的依赖
pip install pytest-randomly
pytest --randomly-seed=42

10.3 性能优化建议

  1. 使用适当的 fixture 作用域:将昂贵的资源设置为 session 或 module 作用域。
  2. 并行执行 :对独立测试使用 pytest-xdist 并行运行。
  3. 测试分组:将快速测试和慢速测试分开,使用标记区分。
  4. 避免不必要的导入:在 fixture 或测试函数内部进行耗时导入。
  5. 使用测试数据工厂:避免从文件重复读取测试数据。
python 复制代码
# 使用 lazy fixture 延迟加载
@pytest.fixture(scope="session")
def expensive_resource():
    """昂贵的资源只在需要时初始化"""
    resource = None    def _get_resource():
        nonlocal resource if resource is None:
            print("初始化昂贵资源...")
            resource = ExpensiveClass()
        return resource return _get_resource

通过掌握以上内容,你可以充分利用 pytest 的强大功能来构建高效、可维护的测试套件。pytest 的插件生态系统非常丰富,可以根据项目需求选择合适的插件进一步扩展功能 。


参考来源