Property-based тесты с Hypothesis: находим то, что не искали
Классические тесты проверяют примеры, которые вы придумали сами. Hypothesis генерирует сотни входов и находит баг на пустой строке, отрицательном числе и Unicode-символе, о которых вы не подумали.

Вы написали функцию сериализации, покрыли её десятком тестов, все зелёные. Через неделю в проде она падает на строке с эмодзи и на числе -0.0. Ни один из ваших примеров таких входов не содержал — вы проверяли то, что сами придумали. Property-based тестирование переворачивает подход: вы описываете свойство, которое должно выполняться всегда, а библиотека сама подбирает входные данные, чтобы это свойство сломать. В Python это делает Hypothesis. Разберём, как она устроена, где реально ловит баги и чем за это приходится платить.
Материал написан для Hypothesis 6.x и pytest как раннера. Всё, что ниже, запускается на Python 3.9+.
Что не так с примерными тестами
Возьмём стандартный тест. Мы проверяем, что кодирование и декодирование возвращают исходное значение:
def encode(text):
# упрощённый RLE: подряд идущие символы -> (символ, счётчик)
if not text:
return []
result = []
current = text[0]
count = 1
for char in text[1:]:
if char == current:
count += 1
else:
result.append((current, count))
current = char
count = 1
result.append((current, count))
return result
def decode(pairs):
return "".join(char * count for char, count in pairs)
def test_roundtrip():
assert decode(encode("aaabbc")) == "aaabbc"
assert decode(encode("")) == ""
Тест зелёный. Но вы проверили ровно две строки, которые пришли вам в голову. А что с числами в строке? С Unicode? С символом-разделителем внутри данных? Примерный тест проверяет ваши предположения о входах, а не сами входы. Property-based тест проверяет свойство на входах, которые придумывает не человек.
Свойство вместо примера
Ключевая мысль: вы формулируете инвариант — утверждение, истинное для любого корректного входа. Для пары encode/decode инвариант очевиден: для любой строки декодирование её кодировки даёт исходную строку. Записываем это через Hypothesis:
from hypothesis import given
from hypothesis import strategies as st
@given(st.text())
def test_roundtrip_any_text(text):
# свойство: decode(encode(x)) == x для любой строки x
assert decode(encode(text)) == text
Здесь st.text() — стратегия: описание того, какие значения генерировать. Декоратор @given запускает тело теста много раз (по умолчанию около сотни) с разными значениями. Hypothesis начнёт с простых случаев — пустая строка, один символ — и будет наращивать сложность: длинные строки, Unicode, суррогатные пары, управляющие символы.
Примерный тест отвечает на вопрос «работает ли это на входе, который я придумал». Property-based тест отвечает на вопрос «на каком входе это сломается».
Что такое минимизация
Самая полезная часть Hypothesis — не генерация, а shrinking1. Когда библиотека находит падающий вход, она не показывает вам случайную строку из 400 символов. Она автоматически упрощает её до минимального контрпримера, который всё ещё воспроизводит ошибку. Вместо "x9f2\u200d\U0001f9d1kq..." вы получите что-то вроде "0" — и сразу поймёте суть бага. Найденный контрпример Hypothesis запоминает в локальной базе (каталог .hypothesis/) и подставляет первым при следующем запуске, чтобы регрессия не проскочила.
1 Shrinking (минимизация) — процесс поиска наименьшего входа, воспроизводящего сбой. Hypothesis сокращает строки, уменьшает числа к нулю, убирает элементы из списков, пока тест продолжает падать.
Стратегии: из чего собираются входы
Стратегия — это генератор значений с встроенной логикой минимизации. Базовые стратегии комбинируются в сложные структуры. Несколько, которые нужны в 90% случаев:
st.integers(min_value=0, max_value=100)— целые в диапазоне;st.floats(allow_nan=False, allow_infinity=False)— числа с плавающей точкой; NaN и бесконечности отключаются явно, иначе они прилетят в тест;st.text(alphabet="abc")— строки из заданного алфавита;st.lists(st.integers(), min_size=1)— списки из других стратегий;st.dictionaries(st.text(), st.integers())— словари;st.sampled_from(["GET", "POST", "PUT"])— значение из фиксированного набора.
Стратегии преобразуются методами .map() и фильтруются .filter(), а для составных объектов удобен st.builds():
from dataclasses import dataclass
from hypothesis import given, strategies as st
@dataclass
class User:
name: str
age: int
# строит User из стратегий для каждого поля
user_strategy = st.builds(
User,
name=st.text(min_size=1),
age=st.integers(min_value=0, max_value=150),
)
@given(user_strategy)
def test_user_serialization(user):
assert deserialize(serialize(user)) == user
Не злоупотребляйте .filter() для отбрасывания большой доли значений: если фильтр отсеивает почти всё, Hypothesis потратит бюджет генерации впустую и предупредит вас об этом. Лучше сузить исходную стратегию.
Где это реально ловит баги
Property-based тесты особенно сильны там, где легко сформулировать инвариант. Типичные шаблоны свойств:
| Свойство | Формулировка | Пример задачи |
|---|---|---|
| Round-trip | decode(encode(x)) == x | сериализация, кодеки, парсеры |
| Идемпотентность | f(f(x)) == f(x) | нормализация, сортировка, дедупликация |
| Инвариант | свойство сохраняется после операции | баланс счёта не уходит в минус |
| Сравнение с эталоном | fast(x) == slow(x) | оптимизированная версия против наивной |
| Коммутативность | merge(a, b) == merge(b, a) | слияние конфигов, объединение множеств |
Последний шаблон — test oracle — часто недооценивают. Если у вас есть медленная, но заведомо правильная реализация, а вы пишете быструю, свойство «обе дают одинаковый результат на любом входе» ловит расхождения, которые вы никогда бы не задали руками.
Пример: свойство идемпотентности
from hypothesis import given, strategies as st
def normalize_tags(tags):
# убираем пустые, приводим к нижнему регистру, сортируем, дедуплицируем
cleaned = sorted({t.strip().lower() for t in tags if t.strip()})
return cleaned
@given(st.lists(st.text()))
def test_normalize_is_idempotent(tags):
once = normalize_tags(tags)
twice = normalize_tags(once)
# повторная нормализация ничего не меняет
assert once == twice
Если внутри normalize_tags закрадётся операция, зависящая от порядка или регистра непоследовательно, Hypothesis найдёт вход, где второй проход изменит результат.
Чем приходится платить
Property-based тесты — не замена примерным, а дополнение. Их цена:
- Труднее написать. Сформулировать инвариант сложнее, чем накидать пару примеров. Для куска бизнес-логики без явного свойства ("кнопка должна быть синей") подход бесполезен.
- Медленнее. Сотня прогонов вместо одного. На тяжёлых тестах это чувствуется — держите их отдельно от быстрого юнит-набора.
- Флейки при плохих стратегиях. Если стратегия генерирует значения, которые ваша функция в принципе не должна принимать, тест будет падать на «невалидных» входах. Сужайте стратегию под контракт функции.
- Недетерминизм в CI. По умолчанию генерация случайна. Тест может позеленеть локально и упасть в CI на другом входе — это фича, но её надо принять и сохранять контрпримеры.
Как держать это под контролем
Настройки задаются декоратором @settings. Полезно ограничить время и явно закрепить примеры-регрессии через @example:
from hypothesis import given, settings, example, strategies as st
@settings(max_examples=200, deadline=500) # 200 прогонов, дедлайн 500 мс на прогон
@given(st.text())
@example("") # эти входы проверяются всегда
@example("\u200d") # zero-width joiner — частый источник багов
def test_roundtrip_with_pinned(text):
assert decode(encode(text)) == text
Параметр deadline отсекает тесты, которые внезапно стали медленными на конкретном входе. Если тест по своей природе нестабилен по времени (например, ходит в сеть — чего в property-тестах лучше избегать), дедлайн отключают через deadline=None.
Если не сработало
Три ситуации, с которыми сталкиваются почти все:
- FailedHealthCheck: filter_too_much. Ваш
.filter()отбрасывает слишком много сгенерированных значений. Перепишите стратегию так, чтобы она сразу порождала нужные значения, а не отсеивала лишние. - Тест падает в CI, но не локально. Разные seed дают разные входы. Найденный контрпример лежит в
.hypothesis/examples— этот каталог по умолчанию не коммитят, поэтому CI его не видит. Либо закоммитьте базу, либо (чаще) добавьте найденный вход как@example, чтобы он проверялся детерминированно. - DeadlineExceeded. Функция медленная на части входов. Профилируйте на конкретном контрпримере (Hypothesis его печатает) или поднимите/снимите
deadline, если медленный вход легитимен.
Что делать дальше
Начните с одного round-trip теста в проекте, где есть сериализация или парсинг — там инвариант очевиден, а баги на краевых входах вероятны. Посмотрите, что найдёт Hypothesis на первом же прогоне. Дальше — стратегии @composite для сложных связанных данных и RuleBasedStateMachine для тестирования систем с состоянием (очереди, кэши, конечные автоматы): это отдельная большая тема, здесь мы её только обозначаем. В проде смотрите на то, попадают ли найденные контрпримеры в набор @example, чтобы регрессии не возвращались. Актуальный список стратегий и настроек сверяйте с документацией вашей версии — API между минорными релизами 6.x расширяется.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Hypothesis — официальная документация, Hypothesis — репозиторий на GitHub
Частые вопросы
Property-based тесты заменяют обычные юнит-тесты?
У меня Hypothesis не 6.x, а более старая версия — что изменится?
Тест зелёный локально, а в CI падает на другом входе — это баг библиотеки?
Тесты стали медленными, весь набор тормозит — что делать?
Как заставить стратегию генерировать только валидные для моей функции данные?
Можно ли использовать Hypothesis для тестов, которые ходят в базу или сеть?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.