Урок курса

Первые тесты на 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. Никаких других файлов или переменных заранее не создаётся.

В редакторе есть каркас с тремя незавершёнными тест-функциями и уже готовым импортом.

Что нужно сделать

Допиши три тест-функции в редакторе:

  1. test_safe_divide_normal — нормальный сценарий: проверь, что safe_divide(10, 4) возвращает 2.5.
  2. test_safe_divide_zero_dividend — граничный случай: проверь, что safe_divide(0, 5) возвращает 0.0.
  3. 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
деление на ноль
РешениеPython

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задачи к уроку

Все задачи курса →

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку