Тестирование на Python с pytest: фикстуры, параметризация и моки
Практика3 минРедакция Innovialabs
Коротко
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. - Один тест проверяет пять разных вещей — при падении непонятно, что сломалось.