Python: от данных до своей сводки Урок 53 из 56
Тесты pytest: проверяем обещания конвейера
Пятьдесят второй урок курса по Python. Превращаем правила проекта в автоматические тесты: отделяем подготовку данных от файлов, проверяем обычный случай, границы и ожидаемые ошибки, а затем запускаем весь набор одной командой.
Зачем это нужно
Ручной запуск показывает, что программа сработала один раз. Тест сохраняет обещание проекта и повторяет проверку после каждого изменения. Например: неизвестный источник должен отклоняться, причинное утверждение — ждать редактора, а пустой отчёт — не перезаписывать предыдущий.
Тест не доказывает отсутствие всех ошибок. Он подтверждает только явно записанный пример. Поэтому хороший набор проверяет не количество строк кода, а важные границы поведения.
Сразу целиком
Создайте рядом два файла:
# policy.py
def route(claim_type, evidence_ids, known_ids, formula_id=None):
if claim_type not in {"observation", "calculation", "cause"}:
return "reject"
if not set(evidence_ids).issubset(known_ids):
return "reject"
if claim_type == "cause":
return "review"
if claim_type == "calculation" and not formula_id:
return "review"
return "accept"
# test_policy.py
import pytest
from policy import route
@pytest.mark.parametrize(
("claim_type", "evidence", "formula_id", "expected"),
[
("observation", ["cpi-2025"], None, "accept"),
("cause", ["cpi-2025"], None, "review"),
("calculation", ["cpi-2025"], None, "review"),
("calculation", ["cpi-2025"], "change-v1", "accept"),
("guess", ["cpi-2025"], None, "reject"),
("observation", ["missing"], None, "reject"),
],
)
def test_route(claim_type, evidence, formula_id, expected):
assert route(claim_type, evidence, {"cpi-2025"}, formula_id) == expected
Установите средство разработки и запустите проверку:
python -m pip install pytest
python -m pytest -q
Результат 6 passed означает, что шесть примеров прошли. Запуск через python -m pytest использует тот же интерпретатор, который вы указали командой python.
Устройство теста
Имя файла test_policy.py и функции test_route позволяет pytest найти тест автоматически. Внутри соблюдается схема Arrange–Act–Assert:
- Arrange — подготовьте входные данные;
- Act — вызовите одну проверяемую операцию;
- Assert — сравните результат с обещанием.
Не помещайте несколько независимых причин отказа в один тест: при падении будет непонятно, какое правило сломалось.
Таблица случаев вместо копирования
@pytest.mark.parametrize запускает одну функцию с разными строками таблицы. Каждая строка видна как отдельный случай. Добавляя правило, сначала добавьте пример, который должен упасть, затем измените рабочий код и снова запустите тесты. Это короткий цикл «красный → зелёный → улучшение».
Не превращайте параметризацию в огромную таблицу всех возможных сочетаний. Отбирайте классы поведения: нормальный случай, пустое значение, границу, неверный тип и ранее найденную ошибку.
Ожидаемая ошибка
Ошибка иногда является правильным результатом:
import pytest
def percent(value):
if not 0 <= value <= 100:
raise ValueError("процент вне диапазона")
return value
def test_percent_rejects_101():
with pytest.raises(ValueError, match="вне диапазона"):
percent(101)
Если исключение не возникнет или будет другого типа, тест упадёт. Не используйте широкое pytest.raises(Exception): оно способно принять постороннюю поломку за правильное поведение.
Файлы без мусора
Встроенная фикстура tmp_path даёт каждому тесту отдельную временную папку:
def test_write_report(tmp_path):
report = tmp_path / "report.txt"
report.write_text("готово\n", encoding="utf-8")
assert report.read_text(encoding="utf-8") == "готово\n"
Тест не зависит от текущей папки, не портит настоящий отчёт и удаляет временные данные после прогона. Сеть, текущее время и внешний API в модульном тесте лучше заменить переданными данными или функцией-заглушкой. Отдельные интеграционные тесты могут проверять настоящую границу системы, но они медленнее и менее стабильны.
Проверяйте поведение, а не устройство
Хрупкий тест знает, какие внутренние функции вызывались и в каком порядке. Полезный тест проверяет наблюдаемый результат: решение, файл, строку отчёта или исключение. Тогда внутренний код можно улучшить, не переписывая тесты при сохранении поведения.
Один найденный баг должен получить регрессионный тест: сначала воспроизведите ошибку тестом, убедитесь, что он падает на старом коде, исправьте причину и сохраните тест навсегда.
Карта урока
Опорный сигнал: обещание → обычный случай + граница + ошибка → один запуск → понятный результат.
Скажите своими словами
- Почему один успешный ручной запуск не заменяет тест?
- Что даёт
@pytest.mark.parametrize? - Зачем использовать
tmp_path? - Почему
pytest.raises(Exception)слишком широк?
Разминка
1. Предскажите. Сколько тестовых случаев выполнится?
import pytest
values = [0, 50, 100]
@pytest.mark.parametrize("value", values)
def test_range(value):
assert 0 <= value <= 100
print(len(values))
2. Заполните пропуск.
import pytest
with pytest.___(ValueError):
percent(101)
3. Почините. Тест пишет в настоящий отчёт:
def test_report():
path = Path("data/report.txt")
path.write_text("test", encoding="utf-8")
Задание
Обязательное. Создайте policy.py и test_policy.py. Проверьте шесть случаев из первого примера, а также отдельным тестом подтвердите, что функция проверки процента отклоняет -1 и 101 через ValueError. Запустите python -m pytest -q.
8 passed
На своих данных. Выберите три обещания своего конвейера. Для каждого запишите обычный случай, границу и ошибочный вход.
По желанию. Добавьте тест записи HTML через tmp_path и убедитесь, что пользовательский текст экранируется.
Куда это встанет в проекте
Шаг 27 проекта получает набор регрессионных тестов для правил модели и страницы. Теперь изменение можно проверить одной командой. Следующий урок добавит аннотации типов и mypy, чтобы часть несоответствий ловилась ещё до выполнения тестов.
Ответы
Показать ответы
- Ручной запуск не сохраняет вход, ожидание и повторяемую процедуру проверки.
- Он запускает одно правило на нескольких явно перечисленных случаях.
- Чтобы изолировать файлы теста и не затронуть данные проекта.
- Любое постороннее исключение будет ошибочно принято за ожидаемое.
3
- Метод —
raises.
import pytest
def percent(value):
if not 0 <= value <= 100:
raise ValueError("процент вне диапазона")
with pytest.raises(ValueError):
percent(101)
print("ValueError")
ValueError
- Передайте
tmp_pathтесту и создайте файл внутри него.
from pathlib import Path
def test_report(tmp_path):
path = tmp_path / "report.txt"
path.write_text("test", encoding="utf-8")
assert path.read_text(encoding="utf-8") == "test"
Источники
- pytest: Get Started — поиск и запуск тестов.
- pytest: parametrization — таблицы тестовых случаев.
- pytest: temporary directories — изолированные временные файлы.
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.