Shanraq.org Shanraq.org
Тесты pytest: проверяем обещания конвейера
IT

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:

  1. Arrange — подготовьте входные данные;
  2. Act — вызовите одну проверяемую операцию;
  3. 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 в модульном тесте лучше заменить переданными данными или функцией-заглушкой. Отдельные интеграционные тесты могут проверять настоящую границу системы, но они медленнее и менее стабильны.

Проверяйте поведение, а не устройство

Хрупкий тест знает, какие внутренние функции вызывались и в каком порядке. Полезный тест проверяет наблюдаемый результат: решение, файл, строку отчёта или исключение. Тогда внутренний код можно улучшить, не переписывая тесты при сохранении поведения.

Один найденный баг должен получить регрессионный тест: сначала воспроизведите ошибку тестом, убедитесь, что он падает на старом коде, исправьте причину и сохраните тест навсегда.

Карта урока

Карта урока: обещание, примеры и результат

Опорный сигнал: обещание → обычный случай + граница + ошибка → один запуск → понятный результат.

Скажите своими словами

  1. Почему один успешный ручной запуск не заменяет тест?
  2. Что даёт @pytest.mark.parametrize?
  3. Зачем использовать tmp_path?
  4. Почему 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, чтобы часть несоответствий ловилась ещё до выполнения тестов.

Ответы

Показать ответы
  1. Ручной запуск не сохраняет вход, ожидание и повторяемую процедуру проверки.
  2. Он запускает одно правило на нескольких явно перечисленных случаях.
  3. Чтобы изолировать файлы теста и не затронуть данные проекта.
  4. Любое постороннее исключение будет ошибочно принято за ожидаемое.
3
  1. Метод — raises.
import pytest

def percent(value):
    if not 0 <= value <= 100:
        raise ValueError("процент вне диапазона")

with pytest.raises(ValueError):
    percent(101)
print("ValueError")
ValueError
  1. Передайте 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"

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.