Урок курса
Первые тесты на pytest: нормальные сценарии, граничные случаи и исключения
Python для продвинутых: ООП, типизация и тестированиеНачнём с самого маленького куска pytest: одна функция, один assert, один понятный результат. Пока не выбираем набор сценариев и не трогаем исключения — сначала важно увидеть, что pytest вообще считает тестом.
Минимальная тест-функция pytest и обычный assert
Тест в pytest — это обычная функция Python. Главное, чтобы pytest смог её найти: файл обычно называют в стиле test_prices.py, а функцию — с префиксом test_.
Например, есть простая функция расчёта итоговой цены:
# prices.py
def apply_discount(price: int, percent: int) -> int:
return price - price * percent // 100
Минимальный тест для неё выглядит без специального класса, без наследования и без методов вроде assertEqual:
# test_prices.py
from prices import apply_discount
def test_apply_discount_reduces_price() -> None:
assert apply_discount(1000, 15) == 850
pytest проходит по тестовым файлам, находит функции test_* и запускает их. Если функция закончилась без исключений, тест считается прошедшим. Если внутри упал assert, это обычный AssertionError, и тест считается упавшим.
Важная удобная деталь pytest — он умеет показывать не только факт падения, но и выражение, которое не сошлось. Если в тесте случайно ожидать неверное значение:
def test_apply_discount_reduces_price() -> None:
assert apply_discount(1000, 15) == 800
pytest покажет строку с assert и фактическое значение. По смыслу это ответ на вопрос: «что реально вернула функция и с чем мы это сравнивали?». Поэтому в pytest обычно пишут простой встроенный assert, а не заводят отдельный набор специальных проверочных методов.
Есть граница, о которой лучше помнить сразу. Подробность вывода зависит не только от самого assert, но и от того, где он находится и насколько прозрачно названа проверка. Если вспомогательная функция лежит прямо в тестовом модуле, pytest обычно всё ещё может показать полезную расшифровку упавшего assert. Но если проверку вынесли в обычный модуль приложения или спрятали несколько разных ожиданий за одним слишком общим helper, диагностика становится менее полезной: по вызову в тесте видно, что сломалась вспомогательная проверка, но смысл конкретного ожидания приходится искать глубже.
# хуже читается: название helper не говорит, какое ожидание проверяется
def assert_discount_is_correct() -> None:
assert apply_discount(1000, 15) == 850
def test_apply_discount_reduces_price() -> None:
assert_discount_is_correct()
Для первых тестов лучше держать главное ожидание прямо в test_*-функции:
# проще диагностировать
def test_apply_discount_reduces_price() -> None:
assert apply_discount(1000, 15) == 850
Если helper всё-таки нужен, пусть его имя описывает предмет проверки, а для неочевидного сравнения можно добавить сообщение вторым аргументом assert:
def test_apply_discount_reduces_price() -> None:
actual = apply_discount(1000, 15)
assert actual == 850, "скидка 15% от 1000 должна дать цену 850"
Так тест остаётся коротким, а причина падения находится рядом с названием проверяемого поведения. На этом уровне нам достаточно запомнить три вещи: файл должен быть похож на тестовый, функция должна начинаться с test_, а проверка результата пишется обычным assert.
Нормальные сценарии и граничные случаи
Когда синтаксис теста уже понятен, следующий вопрос важнее: какие входные данные брать для проверки.
Нормальный сценарий — это спокойный рабочий пример. Такой, который похож на реальное использование функции и не пытается сломать её специально. Для скидки это обычная цена и обычный процент:
from prices import apply_discount
def test_apply_discount_regular_percent() -> None:
assert apply_discount(2000, 10) == 1800
Такой тест отвечает на вопрос: «функция вообще делает основную работу?» Но одного нормального сценария мало. Ошибки часто живут не в середине диапазона, а на краях.
Граничный случай — это значение около перехода в другое поведение. Для чисел это часто ноль, единица, максимум, минимум. Для коллекций — пустой список и список из одного элемента. Для строк — пустая строка, строка из одного символа, строка с пробелами.
В функции скидки края видны сразу: скидка 0% должна оставить цену как есть, а скидка 100% должна обнулить цену.
from prices import apply_discount
def test_apply_discount_zero_percent_keeps_price() -> None:
assert apply_discount(2000, 0) == 2000
def test_apply_discount_full_percent_makes_price_zero() -> None:
assert apply_discount(2000, 100) == 0
Эти тесты не проверяют «ещё два случайных числа». Они фиксируют важные точки поведения. Если в коде появится ошибка вроде неправильного знака, неверного округления или лишнего условия, крайние значения часто покажут это быстрее, чем ещё один пример с 10% или 15%.
Хороший первый набор обычно состоит из одного типичного случая и нескольких краёв. Не нужно сразу покрывать все возможные числа. Достаточно выбрать значения, про которые есть отдельный смысловой вопрос: что происходит при нуле, при одном элементе, на максимуме, на пустом вводе.
Проверка ожидаемых исключений через pytest.raises
Иногда правильное поведение функции — не вернуть значение, а отказаться выполнять операцию. Например, банковский счёт не должен уходить в минус, если бизнес-правило это запрещает.
Для таких случаев в pytest используют pytest.raises. Это контекстный менеджер: код, который должен упасть, помещают внутрь блока with, а рядом указывают ожидаемый тип исключения.
# account.py
class InsufficientFundsError(Exception):
pass
class Account:
def __init__(self, balance: int) -> None:
self.balance = balance
def withdraw(self, amount: int) -> None:
if amount > self.balance:
raise InsufficientFundsError("недостаточно средств")
self.balance -= amount
Тест фиксирует не итоговый баланс, а сам факт отказа:
# test_account.py
import pytest
from account import Account, InsufficientFundsError
def test_withdraw_more_than_balance_raises_error() -> None:
account = Account(balance=500)
with pytest.raises(InsufficientFundsError):
account.withdraw(1000)
Смысл проверки такой: если withdraw(1000) поднял InsufficientFundsError, тест проходит. Если исключения не было, тест падает, потому что ожидался отказ. Если поднялось исключение другого типа, pytest его не считает подходящим и тоже роняет тест.
Иногда одного типа мало. Сообщение исключения тоже может быть частью поведения: по нему логируют ошибку, показывают текст пользователю или отличают одну причину отказа от другой. Тогда результат pytest.raises сохраняют в переменную через as excinfo.
def test_withdraw_more_than_balance_explains_reason() -> None:
account = Account(balance=500)
with pytest.raises(InsufficientFundsError) as excinfo:
account.withdraw(1000)
assert "недостаточно" in str(excinfo.value)
excinfo.value — это сам объект исключения. Его можно привести к строке и проверить важную часть сообщения. Обычно лучше не сравнивать весь текст целиком, если формулировка может чуть измениться. Проверка ключевого фрагмента устойчивее.
Важная граница: pytest.raises не ловит «любую ошибку примерно рядом». Тип должен совпасть с тем, что вы действительно ожидаете.
def test_wrong_expected_exception_type() -> None:
account = Account(balance=500)
with pytest.raises(ValueError):
account.withdraw(1000)
Этот тест не пройдёт, потому что код поднимает InsufficientFundsError, а тест ждёт ValueError. Это полезно: тест не маскирует неожиданные поломки и заставляет явно назвать контракт функции. Если функция должна сообщать о нехватке денег своим исключением предметной области, тест должен ждать именно его.
Запуск pytest и чтение результата PASSED/FAILED
После того как тесты лежат в проекте, их нужно запускать не по одному вручную, а через pytest. Обычно команду выполняют из корня проекта — там, где видны и код приложения, и файлы тестов.
pytest
Если хочется видеть каждый тест отдельной строкой, удобнее включить подробный режим:
pytest -v
В обычной работе -v особенно полезен на первых шагах: сразу видно имя файла, имя тестовой функции и статус рядом с ней.
Для примера возьмём уже знакомые тесты скидки. Представим, что два теста написаны правильно, а в тесте полной скидки при редактировании случайно ошиблись в ожидании: написали == 1 вместо правильного == 0. Тогда короткий вывод в подробном режиме может выглядеть так:
test_prices.py::test_apply_discount_regular_percent PASSED [ 33%]
test_prices.py::test_apply_discount_zero_percent_keeps_price PASSED [ 66%]
test_prices.py::test_apply_discount_full_percent_makes_price_zero FAILED [100%]
PASSED означает, что тестовая функция завершилась без исключений. FAILED означает, что внутри теста произошло исключение: чаще всего это AssertionError, но может быть и любая другая ошибка.
Самая полезная часть при падении находится ниже, в блоке с подробностями. Там pytest показывает, какой тест упал и на какой строке проверка не сошлась.
================================ FAILURES =================================
____________ test_apply_discount_full_percent_makes_price_zero ____________
def test_apply_discount_full_percent_makes_price_zero() -> None:
> assert apply_discount(2000, 100) == 1
E assert 0 == 1
E + where 0 = apply_discount(2000, 100)
test_prices.py:11: AssertionError
Читать такой фрагмент лучше сверху вниз.
Имя после FAILURES говорит, какая тестовая функция упала.
Строка с символом > показывает конкретный assert, на котором выполнение остановилось.
Строки, начинающиеся с E, показывают расшифровку ошибки: что получилось фактически и с чем это сравнивали.
В этом примере функция вернула 0, а тест ожидал 1. Значит, сначала нужно решить не «pytest сломался», а что неверно: код функции или ожидание в тесте. Для случая полной скидки логичнее ожидать ноль, поэтому здесь ошибка именно в тестовом ожидании.
В конце pytest печатает итоговую сводку:
========================= short test summary info ========================= FAILED test_prices.py::test_apply_discount_full_percent_makes_price_zero ======================= 1 failed, 2 passed in 0.04s =======================
Эта строка нужна для быстрой ориентации. Если видите 3 passed, набор зелёный. Если видите 1 failed, 2 passed, не нужно прокручивать весь вывод наугад: в short test summary уже указано точное имя упавшего теста.
Для диагностики обычно хватает трёх мест в отчёте: статус PASSED или FAILED рядом с тестом, строка с assert в блоке падения и финальная сводка с количеством прошедших и упавших тестов. Это превращает запуск тестов в быстрый цикл: запустили, нашли упавшую проверку, посмотрели фактическое значение, поправили код или ожидание.
Попробуйте сами. Напишите тесты для safe_divide: обычное деление и исключение при нулевом знаменателе. Написать тесты с проверкой — в бесплатном курсе, с проверкой решения.
Следующая самостоятельная тема — управление ресурсами: попробуйте написать свой контекстный менеджер в открытом уроке платного курса Python для профессионалов. Посмотреть продолжение.
Задача 1
Разработчик написал файл tests/test_math.py:
def check_sum():
assert 2 + 2 == 4
def test_multiplication():
assert 3 * 3 == 9
Он запускает pytest tests/test_math.py. Какую из двух функций pytest выполнит как тест?
Задача 2
Студент написал тест и запустил pytest. Функция divide(5, 2) возвращает 2.5, но тест был написан с ошибкой в ожидаемом значении:
def test_divide_integers() -> None:
assert divide(5, 2) == 2
Pytest показал строку:
AssertionError: assert 2.5 == 2
Что означает 2.5 в этой строке вывода?
Задача 3
Разработчик запустил pytest -v и увидел:
tests/test_stack.py::test_push_one PASSED tests/test_stack.py::test_push_many PASSED tests/test_stack.py::test_pop_empty FAILED FAILED tests/test_stack.py::test_pop_empty
В секции FAILURES показано:
def test_pop_empty():
s = Stack()
> assert s.pop() is None
E AttributeError: 'Stack' object has no attribute 'pop'
Что является причиной падения теста?
Задача 4
Дан тест:
import pytest
def parse_int(s: str) -> int:
return int(s)
def test_parse_invalid():
with pytest.raises(ValueError):
parse_int('abc')
Что произойдёт, если parse_int('abc') неожиданно поднимает TypeError вместо ValueError?
Задача 5
Разработчик пишет тесты для функции clamp(value, lo, hi). Он упаковал все проверки в одну тест-функцию:
def test_clamp():
assert clamp(5, 0, 10) == 5
assert clamp(-1, 0, 10) == 0
assert clamp(11, 0, 10) == 10
assert clamp(0, 0, 10) == 0
assert clamp(10, 0, 10) == 10
Коллега предлагает разбить это на несколько тест-функций. Какой аргумент наиболее точно описывает практическую проблему одной большой тест-функции?
Задача 6
Напиши тесты для функции safe_divide
Ты пишешь набор тестов для функции safe_divide из модуля mathutils.
Что уже дано
Проверяющая система скрыто создаёт модуль mathutils.py со следующей функцией:
def safe_divide(a: float, b: float) -> float:
"""Делит a на b. Если b == 0, поднимает ValueError с сообщением 'деление на ноль'."""
Этот модуль уже доступен для импорта по имени mathutils. Никаких других файлов или переменных заранее не создаётся.
В редакторе есть каркас с тремя незавершёнными тест-функциями и уже готовым импортом.
Что нужно сделать
Допиши три тест-функции в редакторе:
test_safe_divide_normal— нормальный сценарий: проверь, чтоsafe_divide(10, 4)возвращает2.5.test_safe_divide_zero_dividend— граничный случай: проверь, чтоsafe_divide(0, 5)возвращает0.0.test_safe_divide_by_zero_raises— проверь черезpytest.raises(ValueError), чтоsafe_divide(7, 0)поднимаетValueError, и убедись, что сообщение исключения содержит подстроку'деление на ноль'.
Каждая тест-функция должна содержать хотя бы один assert (или блок pytest.raises) непосредственно в своём теле.
Ввод и вывод
stdin не используется. Печатать через print в stdout ничего не нужно.
Проверяющая система после выполнения твоего кода сама запускает тест-функции, используя их по имени из глобального пространства имён.
Пример использования
print(safe_divide(10, 4))
try:
safe_divide(7, 0)
except ValueError as error:
print(error)
Результат:
2.5 деление на ноль
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Задачи к уроку
- Первые тесты на pytest: нормальные сценарии, граничные случаи и исключения — задача по Python
Какой аргумент наиболее точно описывает практическую проблему одной большой тест-функции? Проверьте ответ в задаче из бесплатного курса Python.
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
