Обычный юнит-тест проверяет пример, который автор придумал сам: такие-то входы, такой-то ответ. Человеческая фантазия на граничных случаях небезгранична, и в глубоких углах кода годами живут баги, до которых никто не додумался. Property-based тестирование работает иначе: разработчик описывает свойство, обязанное выполняться для целого класса входов, а фреймворк сам генерирует сотни входов и ищет тот, что ломает обещание. Статья показывает этот подход на живых сценариях, где он приносит находки, и разбирает случаи, когда привычные юнит-тесты остаются сильнее.

Свойства вместо примеров, идея из мира Haskell

Идея стара как мир автоматического тестирования качественных языков. В 2000 году Клас Класессен и Джон Хьюз опубликовали инструмент QuickCheck для Haskell, где тест описывается как универсальное утверждение о функции, а среда поиска сама подбирает аргументы. Для Python эту модель реализовала библиотека Hypothesis, первый релиз которой вышел в 2014 году, сейчас она живёт в шестой мажорной версии и ставится одной командой pip install hypothesis. Родственные инструменты есть почти во всех стеках: fast-check для JavaScript и TypeScript, jqwik для Java, FsCheck для .NET, RapidCheck для C++.

Смена образа мышления здесь главная. Юнит-тест говорит: на входе 42 жди 1764. Свойство говорит: для любого целого числа квадрат не бывает отрицательным, и обратное преобразование возвращает исходное значение. Вторая формулировка покрывает бесконечное множество случаев, которые никто не стал бы записывать руками. Пустые списки, дубликаты, отрицательные суммы, эмодзи в строках, нестрочные ключи, значения на границах типов, всё это генератор прокрутит вместо вас. Опытные разработчики после первого знакомства обычно признаются, что их личные списки граничных случаев всегда были короче, чем им казалось.

Особенно зримо разница видна на валидаторах. Возьмите проверку телефонного номера или адреса почты и спросите себя, какие строки вы подложите в ручной тест: пустую, с пробелами, с лишним плюсом, с кириллицей. Стратегия st.text() зайдёт дальше и принесёт буквы из разных письменностей, невидимые символы, комбинации диакритики, шестикилометровые строки. Каждый такой случай это вопрос к валидатору, и далеко не всегда ответ вас обрадует. Баги парсинга и нормализации строк почти всегда живут ровно там, куда человек не стал бы писать примеры добровольно.

Первый тест на свойства для сортировки списка

Классический вход в методику выглядит так:

from hypothesis import given, strategies as st

@given(st.lists(st.integers()))
def test_sorted_keeps_contract(xs):
    ys = sorted(xs)
    assert len(ys) == len(xs)
    for a, b in zip(ys, ys[1:]):
        assert a <= b

Стратегия st.lists(st.integers()) описывает класс входов: любые списки любых целых чисел, включая пустые и с повторами. По умолчанию Hypothesis прогоняет тест на ста случайных входах, число управляется настройкой max_examples. Если контрпример найден, библиотека печатает его в отчёте pytest как готовый набор аргументов.

Польза такого теста не в проверке стандартной сортировки, а в проверке вашей собственной. Напишите аналог сортировки с одной ошибкой в сравнении, и свойство монотонности зацепит его за считанные секунды. Показательный случай приносят числа с плавающей точкой: свойство a <= b для соседей нарушается, когда в списке появляется NaN, потому что любое сравнение с NaN ложно. Человек почти никогда не кладёт NaN в ручной пример, а генератор случайных float находит его как тренировку на прочность. То же свойство молча страхует рефакторинги: заменили алгоритм, а контракт длины и порядка остался проверенным на сотне конфигураций входа.

Hypothesis-тест остаётся обычной функцией, и pytest подхватывает её как родную: работает параметризация, фикстуры, маркировки. Когда входы зависят друг от друга, приходит черёд композитных стратегий:

@st.composite
def ordered_pair(draw):
    low = draw(st.integers())
    high = draw(st.integers(min_value=low))
    return (low, high)

Так описываются диапазоны, интервалы и вообще любые связанные структуры, где второй элемент обязан согласовываться с первым.

Круговая конвертация как источник тихих багов

Самый урожайный шаблон свойств звучит так: обратная операция обязана восстанавливать исходное значение. Кодирование и декодирование, сериализация и разбор, сжатие и распаковка, смена типов и обратно. Пара операций f и её обращение должны давать тождество, и это требование легко записать.

Живой пример из финансовой практики. Цены хранятся в Decimal, но по дороге проходят через float, и никто не замечает потерь:

from decimal import Decimal
from hypothesis import given, strategies as st

money = st.decimals(min_value=0, max_value=1_000_000, places=2)

@given(money)
def test_price_survives_float_roundtrip(value):
    assert Decimal(repr(float(value))) == value

Тест падает почти сразу. Значение вроде 0.01 после путешествия через float возвращается с хвостом из лишних знаков после запятой, потому что двоичное представление не способно точно удержать десятые доли. Автор юнит-тестов берёт для проверки 100.50, на котором всё сходится, и спокойно уходит домой. Свойство перебирает десятичные значения и вылавливает первое расхождение, а контракт теста читается как требование к системе: любая цена обязана выдерживать кругосветку через float без искажений. Такой шаблон без изменений переносится на даты в ISO-строках, конфигурации, байтовые протоколы и парсеры: свойство одно, баги в каждом домене свои, зато типовые, те самые, что позже превращаются в ночные инциденты.

