Привет, Хабр!

Тестирование свойств придумали в конце девяностых: Коэн Клаессен и Джон Хьюз собрали для Haskell библиотеку QuickCheck и описали её в статье на ICFP 2000. Идея была вывернута наизнанку относительно привычной: разработчик не перечисляет примеры входов и ожидаемых ответов, а формулирует утверждение, которое обязано выполняться для любого корректного входа.

Примеры дальше придумывает библиотека, сотнями, и старается придумать такие, на которых утверждение сломается.

С тех пор подход разошёлся почти по всем языкам: fast‑check в TypeScript, proptest в Rust, ScalaCheck, PropEr в Erlang. В Python эталоном стал Hypothesis Дэвида МакАйвера, который живёт с 2013 года и давно перестал быть экзотикой: он стоит в тестовой инфраструктуре CPython, NumPy, SciPy, Pandas, Django.

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

В работе «Agentic Property‑Based Testing» LLM‑агента научили читать сигнатуры, докстринги и код, выводить из этого свойства и писать по ним тесты на Hypothesis. Его прогнали по сотне популярных Python‑пакетов, он выдал 984 отчёта о багах, из которых после ручной проверки 56% оказались настоящими багами, а 32% — такими, которые имеет смысл нести мейнтейнерам. Часть патчей, включая правку в NumPy, приняли. Речь про библиотеки, которые тестируют десятилетиями тысячи людей.

Разница на одной функции

Обычный тест сортировки выглядит так:

def test_sort_examples():
    assert my_sort([]) == []
    assert my_sort([1]) == [1]
    assert my_sort([3, 1, 2]) == [1, 2, 3]
    assert my_sort([-1, 0, 1]) == [-1, 0, 1]

Четыре примера проверены, четыре ответа зафиксированы. Про список из десяти тысяч элементов тест не знает ничего. Про список из одинаковых чисел, про огромные int, про NaN среди float — тоже ничего, потому что таких примеров никто не написал.

Тот же тест как свойство:

from hypothesis import given, strategies as st

@given(st.lists(st.integers()))
def test_sort(lst):
    result = my_sort(lst)
    assert len(result) == len(lst)                    # ничего не потеряли
    assert sorted(result) == sorted(lst)              # состав элементов тот же
    assert all(a <= b for a, b in zip(result, result[1:]))   # порядок неубывающий

Hypothesis сгенерирует сотню списков: пустые, из одного элемента, с повторами, с граничными значениями int, с отрицательными числами. Генератор не просто кидает случайные данные, он целенаправленно тянется к краям диапазонов, потому что именно там обычно живут баги.

Обратите внимание на второе утверждение. Оно сравнивает результат с эталонной сортировкой и потому не проверяет ничего интересного само по себе. А вот первое и третье вместе с ним образуют полное описание того, что значит «отсортировать»: тот же мультимножественный состав плюс порядок.

Хороший property‑тест выглядит именно так — не «на этом входе ответ такой», а «вот что вообще значит быть правильным ответом».

Стратегии

Стратегия описывает пространство входов. Для примитивов есть готовые, для составных типов — комбинаторы.

@given(st.integers(min_value=1, max_value=100))
def test_bounded(n): ...

@given(st.floats(allow_nan=False, allow_infinity=False))
def test_float_roundtrip(x):
    assert float(repr(x)) == x

@given(st.text(alphabet=st.characters(codec='ascii'), min_size=1))
def test_ascii(s): ...

@given(st.dictionaries(st.text(min_size=1), st.integers()))
def test_dict(d): ...

@given(st.one_of(st.integers(), st.none()))
def test_optional(x): ...

st.floats() без аргументов выдаёт NaN, плюс‑минус бесконечность, отрицательный ноль и денормализованные числа. Это ровно тот набор, о котором никто не думает, когда пишет функцию, и ровно поэтому Hypothesis так эффективен на любой float‑математике. Если ваша функция не обязана работать с NaN, границы надо задать явно, а не надеяться, что генератор их не найдёт. Найдёт, и на первой же сотне примеров.

Для доменных типов есть готовое: st.emails(), st.ip_addresses(), st.datetimes() с временными зонами, st.uuids(), st.decimals().

Собственные объекты

Для датакласса чаще всего хватает st.builds(), который сам разберёт конструктор:

@dataclass
class Order:
    order_id: str
    customer_id: int
    total: float

