Привет, Хабр!
Тестирование свойств придумали в конце девяностых: Коэн Клаессен и Джон Хьюз собрали для 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. «Как ускорить создание автотестов с помощью локальных ИИ‑моделей». Записаться
Уроки ведут преподаватели‑практики. На занятиях можно задать вопросы и протестировать формат обучения.
Больше бесплатных уроков июля смотрите в дайджесте.