前置知识: 软件工程与测试

pytest

3 min中级

pytest单元测试框架:fixture、参数化、插件、配置与最佳实践详解。

1. pytest 基础

pytest 是 Python 生态事实上的标准测试框架。它的核心卖点:用原生 assert 写断言(失败时自动展示表达式两边的实际值),用函数参数实现依赖注入 (fixture),用装饰器实现参数化与标记。写过 unittest 的话,可以把它理解为 「少写一半样板代码」的替代品;刚入门则可以直接从 pytest 学起。

前置知识:Python 基础语法、函数与装饰器、pip 虚拟环境的基本使用。

1.1 安装

pip install pytest pytest-cov pytest-mock

1.2 基本示例

# test_calc.py
def add(a, b):
    return a + b

def test_add():
    assert add(1, 2) == 3
    assert add(-1, 1) == 0
    assert add(0, 0) == 0
pytest test_calc.py -v

1.3 测试发现规则

规则描述
文件名test_*.py 或 *_test.py
类名Test*(无 __init__)
函数名test_*

2. Fixture

2.1 基本用法

import pytest

@pytest.fixture
def sample_data():
    return [1, 2, 3, 4, 5]

def test_sum(sample_data):
    assert sum(sample_data) == 15

2.2 Fixture 作用域

作用域描述
function每个测试函数(默认)
class每个测试类
module每个模块
session整个测试会话
@pytest.fixture(scope="session")
def db_connection():
    conn = create_connection()
    yield conn
    conn.close()

2.3 Fixture 依赖

@pytest.fixture
def db(db_connection):
    return Database(db_connection)

@pytest.fixture
def user(db):
    return db.create_user(name="Alice")

2.4 conftest.py

# conftest.py - 自动发现,无需导入
import pytest

@pytest.fixture
def app():
    app = create_app()
    app.config["TESTING"] = True
    return app

@pytest.fixture
def client(app):
    return app.test_client()

2.5 参数化 Fixture

@pytest.fixture(params=["sqlite", "postgresql"])
def db_engine(request):
    engine = create_engine(request.param)
    yield engine
    engine.dispose()

def test_query(db_engine):
    result = db_engine.execute("SELECT 1")
    assert result.fetchone()[0] == 1

2.6 内置 fixture

pytest 自带一批开箱即用的 fixture,覆盖测试中最常见的三类需求:

fixture用途典型场景
tmp_path本次测试专属临时目录读写文件、验证落盘逻辑
capsys捕获 stdout/stderr验证命令行输出
monkeypatch临时修改环境/属性改环境变量、替换函数后还原
caplog捕获日志记录验证告警与日志内容
import os

def test_write_and_read(tmp_path):
    # tmp_path 是 pathlib.Path,测试结束后自动清理
    file = tmp_path / "data.txt"
    file.write_text("hello", encoding="utf-8")
    assert file.read_text(encoding="utf-8") == "hello"

def test_output(capsys):
    print("hello")
    captured = capsys.readouterr()
    assert "hello" in captured.out

def test_env(monkeypatch):
    # monkeypatch.setenv 只在本测试内生效,结束后自动还原
    monkeypatch.setenv("APP_MODE", "test")
    assert os.environ["APP_MODE"] == "test"

陷阱:不要在测试里手工改 os.environ 或全局变量——用 monkeypatch, 否则测试之间会互相污染,并行执行时问题尤其隐蔽。

3. 参数化测试

3.1 @pytest.mark.parametrize

@pytest.mark.parametrize("input,expected", [
    (1, 1),
    (2, 4),
    (3, 9),
    (-1, 1),
    (0, 0),
])
def test_square(input, expected):
    assert input ** 2 == expected

3.2 多参数组合

@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
    assert x * y > 0

3.3 参数化 ID

@pytest.mark.parametrize("input,expected", [
    ("hello", "HELLO"),
    ("WORLD", "WORLD"),
], ids=["lowercase", "uppercase"])
def test_upper(input, expected):
    assert input.upper() == expected

4. 标记(Markers)

4.1 内置标记

标记描述
@pytest.mark.skip跳过测试
@pytest.mark.skipif条件跳过
@pytest.mark.xfail预期失败
@pytest.mark.parametrize参数化
@pytest.mark.slow自定义标记

4.2 自定义标记

# pytest.ini
[pytest]
markers =
    slow: slow tests
    integration: integration tests

# 使用
@pytest.mark.slow
def test_large_dataset():
    ...

4.3 选择执行

# 运行非 slow 测试
pytest -m "not slow"

# 运行 integration 测试
pytest -m integration

# 组合
pytest -m "integration and not slow"

4.4 异常断言

用 pytest.raises 验证「代码是否按预期抛错」,这是把防御性逻辑纳入测试的 标准做法:

import pytest

def divide(a: int, b: int) -> int:
    if b == 0:
        raise ValueError("除数不能为零")
    return a // b

def test_zero_division():
    with pytest.raises(ZeroDivisionError):
        1 / 0

def test_divide_rejects_zero():
    # match 接收正则,额外校验错误消息
    with pytest.raises(ValueError, match="除数不能为零"):
        divide(6, 0)

常见错误是把 pytest.raises 的范围写得太宽,把整段业务逻辑都包进去—— 这样即使异常从错误的语句抛出,测试也会通过。with 块内只放「会抛错的那 一行」。

5. Mock 与 Patch

5.1 unittest.mock

from unittest.mock import Mock, patch

def test_api_call():
    mock_response = Mock()
    mock_response.json.return_value = {"status": "ok"}
    mock_response.status_code = 200

    with patch("requests.get", return_value=mock_response):
        result = fetch_data("https://api.example.com")
        assert result["status"] == "ok"

5.2 pytest-mock

def test_database_query(mocker):
    mock_db = mocker.patch("app.database.query")
    mock_db.return_value = [{"id": 1, "name": "Alice"}]

    result = get_users()
    assert len(result) == 1
    mock_db.assert_called_once()

6. 插件生态

插件功能
pytest-cov覆盖率
pytest-mockMock 封装
pytest-asyncio异步测试
pytest-djangoDjango 集成
pytest-flaskFlask 集成
pytest-xdist并行执行
pytest-timeout超时控制
pytest-randomly随机顺序

7. 配置

7.1 pyproject.toml

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-v --tb=short --strict-markers"
markers = [
    "slow: slow tests",
    "integration: integration tests",
]

7.2 覆盖率

pytest --cov=app --cov-report=html --cov-report=term-missing

8. 最佳实践

实践描述
命名规范test_ 前缀
单一断言每个测试一个关注点
AAA 模式Arrange-Act-Assert
Fixture 复用conftest.py 共享
参数化减少重复代码
Mock 外部依赖隔离测试
覆盖率目标80%+

小结

  • 初学者要点:pytest 的三块基石——原生 assert(失败自动展开)、fixture (函数参数即依赖注入,conftest.py 免导入共享)、@pytest.mark. parametrize(参数化);pytest.raises 验证异常路径,with 块内只放 会抛错的那一行。
  • 进阶注意:优先用内置 fixture(tmp_path/monkeypatch/capsys)而不是 手工管理临时状态,避免用例间污染;自定义标记必须在配置里注册(配合 --strict-markers 防拼写错误);-m 表达式("integration and not slow")+ pytest-xdist 并行是 CI 提速的标准组合;覆盖率阈值交给 --cov-fail-under 或 CI 门禁,而不是口头约定。