order_strategy = st.builds(
    Order,
    order_id=st.text(min_size=8, max_size=16),
    customer_id=st.integers(min_value=1, max_value=1_000_000),
    total=st.floats(min_value=0, max_value=100_000, allow_nan=False),
)

@st.composite нужен там, где поля зависят друг от друга.

@st.composite
def date_range(draw):
    start = draw(st.dates())
    end = draw(st.dates(min_value=start))    # конец гарантированно не раньше начала
    return start, end

Это важнее, чем кажется.

Альтернатива — генерировать пары дат как попало и отбрасывать неподходящие через assume(). Работает, но каждый отброшенный пример съедает бюджет генерации, а при слишком жёстком фильтре Hypothesis просто сдастся с Unsatisfiable.

Правило простое: ограничение лучше встроить в стратегию, чем отфильтровать после.

Shrinking, ради которого всё и затевалось

Найти падающий вход — половина дела, и не самая ценная. Случайный контрпример обычно выглядит как [47, 92, -13, 0, 0, 0, 8, 8, 1042, -1, 33], и что именно в нём сломало функцию, непонятно.

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

Показательный пример — наивный парсер почтового адреса:

import re

def parse_email(email):
    m = re.match(r"(?P<username>\w+).(?P<domain>[\w\.]+)", email)
    return m.groups() if m else None

@given(st.emails())
def test_parse_email(email):
    result = parse_email(email)
    assert result is not None
    assert "." in result[1]

Hypothesis довольно быстро находит падение и сокращает его до чего‑то вроде 0/0@A.ac. Из этого немедленно видно, в чём дело: \w+ не знает про слэш, плюс, дефис и прочие символы, которые RFC в локальной части адреса разрешает. Тест по примерам такого бы не нашёл, потому что человек, пишущий примеры, придумывает адреса вида user@example.com.

Найденные контрпримеры библиотека складывает в локальную базу в .hypothesis/ и при следующем прогоне проверяет их первыми. Полагаться на это в CI не стоит: директория живёт на конкретной машине, а раннер обычно чистый. Надёжный способ закрепить найденный баг — прибить его прямо в тесте:

from hypothesis import example

@given(st.emails())
@example("0/0@a.ac")     # регрессия из issue #142
def test_parse_email(email):
    ...

Теперь этот вход проверяется всегда и первым, независимо от seed, машины и настроения генератора.

Какие свойства вообще бывают

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

  • Туда и обратно. Всё, что имеет обратную операцию: сериализация, кодировщики, парсеры, конвертеры форматов.

@given(st.recursive(
    st.none() | st.booleans() | st.integers() | st.text(),
    lambda children: st.lists(children) | st.dictionaries(st.text(), children),
    max_leaves=50,
))
def test_json_roundtrip(value):
    assert json.loads(json.dumps(value)) == value

Тут же и вылезает знаменитая особенность: добавьте в стратегию st.floats(), и тест упадёт на NaN, потому что json.dumps пишет NaN, чего в стандарте JSON нет, а NaN != NaN ломает сравнение. Ровно тот класс проблем, который в тестах по примерам живёт годами.

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

  • Сравнение с эталоном. Быстрая реализация против медленной и заведомо верной. Классика при оптимизации: сначала пишется тупая версия, потом хитрая, и Hypothesis сотнями входов проверяет, что они согласны друг с другом.

@given(st.lists(st.floats(allow_nan=False, allow_infinity=False), min_size=1))
def test_median(data):
    assert abs(my_fast_median(data) - statistics.median(data)) < 1e-9
  • Метаморфные свойства. Когда правильный ответ вы не знаете, но знаете, как он должен меняться. Сортировка перевёрнутого списка даёт тот же результат. Скидка 10% и потом ещё 10% не равны скидке 20% (и хорошо бы, чтобы код об этом тоже знал). Поиск по запросу в верхнем регистре возвращает то же, что и в нижнем. Такие свойства особенно хорошо ложатся на бизнес‑логику, где никакого «эталона» не существует.

  • Идемпотентность. Повторное применение ничего не меняет: нормализация, дедупликация, миграция, PUT в вашем API.

Тестирование с состоянием

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

from hypothesis.stateful import RuleBasedStateMachine, rule, invariant, precondition

