Перейти к содержанию
Innovialabs

Тестирование на Python с pytest: фикстуры, параметризация и моки

Практика3 мин

Коротко

pytest — это простые функции test_* с обычным assert; фикстуры готовят данные и ресурсы, parametrize прогоняет один тест на многих входах, а моки заменяют сеть и внешние сервисы.

Что нужно знать сразу

pytest находит файлы test_*.py, запускает в них функции test_* и проверяет обычные assert. Никаких классов-наследников и специальных методов сравнения не нужно. Поверх этого есть три главных инструмента:

  • фикстуры — подготовка данных и ресурсов для тестов;
  • parametrize — один тест на много наборов входных данных;
  • моки — подмена сети, времени и внешних сервисов.

Установка и первый тест

python -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install pytest

Код и тест:

# app/pricing.py
def apply_discount(price, percent):
    if not 0 <= percent <= 100:
        raise ValueError("percent must be 0..100")
    return round(price * (100 - percent) / 100, 2)
# tests/test_pricing.py
import pytest
from app.pricing import apply_discount

def test_discount_basic():
    assert apply_discount(200, 10) == 180

def test_discount_invalid_percent():
    with pytest.raises(ValueError):
        apply_discount(100, 150)

Запуск: pytest в корне проекта. Полезные флаги: -v — подробный вывод, -x — остановиться на первой ошибке, -k discount — запустить тесты по части имени. При падении pytest показывает значения обеих сторон assert, поэтому дебажить проще.

Фикстуры

Фикстура — функция с @pytest.fixture, результат которой pytest передаёт в тест по имени аргумента.

import pytest

@pytest.fixture
def cart():
    return {"items": [], "currency": "UZS"}

def test_empty_cart(cart):
    assert cart["items"] == []

Если ресурс нужно закрыть после теста, используйте yield: всё после него выполнится как teardown.

@pytest.fixture
def db_connection():
    conn = connect_test_db()
    yield conn
    conn.close()

Параметр scope задаёт время жизни: function (по умолчанию, свежая фикстура на каждый тест), module, session. Дорогие ресурсы вроде тестовой базы удобно создавать один раз на сессию, но тогда следите, чтобы тесты не портили общие данные.

Встроенные фикстуры, которые стоит знать: tmp_path — временная папка, monkeypatch — подмена атрибутов и переменных окружения, capsys — перехват вывода.

conftest.py

Фикстуры, нужные в нескольких файлах, кладите в conftest.py. pytest подхватывает его автоматически — импортировать ничего не нужно. conftest.py в папке tests/ виден всем тестам внутри неё, а во вложенной папке — только её тестам.

Параметризация

Вместо десяти почти одинаковых тестов — один:

@pytest.mark.parametrize(
    "price, percent, expected",
    [
        (100, 0, 100),
        (100, 25, 75),
        (99.99, 10, 89.99),
        (100, 100, 0),
    ],
)
def test_discount(price, percent, expected):
    assert apply_discount(price, percent) == expected

Каждый набор отображается в отчёте как отдельный тест, и сразу видно, какой именно случай упал. Для читаемых имён можно передать ids=[...].

Моки HTTP-запросов

Тесты не должны ходить в реальную сеть: это медленно, нестабильно и может менять чужие данные. Подменяйте вызов на уровне вашего кода:

# app/rates.py
import requests

def get_usd_rate():
    resp = requests.get("https://api.example.com/rates", timeout=5)
    resp.raise_for_status()
    return resp.json()["USD"]
# tests/test_rates.py
from unittest.mock import Mock
from app import rates

def test_get_usd_rate(monkeypatch):
    fake = Mock()
    fake.json.return_value = {"USD": 12650.0}
    fake.raise_for_status.return_value = None
    monkeypatch.setattr(rates.requests, "get", Mock(return_value=fake))

    assert rates.get_usd_rate() == 12650.0

Главное правило: патчите там, где объект используется, а не там, где он определён. Для удобной работы с HTTP есть отдельные библиотеки, например responses для requests или respx для httpx, — они позволяют описывать фейковые ответы декларативно.

Структура тестов в реальном проекте

project/
├── app/
│   ├── pricing.py
│   └── rates.py
├── tests/
│   ├── conftest.py
│   ├── unit/
│   │   └── test_pricing.py
│   └── integration/
│       └── test_rates_api.py
└── pyproject.toml

Настройки — в pyproject.toml:

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"
markers = ["integration: tests that need external services"]

Медленные тесты помечайте @pytest.mark.integration и запускайте отдельно: pytest -m "not integration".

Частые ошибки

  • Тесты зависят от порядка запуска или общего состояния.
  • Моки подменяют слишком много — тест проверяет мок, а не код.
  • Сравнение float через == без pytest.approx.
  • Один тест проверяет пять разных вещей — при падении непонятно, что сломалось.

Частые вопросы

pytest запускает и тесты на unittest, но пишется короче: обычный assert, фикстуры вместо setUp, параметризация из коробки и богатая экосистема плагинов.

Поможем с этим

Если хотите не разбираться самим, а получить результат — этим мы занимаемся каждый день.

Обсудить проект

Читайте также

Есть идея или задача? Расскажите нам

Ответим в течение рабочего дня и предложим решение. Консультация бесплатная.