БэкендDevOpsPython

pytest для начинающих: пишем первые тесты для Python-функции

Пишем функцию расчёта скидки, проверяем её через pytest, намеренно создаём ошибку и учимся читать diff, parametrization и исключения.

Кодик

Автор

6 мин чтения

pytest запускает обычные функции с именем test_ и показывает значения, из-за которых проверка не прошла. На функции скидки увидим зелёный запуск, красный diff, параметризацию и pytest.raises. Затем специально сломаем формулу, чтобы тест поймал ошибку до ручной проверки приложения.

Без теста новичок открывает программу, вводит несколько чисел и надеется, что не пропустил границу. Один pytest-файл превращает ожидания в повторяемые примеры. В этой статье вы соберёте набор тестов для функции final_price. Финальный результат можно проверить без догадок: команда pytest -q печатает 6 passed, а неверная формула указывает конкретный набор price и discount.

1Arrange

Готовим цену, скидку и ожидаемый ответ без лишней инфраструктуры.

2Act

Вызываем final_price так же, как её вызывает программа.

3Assert

Сравниваем фактический результат и отдельно проверяем неверные аргументы.

Что именно проверяет pytest

Логика лежит в price.py, а примеры поведения в test_price.py. pytest находит файл по префиксу test_, импортирует функцию и запускает каждую проверку отдельно. Общий результат зависит только от возвращаемого значения, поэтому тест не требует браузера или базы. Для pytest держите этапы раздельно: ввод, проверка, основное действие и вывод. Такой маршрут позволяет остановиться в конкретной точке, увидеть фактические данные и исправить одну причину вместо случайной перестановки строк.

ЭтапДействиеПроверяемый результат
Сборpytest ищет test_*.pyНайдены функции test_*
ПодготовкаПараметры создают несколько случаевУ каждого случая свои значения
ПроверкаОбычный assert сравнивает результатpytest строит читаемый diff
ОтчётСчитаются passed и failedКоманда возвращает код завершения

Один красный случай содержит вход, ожидание и фактическое значение, поэтому его можно повторить отдельно. У pytest каждый этап оставляет наблюдаемый след, поэтому следующий шаг начинается только после проверки предыдущего.

Цикл запуска pytest
От входных данных до видимого результата. Каждый сценарий получает собственный результат

Тестируйте правило, а не строки реализации. Проверка должна пережить переименование локальной переменной. Если тест знает внутренние шаги final_price, рефакторинг будет ломать его без изменения поведения.

Пишем функцию скидки и шесть тестов

Создайте проект uv init discount-tests, добавьте uv add --dev pytest и сохраните два файла из примера. Запуск uv run pytest -q использует окружение проекта. Флаг -q сокращает отчёт, а -vv показывает параметры каждого случая. Не называйте собственный файл pytest.py: он перекроет установленный пакет.

# price.py
def final_price(price: float, discount: int) -> float:
    if price < 0:
        raise ValueError("price must be non-negative")
    if not 0 <= discount <= 100:
        raise ValueError("discount must be from 0 to 100")
    return round(price * (1 - discount / 100), 2)


# test_price.py
import pytest

from price import final_price


@pytest.mark.parametrize(
    ("price", "discount", "expected"),
    [(100, 0, 100), (100, 25, 75), (99.90, 10, 89.91)],
)
def test_final_price(price, discount, expected):
    assert final_price(price, discount) == expected


def test_rejects_negative_price():
    with pytest.raises(ValueError, match="non-negative"):
        final_price(-1, 10)
Что должно произойти после запуска
  • Три набора parametrization запускаются как отдельные тесты.
  • Нулевая скидка оставляет цену без изменений.
  • Десять процентов от 99.90 дают 89.91.
  • Отрицательная цена поднимает ожидаемый ValueError.

Функция остаётся чистой: она не читает input и не печатает ответ. Поэтому тест подаёт аргументы напрямую и не зависит от терминала. Пример pytest специально ограничен одним пользовательским сценарием, чтобы назначение каждой строки оставалось видимым.

Ручная проверка и автоматический тест
Что отличает устойчивый проект от случайного запуска. Красный тест показывает вход и ожидание

Как читать падение assert и параметризацию

pytest переписывает выражение assert при импорте тестового модуля. Благодаря этому отчёт показывает не просто False, а сравниваемые значения. mark.parametrize создаёт отдельный вызов для каждой строки таблицы. Контекст pytest.raises считает тест успешным только при нужном классе исключения, а match дополнительно проверяет сообщение. Код завершения 0 означает, что набор прошёл, и именно его использует CI. Фикстуры здесь не нужны: сначала полезно научиться тестировать чистые функции без скрытой подготовки.

ЧастьЗа что отвечаетЧто проверить
ФункцияВозвращает результат без вводаОдинаковый вход даёт одинаковый ответ
test_ файлХранит примеры поведенияИмпорт указывает на ваш модуль
assertФиксирует ожидаемое значениеСравнение читается в отчёте
parametrizeРазмножает один сценарийКаждая граница отдельной строкой
raisesПроверяет отказ функцииКласс и текст исключения точны

