Юнит-тесты в Python часто начинаются с unittest, а заканчиваются тем, что половина проверок закомментирована: запускать их долго, а падение сложно читать. pytest убирает лишний каркас. Тест — это обычная функция с assert, а фикстуры, параметризация, временные каталоги и отчёты уже встроены. Ниже — рабочая настройка проекта, типовые приёмы и грабли, на которые наступают при переходе.
Почему pytest, а не unittest
В unittest проверка живёт внутри класса, а сравнение приходится писать через self.assertEqual. pytest сам разбирает выражение в assert и показывает значения обеих сторон, поэтому отладка занимает секунды. Тесты из unittest тоже запускаются без переписывания, так что миграцию можно делать постепенно.
Второй плюс — расширения. Покрытие, параллельный запуск, работа с Django или FastAPI, сравнение снимков и проверка типов подключаются отдельными пакетами.
Подготовка окружения
Ставьте pytest в виртуальное окружение проекта. В свежих Debian, Ubuntu и Mint системный pip блокирует установку в глобальные каталоги, и это правильно: версии не должны конфликтовать между проектами.
cd ~/projects/notes-api
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -U pip pytest pytest-cov
python -m pytest --version
В Windows активация выглядит как .venv\Scripts\activate. В CI на Ubuntu пакет python3-venv может отсутствовать, его добавляют в шаг установки зависимостей.
Структура проекта и первый тест
Держите тесты в отдельном каталоге tests и повторяйте структуру пакета. Имена файлов должны начинаться с test_ или заканчиваться на _test.py, иначе сборщик их не увидит. Для примера возьмём маленький модуль расчёта стоимости заказа.
# app/pricing.py
def total(price, count, discount=0):
if count < 1:
raise ValueError("count must be positive")
base = price * count
return round(base * (1 - discount), 2)
# tests/test_pricing.py
import pytest
from app.pricing import total
def test_total_without_discount():
assert total(100, 3) == 300
def test_invalid_count():
with pytest.raises(ValueError, match="positive"):
total(100, 0)
Запуск из корня проекта: python -m pytest -q. Ключ -q убирает лишнюю шапку, но оставляет прогресс и итог.
Параметризация вместо копипасты
Одинаковые проверки с разными данными описываются одним декоратором. Каждый набор считается отдельным тестом, поэтому видно, какой именно случай упал.
@pytest.mark.parametrize(
"price,count,discount,expected",
[
(100, 1, 0, 100),
(100, 2, 0.1, 180),
(99.99, 3, 0.15, 254.97),
],
ids=["one", "two-with-discount", "rounding"],
)
def test_total_cases(price, count, discount, expected):
assert total(price, count, discount) == expected
Фикстуры: подготовка без дублей
Фикстура — функция, которая готовит ресурс и отдаёт его тесту. Всё после yield выполняется на выходе, даже если проверка упала. Общие фикстуры кладут в tests/conftest.py, pytest находит их автоматически.
# tests/conftest.py
import pytest
from app.db import connect, create_schema
@pytest.fixture()
def db():
conn = connect(":memory:")
create_schema(conn)
yield conn
conn.close()
@pytest.fixture(scope="session")
def settings():
return {"currency": "RUB", "tax": 0.2}
scope=»session» создаёт объект один раз на весь прогон. Это удобно для тяжёлых настроек, но опасно для изменяемого состояния: один тест может испортить данные другому.
Временные файлы и переменные окружения
Писать в реальный домашний каталог нельзя: тесты должны быть изолированы. Фикстура tmp_path даёт уникальный каталог типа pathlib.Path, изолированный от остальных тестов. monkeypatch меняет переменные окружения и атрибуты, а затем откатывает изменения.
def test_export(tmp_path, monkeypatch):
monkeypatch.setenv("APP_MODE", "test")
target = tmp_path / "out.csv"
export_csv(target)
assert target.read_text(encoding="utf-8").startswith("id,")
Для вывода в консоль есть capsys и capfd: первый перехватывает Python-потоки, второй — файловые дескрипторы.
Моки без магии
Мок нужен на границе системы: сеть, время, файловая система, платёжный шлюз. Патчить внутреннюю логику бессмысленно — тест начнёт проверять сам мок. Важно указывать путь до места использования, а не до определения.
from unittest.mock import patch
def test_weather_fallback():
with patch("app.weather.requests.get", side_effect=TimeoutError):
assert get_forecast("Moscow") == "нет данных"
Плагин pytest-mock добавляет фикстуру mocker и автоматически снимает патчи.
Конфигурация проекта
Настройки можно хранить в pyproject.toml, pytest.ini или tox.ini. Для современного проекта удобнее первый вариант: один файл описывает зависимости, линтеры и тесты.
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
markers = ["slow: долгие проверки"]
filterwarnings = ["error"]
Флаг —strict-markers ловит опечатки в метках, а filterwarnings превращает предупреждения в ошибки.
Запуск, покрытие и CI
Полный прогон нужен редко. Во время работы удобнее точечные фильтры, а покрытие — как ориентир, а не как цель.
python -m pytest -q
python -m pytest -k "pricing and not slow"
python -m pytest -x --lf
python -m pytest --cov=app --cov-report=term-missing
python -m pytest -n auto # нужен пакет pytest-xdist
Ключ -x останавливается на первом падении, —lf запускает только упавшие в прошлый раз, -k фильтрует по имени. В CI добавьте —cov-fail-under=70, но не гонитесь за сотней процентов: непокрытыми обычно остаются ветки обработки ошибок, которые важнее красивой цифры.
Что ломается чаще всего
- Тесты зависят от порядка: общая база или файл не очищаются между проверками.
- Вместо фикстуры используется глобальная переменная, и состояние протекает между модулями.
- В тестах есть time.sleep: на медленной машине проверка падает, на быстрой проходит.
- Сеть не замокана, поэтому сборка зависит от доступности чужого API.
- Проверяется приватный метод вместо публичного поведения.
- Покрытие считают доказательством качества, хотя оно показывает лишь выполненные строки.
Итог
pytest даёт простой старт и достаточно возможностей для большого проекта. Начните с пары тестов на чистые функции, затем добавьте фикстуры для базы и временных каталогов, параметризацию для однотипных случаев и отдельную метку для медленных проверок. Моки оставьте только на внешних границах. Такой набор запускается за секунды, читается без объяснений и не мешает менять код.