class CartMachine(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.cart = ShoppingCart()
        self.expected = {}          # эталонная модель на обычном dict

    @rule(item=st.text(min_size=1, max_size=5),
          qty=st.integers(min_value=1, max_value=100))
    def add(self, item, qty):
        self.cart.add(item, qty)
        self.expected[item] = self.expected.get(item, 0) + qty

    @precondition(lambda self: self.expected)
    @rule(data=st.data())
    def remove(self, data):
        item = data.draw(st.sampled_from(sorted(self.expected)))
        self.cart.remove(item)
        del self.expected[item]

    @invariant()
    def matches_model(self):
        for item, qty in self.expected.items():
            assert self.cart.get_quantity(item) == qty

TestCart = CartMachine.TestCase

Рядом с настоящим объектом живёт заведомо правильная примитивная копия на словаре, и после каждого шага они сверяются. Это позволяет тестировать штуки, для которых никакого простого инварианта не сформулируешь.

Обратите внимание на remove: удалять надо то, что в корзине есть, поэтому элемент вытягивается из текущего состояния через st.data(), а не генерируется наугад. Наугад вы почти всегда будете попадать в несуществующий товар и не протестируете ничего.

Именно stateful‑тесты ловят самые неприятные баги: те, что проявляются на пятнадцатом вызове в определённом порядке. Причём shrinking работает и здесь: последовательность из сорока операций сжимается до трёх, которых достаточно для воспроизведения.

Настройки и жизнь в CI

По умолчанию Hypothesis гоняет сотню примеров на тест. Для CI это можно поднять, для локального цикла оставить как есть:

settings.register_profile("dev", max_examples=100)
settings.register_profile("ci", max_examples=1000, deadline=None)
settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev"))

deadline ограничивает время одного примера и по умолчанию равен 200 мс. На нагруженном раннере первый прогон легко в него не уложится (прогрев кеша, импорт тяжёлых модулей), и вы получите загадочный DeadlineExceeded вместо честного результата. В CI его обычно снимают или задают с большим запасом.

С pytest всё работает без бубнов: тесты видит обычный сбор, @pytest.mark.parametrize совмещается с @given. Что не совмещается, так это @given и pytest‑фикстуры с состоянием на уровне функции: фикстура создастся один раз, а тело теста выполнится сотню раз, и накопленный мусор от предыдущих примеров вам всё сломает. Состояние надо создавать внутри тела теста.

Что со всем этим делать

Главная трудность property‑тестирования не в библиотеке. @given осваивается за вечер, стратегии читаются как обычный код, RuleBasedStateMachine устроен прозрачно. Трудность в том, чтобы сформулировать свойство, и на этом шаге большинство попыток и глохнет: человек садится писать тест на свою функцию, не находит, что бы такого про неё утвердить, и возвращается к привычным примерам.

Помогает сместить вопрос. Не «что должна вернуть функция на этом входе», а «что останется верным при любом входе». Ответ почти всегда обнаруживается в одной из знакомых форм: результат конвертируется обратно без потерь, инвариант не нарушается, две реализации согласны друг с другом, преобразование входа предсказуемо меняет выход, повторное применение ничего не меняет. Если ни одна форма не подошла, скорее всего вы взяли не ту функцию.

Ваш create_user, который дёргает три сервиса и пишет в базу, property‑тестом не покрывается, и не надо. А вот парсер конфига, нормализатор телефонов, конвертер валют, работа с временными зонами, любой код, где вход большой, а правило простое, — покрывается отлично и обычно падает на первом же прогоне.

Поэтому подход и берут постепенно: не переписывают тестовый набор, а добавляют по одному свойству к самым коварным функциям в проекте. Дальше аппетит приходит сам. Помогают готовые расширения — hypothesis.extra.numpy, hypothesis.extra.pandas, hypothesis.extra.django умеют генерировать объекты нужной формы, и составную стратегию писать не приходится. Из менее известного стоит запомнить target(), который превращает случайный поиск в направленный: вы подсказываете метрику (время работы, ошибку округления, размер результата), и библиотека начинает тянуть примеры туда, где становится хуже.


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

  • 23 июля, 20:00. «Фаззинг и реверс: как понять, что делает программа, найти в ней ошибки». Записаться

  • 30 июля, 20:00. «API и UI тестирование с Playwright на Python». Записаться

  • 20 августа, 20:00. «Как ускорить создание автотестов с помощью локальных ИИ‑моделей». Записаться

Уроки ведут преподаватели‑практики. На занятиях можно задать вопросы и протестировать формат обучения.

Больше бесплатных уроков июля смотрите в дайджесте.

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