Вы написали тридцать юнит-тестов, подобрали входы руками, всё зелёное, релиз уезжает. Через неделю прод падает на пустой строке, на отрицательном нуле, на числе чуть больше int32, на юникоде с суррогатными парами или на списке с дубликатами. Тест на это не сработал по простой причине: пример-ориентированный тест проверяет ровно те случаи, которые вы придумали, а баги живут в тех, которые не придумали.
Property-based testing может изменить подход. Вместо «на входе X получаю Y» вы формулируете свойство, истинное для любого входа, а фреймворк сам генерирует сотни разнообразных значений и пытается это свойство сломать.
В статье разберём на Hypothesis для Python, как писать такие свойства, какие они бывают, как описывать вход, как тестировать системы с состоянием и где этот подход бьёт обычные тесты, а где нет.
Первый property-тест
Самое наглядное свойство — round-trip: если данные закодировать, а потом раскодировать, должно получиться исходное. Обычный тест проверил бы пару строк, property-тест проверяет любые.
from hypothesis import given, strategies as st @given(st.text()) def test_roundtrip(s): assert decode(encode(s)) == s
Hypothesis сгенерирует много значений s, и в их числе те, о которых руками не вспоминают: пустая строка, одиночный перевод строки, эмодзи, символы из разных юникод-плоскостей, суррогатные пары. Если хоть одно сломает равенство, тест упадёт и сообщит конкретное значение. Никакого списка примеров вы не пишете — пишете утверждение, истинное для всего домена.
Зелёный тест и баг, которого он не видит
Покажем разницу на конкретной функции. Пусть chunk режет список на куски по n элементов, и в реализации есть распространённая ошибка — теряется последний неполный кусок.
def chunk(xs, n): out = [] for i in range(0, len(xs), n): if i + n <= len(xs): # баг: неполный хвост в результат не попадает out.append(xs[i:i + n]) return out
Пример-ориентированный тест, написанный на удобных делящихся случаях, проходит и создаёт ложную уверенность:
def test_chunk_example(): assert chunk([1, 2, 3, 4], 2) == [[1, 2], [3, 4]] # зелёный
Property-тест формулирует то, что должно держаться всегда: склейка кусков обратно даёт исходный список, и ни один кусок не длиннее n.
@given(st.lists(st.integers()), st.integers(min_value=1, max_value=10)) def test_chunk_reassembles(xs, n): chunks = chunk(xs, n) assert [x for c in chunks for x in c] == xs # склейка == исходный список assert all(len(c) <= n for c in chunks)
Hypothesis быстро находит падение, плюсом минимизирует его до простейшего вида — например, xs=[0], n=2: функция вернула пустой список, склейка дала [], а ожидался [0]. Минимальный контрпример сразу указывает на природу бага — потерю хвоста, а не тонет в случайном списке из сотен элементов.
Какие свойства вообще бывают
Главная работа в property-тестировании — придумать свойство. Есть несколько типовых форм, и почти любая функция попадает хотя бы в одну.
Инверсия и round-trip — когда у операции есть обратная: сериализация и парсинг, сжатие и распаковка, кодирование и декодирование, сохранение и загрузка. Свойство: прямая, затем обратная, возвращают исходное.
Идемпотентность — когда повторное применение ничего не меняет: нормализация, сортировка, дедупликация, приведение к канону.
@given(st.lists(st.integers())) def test_sort_invariants(xs): out = sorted(xs) assert len(out) == len(xs) # длина сохранена assert all(a <= b for a, b in zip(out, out[1:])) # результат упорядочен assert sorted(out) == out # идемпотентность
Инвариант — свойство, которое держится для любого выхода независимо от входа: сортировка сохраняет длину и мультимножество элементов, маршрут не длиннее суммы рёбер, сумма по счетам неизменна. Эталон (oracle) — когда есть медленная, но заведомо верная реализация: новую быструю проверяют против неё, или новую версию против старой.
@given(st.lists(st.integers())) def test_against_reference(xs): assert my_fast_sort(xs) == sorted(xs) # эталон — встроенная сортировка
Метаморфические соотношения — связь между выходами на связанных входах, когда правильного ответа вы не знаете: добавление элемента увеличивает счётчик ровно на единицу, перестановка входа не меняет сумму, удвоение всех цен удваивает итог.
Несколько свойств на одной функции
На нетривиальной функции свойства складываются и вместе описывают её поведение точнее любого набора примеров. Возьмём слияние интервалов: на входе список отрезков, на выходе — упорядоченные непересекающиеся отрезки, покрывающие то же множество точек.
intervals = st.lists( st.tuples(st.integers(0, 100), st.integers(0, 100)).map(lambda t: (min(t), max(t))) ) @given(intervals) def test_merge_properties(ivs): out = merge_intervals(ivs) assert all(a[1] < b[0] for a, b in zip(out, out[1:])) # упорядочены и не пересекаются assert merge_intervals(out) == out # идемпотентность def covered(point, segs): return any(s <= point <= e for s, e in segs) for s, e in ivs: assert covered(s, out) and covered(e, out) # покрытие входа сохранено
Стратегия intervals через .map гарантирует, что начало отрезка не больше конца, так что генератор не тратит время на заведомо невалидные пары. Три свойства — порядок, идемпотентность и сохранение покрытия — вместе фиксируют контракт функции, и любая реализация, которая теряет интервал, переставляет границы или оставляет пересечение, провалит хотя бы одно.
Стратегии: как описывать вход
Вход задаётся стратегиями — описаниями того, какие значения генерировать. Базовые покрывают примитивы и контейнеры, доменные объекты собираются из них, а сложные структуры — рекурсией.
ages = st.integers(min_value=0, max_value=120) emails = st.from_regex(r"[a-z]{1,8}@[a-z]{1,8}\.[a-z]{2,4}", fullmatch=True) users = st.builds(User, name=st.text(min_size=1, max_size=20), age=ages, email=emails) colors = st.sampled_from(["red", "green", "blue"]) # из фиксированного множества maybe_int = st.none() | st.integers() # объединение вариантов
st.floats стоит настраивать под задачу: по умолчанию он генерирует nan и бесконечности, и если код к ним не готов, либо обрабатывайте их, либо отключайте через allow_nan=False, allow_infinity=False. Для древовидных и JSON-подобных данных есть рекурсивные стратегии:
json_value = st.recursive( st.none() | st.booleans() | st.integers() | st.text(), lambda children: st.lists(children) | st.dictionaries(st.text(), children), max_leaves=20, )
Когда нужно построить значение с зависимостью между частями, помогает @st.composite, где можно рисовать значения последовательно:
@st.composite def sorted_pair(draw): a = draw(st.integers()) b = draw(st.integers(min_value=a)) # второй элемент по построению не меньше первого return (a, b)
А если решение, что рисовать дальше, зависит от уже сгенерированного прямо внутри теста, используется st.data:
@given(st.data()) def test_index_in_list(data): xs = data.draw(st.lists(st.integers(), min_size=1)) i = data.draw(st.integers(0, len(xs) - 1)) # индекс зависит от длины уже нарисованного списка assert xs[i] in xs
Для ограничений входа есть два пути — assume, отбрасывающий неподходящее значение, и конструирование валидного значения через map/builds. Конструирование почти всегда лучше.
@given(st.integers()) def test_division(x): assume(x != 0) # редкое исключение можно отбросить, частое — лучше не генерировать assert (10 // x) * x + 10 % x == 10
Стейтфул-тестирование: проверка системы против модели
Еще одна сила Hypothesis — генерация не одного входа, а целых последовательностей операций. Так тестируют объекты с состоянием: кэш, очередь, хранилище, конечный автомат. Вы описываете операции правилами, рядом держите простую эталонную модель, и Hypothesis генерирует случайные сценарии вызовов, проверяя, что реальная система ведёт себя как модель.
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant class KVStateMachine(RuleBasedStateMachine): def __init__(self): super().__init__() self.model = {} # эталон — обычный dict self.store = KVStore() # тестируемая реализация @rule(k=st.integers(0, 50), v=st.integers()) def put(self, k, v): self.store.put(k, v) self.model[k] = v @rule(k=st.integers(0, 50)) def check(self, k): assert self.store.get(k) == self.model.get(k) @invariant() def size_matches(self): assert self.store.size() == len(self.model) TestKV = KVStateMachine.TestCase
Hypothesis перебирает случайные цепочки put и check, после каждого шага сверяя состояние, а инвариант проверяется на каждом шаге автоматически. Такой тест ловит ошибки, возникающие только на определённой последовательности операций, именно их пример-ориентированные тесты пропускают чаще всего.
Когда правила создают сущности, на которые ссылаются последующие операции (открытые соединения, дескрипторы, созданные записи), их складывают в Bundle и берут оттуда как вход других правил. Для систем с вытеснением, вроде LRU-кэша, в модель закладывают и политику вытеснения, иначе эталон разойдётся с реализацией после первого выселения.
Целенаправленная генерация
Иногда баг прячется не в случайном входе, а в экстремальном — самом медленном, самом большом, с максимальным расхождением. Чистая случайная генерация натыкается на такие входы редко. target подсказывает Hypothesis, какую величину максимизировать, и поиск смещается в сторону тяжёлых случаев.
from hypothesis import given, target @given(st.lists(st.integers())) def test_runtime_bounded(xs): elapsed = measure_runtime(xs) target(elapsed) # двигаем генерацию к входам, максимизирующим время assert elapsed < LIMIT
Это превращает property-тест в лёгкий инструмент поиска худшего случая — деградации производительности, переполнения, патологического входа для алгоритма с плохой асимптотикой.
Чего стоит избегать
Property-тесты дают ложную уверенность, если свойство выбрано неудачно, и тут есть несколько ошибок.
Первая — переписать реализацию в свойстве: если проверять функцию против её же повторной реализации, вы написали один и тот же код дважды, и общий баг тест пропустит. Спасают независимый эталон и структурные инварианты, не повторяющие логику.
Вторая — недетерминированное свойство, зависящее от времени, случайности или порядка обхода множества: оно будет мигать и подрывать доверие к набору тестов. Свойство должно быть детерминированным при фиксированном входе.
Третья — тривиально истинное свойство, которое держится независимо от наличия бага; проверить его осмысленность можно, временно внеся ошибку в функцию и убедившись, что тест краснеет.
Воспроизводимость и база контрпримеров
Когда тест падает, Hypothesis сохраняет найденный контрпример в локальную базу и при следующем запуске сначала проигрывает сохранённые падения. Один раз пойманный баг автоматически становится регрессионным тестом и не исчезает, пока не починен. Конкретный случай можно прибить к тесту явно через @example, а полную детерминированность дать через @seed.
from hypothesis import given, example @given(st.text()) @example("") # пустую строку проверяем всегда, не полагаясь на случай @example("\x00") # и нулевой байт def test_parse(s): assert parse(s) is not None or s == ""
База примеров — причина, по которой property-тесты не превращаются в мигающие: найденное падение фиксируется и проигрывается, а не зависит от того, повезёт ли генератору наткнуться на него снова.
Производительность и health-checks
Каждый property-тест прогоняет функцию много раз, поэтому стоит следить за бюджетом. Число примеров задаётся через settings(max_examples=...), ограничение времени на один пример — через deadline. Health-checks предупреждают о проблемах генерации: слишком медленная функция, слишком много отброшенных фильтром значений, слишком тяжёлые данные. Срабатывание health-check — сигнал поправить стратегию, а не отключить проверку: чаще всего за ним стоит фильтрация там, где нужно конструирование, или генерация данных большего размера, чем требуется.
from hypothesis import given, settings @settings(max_examples=500, deadline=200) # 500 примеров, не дольше 200 мс на каждый @given(st.lists(st.integers(), max_size=100)) def test_pipeline(xs): assert process(xs) is not None
В CI обычно держат умеренный max_examples, а более долгий прогон с большим числом примеров и расширенной генерацией выносят в отдельный ночной запуск, где время не критично.
В итоге
Property-based testing не заменяет пример-ориентированные тесты, а закрывает то пространство входов, которое вы не продумали: вместо перечисления случаев вы утверждаете свойство, истинное для всего домена, и фреймворк сам ищет минимальный контрпример.
Основная работа — выбрать свойство: инверсию, идемпотентность, инвариант, эталон или метаморфическое соотношение, а для совсем тяжёлых случаев достаточно проверки «не падает». Вход описывается стратегиями, и валидный домен лучше конструировать через builds, map и composite, чем выфильтровывать через assume.

Продолжить тему автоматизированного поиска ошибок можно на бесплатных открытых уроках OTUS. Преподаватели-практики покажут рабочие инструменты, разберут подходы на примерах и ответят на вопросы по ходу занятия.
2 июля, 20:00. «REST Assured & JSON Schema Validator: автоматизация тестирования API на практике». Записаться
23 июля, 20:00. «Фаззинг и реверс: как понять, что делает программа, найти в ней ошибки». Записаться
Больше бесплатных уроков смотрите в дайджесте.
Granulex
Значит, вместо тридцати придуманных примеров – пятьсот сгенерированных, и всё зелёное. Только свойство было "не вызывает исключение". Hypothesis добросовестно проверил это на пустых строках, суррогатных парах и отрицательном нуле. Хорошо протестированный баг.