Вы написали тридцать юнит-тестов, подобрали входы руками, всё зелёное, релиз уезжает. Через неделю прод падает на пустой строке, на отрицательном нуле, на числе чуть больше 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. «Фаззинг и реверс: как понять, что делает программа, найти в ней ошибки». Записаться

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

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


  1. Granulex
    16.06.2026 13:23

    Значит, вместо тридцати придуманных примеров – пятьсот сгенерированных, и всё зелёное. Только свойство было "не вызывает исключение". Hypothesis добросовестно проверил это на пустых строках, суррогатных парах и отрицательном нуле. Хорошо протестированный баг.