Юнит-тесты в 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 даёт простой старт и достаточно возможностей для большого проекта. Начните с пары тестов на чистые функции, затем добавьте фикстуры для базы и временных каталогов, параметризацию для однотипных случаев и отдельную метку для медленных проверок. Моки оставьте только на внешних границах. Такой набор запускается за секунды, читается без объяснений и не мешает менять код.

Насколько публикация полезна?

Нажмите на звезду, чтобы оценить!

Средняя оценка 0 / 5. Количество оценок: 0

Оценок пока нет. Поставьте оценку первым.

Поделиться: Telegram ВКонтакте