Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Property-based тестирование: Hypothesis на практике

Property-based тестирование: проверяем свойства, а не примеры

Hypothesis в Python: стратегии генерации, shrinking, инварианты и состояния, настройка бюджета примеров и место среди обычных тестов.

Прямой ответ

Property-based тестирование (PBT) проверяет не конкретные пары «вход — ожидаемый выход», а свойства, которые должны выполняться для всех допустимых входов. Вместо того чтобы писать «для входа 7 результат равен 12», разработчик формулирует инвариант: «функция идемпотентна», «сортировка сохраняет длину списка», «результат всегда валиден по схеме».

Библиотека Hypothesis в Python генерирует сотни и тысячи входных значений по заданным правилам, подставляет их в тест и ищет контрпример. Если находит — сжимает его до минимального воспроизводимого случая и сохраняет в базе примеров, чтобы в следующих прогонах начинать именно с него.

Ключевые понятия:

  • Стратегия — объект, описывающий, как генерировать значения: st.integers(), st.text(), st.lists(st.floats()), st.builds(...).
  • Инвариант (свойство) — утверждение, истинное для всех сгенерированных значений.
  • Shrinking — автоматическое уменьшение найденного контрпримера: список из 47 элементов превращается в список из одного-двух, сохраняя ошибку.
  • База примеров — каталог .hypothesis, где хранятся найденные падения; его стоит добавлять в .gitignore, но не удалять между прогонами.
from hypothesis import given, strategies as st, settings, example

@given(st.lists(st.integers()))
@example([])                      # явный граничный случай
def test_sort_preserves_multiset(xs):
    result = sorted(xs)
    assert len(result) == len(xs)
    assert sorted(result) == sorted(xs)          # мультимножество сохранено
    assert all(a <= b for a, b in zip(result, result[1:]))  # упорядоченность

Типичные свойства, которые легко формулировать:

  1. Обратимость. decode(encode(x)) == x.
  2. Идемпотентность. f(f(x)) == f(x).
  3. Инвариант операции. Сумма элементов не меняется при перестановке.
  4. Соответствие эталону. Своя реализация совпадает с заведомо правильной на всех входах.
  5. Валидность результата. Результат проходит проверку схемы независимо от входа.
  6. Монотонность. Увеличение входа не уменьшает выход.

Практика: как применять без разочарований

1. Начинайте с обратимости и эталонов. Это самые дешёвые свойства: не нужно придумывать сложный инвариант, достаточно пары функций.

2. Сравнивайте с эталонной реализацией. Если есть медленная, но очевидная реализация, проверяйте быструю против неё. Такой тест находит расхождения на входах, которые человек не предусмотрел бы.

@given(st.lists(st.integers(min_value=-10**6, max_value=10**6), max_size=200))
def test_fast_median_matches_reference(xs):
    assert fast_median(xs) == reference_median(xs)

3. Управляйте бюджетом. По умолчанию Hypothesis выполняет 100 примеров на тест. Для медленных тестов это много; для критичных — мало.

@settings(max_examples=500, deadline=None)   # deadline=None для тестов с вводом-выводом
@given(st.text())
def test_slug_is_url_safe(s):
    slug = make_slug(s)
    assert re.fullmatch(r"[a-z0-9\-]+", slug)

4. Состояние и последовательности операций. Для протоколов и конечных автоматов Hypothesis предлагает stateful-тестирование: генерируется не одно значение, а последовательность команд, и после каждой проверяются инварианты. Это самый мощный, но и самый сложный режим.

5. Не заменяйте обычные тесты. Примерные тесты читаются как документация: «вот вход, вот ожидаемый результат». Свойства дополняют их, покрывая пространство входов. Практика — оба подхода в одном файле, см. фикстуры и параметризацию pytest.

Рекомендации:

  1. Формулируйте свойство до написания кода. Если свойство сформулировать трудно, возможно, интерфейс функции неудобен.
  2. Не проверяйте реализацию саму собой. Свойство f(x) == f(x) ничего не даёт; нужна независимая проверка.
  3. Следите за временем прогона. Тысячи примеров в unit-тестах замедляют обратную связь. Основной бюджет стоит переносить в ночной прогон.
  4. Используйте сжатие как инструмент отладки. Минимальный контрпример часто сразу показывает причину: пустая строка, отрицательное число, символ вне BMP.
  5. Отличайте PBT от фаззинга. PBT проверяет сформулированные свойства на структурированных входах; фаззинг ищет краши и нарушения безопасности на произвольных байтах — см. фаззинг в Python.

Практический сценарий: PBT нашёл ошибку в парсере диапазонов дат

Контекст: функция разбирала строку вида 2026-01-01..2026-03-15 в пару дат. Обычные тесты на десяти примерах проходили.

  1. Свойство. Сформулировали три: начало диапазона не позже конца; повторная сериализация даёт ту же строку; результат всегда валиден по схеме.
  2. Запуск. Hypothesis нашёл падение на 63-м примере и сжал его до 2026-02-29..2026-03-01.
  3. Причина. Валидация использовала фиксированный список дней в месяце без учёта високосного года, поэтому 29 февраля невисокосного года принималось, а дальше падало при построении даты.
  4. Дополнительно. Второй прогон с большим бюджетом нашёл падение на строке с пробелом вокруг разделителя: парсер допускал его на входе, но сериализатор — нет, из-за чего нарушалась обратимость.
  5. Исправление. Перешли на стандартный конструктор дат, нормализацию пробелов добавили в парсер; найденные контрпримеры зафиксировали как обычные тесты с явными входами.

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

FAQ

Чем PBT отличается от параметризованного теста? Параметризованный тест перебирает фиксированный список входов, который придумал человек. PBT генерирует входы по правилам и ищет те, на которых свойство нарушается, а затем уменьшает найденный случай до минимума.

Что делать, если тест нестабилен между прогонами? Hypothesis детерминирована при фиксированном seed и повторяет найденные ранее падения из базы примеров. Нестабильность обычно означает, что проверяемое свойство действительно не выполняется для части входов, либо тест зависит от внешнего состояния.

Подходит ли PBT для кода с вводом-выводом? Да, но с оговорками: нужно отключить ограничение времени (deadline=None) и генерировать не запросы к внешней системе, а данные, которые в неё пойдут. Саму внешнюю систему подменяют двойником — см. тестовые двойники.

Как понять, что свойств достаточно? Прямого критерия нет. Косвенный — мутационное тестирование: если внесённые в код мутации не обнаруживаются тестами, свойств не хватает. Методика — в статье про мутационное тестирование.

Источники: Hypothesis docs, pytest docs, Wikipedia (QuickCheck) — ссылки в шапке.