Тест должен падать по правильной причине. Если вы ожидаете ValueError, но функция падает с TypeError раньше, слишком широкий raises(Exception) скроет дефект. Числа с плавающей точкой иногда требуют pytest.approx, однако денежный код лучше строить на Decimal. Не гонитесь за процентом покрытия: строка может выполняться и при этом не проверяться. Сильный тест называет наблюдаемое правило и содержит границу, например скидку 0, 100 и значение за диапазоном.

Сначала увидьте красный тест. Намеренно замените деление на 10. Убедитесь, что test_final_price падает, затем верните формулу. Тест, который никогда не был красным, может проверять не то выражение.

Ломаем формулу и усиливаем набор тестов

Добавьте случаи discount=100 и discount=101. Второй должен проверяться через pytest.raises. Затем поменяйте round(..., 2) на round(..., 0) и прочитайте diff. Создайте test_identity_without_discount, чтобы имя теста объясняло правило. После исправления запустите один тест по узлу pytest test_price.py::test_rejects_negative_price -q, а затем весь набор. В конце добавьте функцию calculate_cart_total и проверьте пустой список.

Проверка своими руками
  1. Получите зелёный запуск исходного набора.
  2. Сломайте деление и зафиксируйте красный diff.
  3. Добавьте границу скидки 100 процентов.
  4. Проверьте отказ для скидки 101.
  5. Запустите один тест по node id.
  6. Верните код и получите полный зелёный отчёт.
Готово, если выполняются все пункты
  • Все примеры проходят повторно.
  • Сломанная формула ловится.
  • Границы описаны явно.
  • Исключения проверяются точно.

Набор ценен не числом функций test_, а тем, что он ловит реальное изменение правила. После этой проверки измените одно правило pytest и заранее запишите ожидаемый результат. Так копирование превращается в понимание.

Четыре качества полезного набора
Четыре проверки перед следующим шагом. Границы важнее большого числа строк

Как не превратить тесты в вторую программу

Дальше вынесите тесты в каталог tests и запускайте их перед каждым push. Фикстуры добавляйте, когда одинаковая подготовка появляется минимум в нескольких тестах. Для внешнего API сначала подменяйте сеть, иначе падение интернета будет выглядеть как дефект функции. Медленные интеграционные сценарии держите отдельно от быстрых unit-тестов. И не исправляйте тест только потому, что он красный: сначала решите, изменилось требование или код.

Как изучить тему в Кодике

Закрепите функции, возврат значений и исключения Python, затем превратите каждый пример в отдельное правило. В Кодике сначала восстановите чистую логику без фреймворка, затем верните библиотеку и интерфейс pytest.

ШагЧто разобратьМини-проверка
1Чистые функцииУбрать input и print из расчёта
2assertСравнить два значения
3Списки случаевВыделить три границы
4ИсключенияПоймать ValueError
5Автоматический запускСвязать тесты с push

Сначала проверяйте маленькую функцию за миллисекунды, только потом подключайте файлы, сеть и UI. Финальная проверка для pytest: закрыть статью, объяснить путь данных и добавить одну функцию без копирования готового блока.

Файл назван pytest.py

Переименуйте его, иначе import pytest загрузит локальный файл.

Один тест содержит десять правил

Разделите сценарии, чтобы падение указывало на конкретное поведение.

Используется raises(Exception)

Проверяйте точный класс и при необходимости match сообщения.

Тест повторяет формулу

Сравнивайте с известным ответом, а не вычисляйте expected тем же алгоритмом.

Сверяйтесь с первичной документацией. Команды и ограничения примера проверены по официальной документации pytest. Если версия инструмента изменилась, официальный источник важнее старого скриншота.

Что получится в итоге

Готовый результат проекта: pytest для начинающих: пишем первые тесты для Python-функции
Один запуск показывает шесть независимых примеров, а намеренная ошибка формулы оставляет точный diff с входными параметрами.

Короткие ответы
Как pytest находит тесты?

pytest ищет файлы и функции с именами по шаблону test_ и запускает их независимо.

Зачем нужен pytest.mark.parametrize?

parametrize прогоняет одно правило на нескольких наборах данных и подписывает каждый случай.

Как проверить исключение в pytest?

Используйте pytest.raises с точным классом исключения и при необходимости параметром match.

Как понять, что учебный проект pytest действительно работает?

Проверьте основной сценарий pytest, неверный ввод, повторный запуск и один граничный случай. Затем объясните путь данных своими словами.

Можно ли начать с готового кода pytest из статьи?

Да. Сначала добейтесь результата pytest, затем измените одно правило и восстановите ключевой файл без копирования.

Запустите тесты автоматически после push

Функции и исключения закрепите в курсе Python. Затем добавьте этот набор в GitHub Actions для Python.

Для браузерного уровня посмотрите сценарий Playwright, а для API сравните тестирование FastAPI.