Рядом живёт второй шаблон, идемпотентность. Повторное применение операции обязано ничего не менять: повторная нормализация строки даёт ту же строку, повторная сортировка отсортированного не двигает элементы, повторное сжатие уже сжатого не растит размер. Ошибки тут коварные и тихие, они всплывают на ретраях в продакшене, когда операция выполняется второй раз из-за таймаута, и внезапно применяют промотку дважды. Свойство ловит весь класс таких бед заранее, ещё на локальной машине разработчика.

Календарные ловушки в датах и времени

Календарная арифметика просит свойства почти в прямом смысле слова. Человек помнит про 31 декабря и високосный 2024 год, но забывает про 29 февраля 2100 года, который високосным не будет, потому что год делится на сто и не делится на четыреста. Стратегия st.dates() про такие тонкости помнит всегда:

from hypothesis import given, strategies as st

@given(st.dates(), st.integers(min_value=1, max_value=240))
def test_add_months_never_fails(day, months):
    shifted = add_months(day, months)
    assert shifted > day

Наивная реализация прибавления месяцев падает с исключением на паре вроде 29 января 2023 плюс один месяц: февраля тридцатого не существует. Property-based прогон вытащит именно такой контрпример за доли секунды, а минимизатор сократит его до аккуратной даты, которую можно вставить в баг-репорт без стыда. Второй календарный слой, часовые пояса и переводы часов, ловится тем же приёмом: свойство требует, чтобы перевод метки времени туда и обратно давал исходный момент, и ловит каждую зону, где разница смещается. Стратегия st.datetimes() принимает ограничители диапазона и набор часовых поясов, поэтому кромка перехода на летнее время, ночь, когда один и тот же момент наступает дважды, попадает в прогон автоматически, без ручного поиска тайной таблицы дат. После пары таких находок команда перестаёт относиться к датам как к простой строке в базе, это само по себе победа.

Минимизация контрпримера и память о падениях

Две механики делают Hypothesis практичной в ежедневной работе, а не только в лабораторных условиях. Первая, сокращение контрпримера. Найдя провал на списке из сорока элементов, библиотека продолжит экспериментировать и выкатит минимальный воспроизводимый случай, часто из двух элементов. Разница между лентой из сорока значений и лаконичным парой аргументов это разница между неразрешимой задачей и пятиминутным исправлением.

Вторая, база примеров. Найденные падения сохраняются в служебном каталоге проекта и воспроизводятся при следующих прогонах, поэтому regression-случаи не теряются даже до того, как баг починен. Декоратор @example закрепляет конкретный вход за тестом навсегда, превращая случайную находку в постоянного охранника. Отдельно настраивается нагрузка: в локальном прогоне хватает сотни примеров, в ночной сборке можно поднять до тысяч, ведь генерация случайных входов стоит дешёво, а единственная цена, время процессора. Когда часть сгенерированных входов не подходит под домен функции, их отсеивает функция assume или метод filter у стратегии. Здесь важна мера: если фильтр отбрасывает девять случаев из десяти, генерация превращается в вытянутый прогон, и честнее описать стратегию уже, чем надевать на неё фильтр. Для сложных структур существуют композитные стратегии, где входы зависят друг от друга, например диапазон дат, у которого конец не раньше начала.

Случаи, когда юнит-тесты справляются лучше

Обратная сторона медали заслуживает отдельного разговора, потому что попытка перевести весь тестовый набор на свойства заканчивается одинаково у всех, кто пробовал. Бизнес-правила с конкретными ожиданиями ближе к примеру: если третий товар в корзине получает скидку десять процентов, читатель теста хочет видеть корзину и итоговую сумму, а не формулу-инвариант, требующую расшифровки. Системы с состоянием и побочными эффектами требуют stateful-механик, где последовательность действий генерируется вместе с проверками инвариантов, и это уже заметно более сложное ремесло с длинной кривой входа.

Тесты с внешними сервисами и медленной инициализацией тоже не место для сотен сгенерированных входов: время прогона взлетает, а детерминированность падает. Роль случайности здесь тоже требует уважения: источник порождающего числа фиксируется в отчёте, поэтому любое падение воспроизводится в CI и на машине коллеги, и свойства не превращаются в лотерею с непредсказуемым результатом. Наконец, свойство можно написать плохо, слишком общее утверждение тривиально истинно, слишком узкое дублирует реализацию и проверяет код кодом. Разумная рабочая пропорция выглядит так: обычные юнит-тесты фиксируют договорённости и бизнес-кейсы, свойства дополняют их там, где входы образуют большое пространство и есть парные или инвариантные операции. Это не конкуренция, а разделение труда.

Порядок внедрения свойств в существующий проект

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

  1. Выберите чистые функции с парными операциями вроде сериализации, сжатия или преобразования единиц, там свойства пишутся за минуты;
  2. Вынесите стратегии в общий модуль, чтобы для доменных типов вроде денег, телефонов и адресов генераторы писались один раз;
  3. Добавьте по одному свойству к выбранным функциям и включите их в обычный прогон pytest без отдельной инфраструктуры;
  4. Поднимите max_examples для ночного прогона, пусть глубокий поиск работает тогда, когда его время никого не держит;
  5. Каждый найденный баг закрепляйте декоратором @example, чтобы контрпример жил в репозитории вечно;
  6. Лишь после первых ловушек расширяйте охват на stateful-сценарии, где инварианты проверяются внутри сгенерированных последовательностей действий.

Честный итог такой. Property-based тестирование не заменяет юнит-тесты, оно закрывает другую сторону риска: примеры фиксируют то, о чём вы уже подумали, свойства ищут то, о чём вы подумать не успели. Самый дешёвый способ ощутить разницу, взять одну функцию с круговой конвертацией и дать Hypothesis минуту процессорного времени. В большинстве случаев она вернёт контрпример, о котором вы не думали, и после этого спорить о полезности методики станет нечем.