Тесты на примерах проверяют те случаи, до которых вы додумались. Property-based тесты проверяют тысячи случаев, до которых вы НЕ додумались, — и при падении сами ужимают вход до минимального контрпримера. Разбираем, как это работает под капотом, какие свойства бывают, и показываем на коде (Python/Hypothesis), как PBT за минуту находит баг, который ручной тест-кейс не нашёл бы никогда.
Классический unit-тест — это «вход X → ожидаю Y». Вы придумали кейс, зафиксировали ожидание, поехали. Проблема в том, что баги обычно живут не в кейсах, которые вы придумали, а ровно в тех, до которых не дотянулась фантазия: пустая строка, эмодзи, дубликаты, отрицательный ноль, перевод строки внутри значения, целочисленное переполнение.
Property-based testing (PBT) переворачивает подход: вы описываете свойство — утверждение, которое должно быть истинно для любого корректного входа, — а фреймворк сам генерирует сотни случайных входов и пытается это свойство опровергнуть. Нашёл контрпример — ужимает его до минимального («shrinking») и показывает вам.
Пример на примерах vs свойство
Тест на примерах для функции сортировки:
def test_sort_example(): assert my_sort([3, 1, 2]) == [1, 2, 3]
Один вход, одно ожидание. А вот свойства, которые верны для любого списка:
from hypothesis import given, strategies as st @given(st.lists(st.integers())) def test_sort_keeps_length(xs): assert len(my_sort(xs)) == len(xs) # длина не меняется @given(st.lists(st.integers())) def test_sort_is_ordered(xs): r = my_sort(xs) assert all(r[i] <= r[i + 1] for i in range(len(r) - 1)) # реально отсортировано @given(st.lists(st.integers())) def test_sort_is_permutation(xs): assert sorted(my_sort(xs)) == sorted(xs) # это перестановка исходного, ничего не потеряли/не добавили
@given(...) запускает каждый тест на сотне+ сгенерированных списков: пустых, из одного элемента, с дубликатами, с MIN_INT/MAX_INT, гигантских. Вам не нужно придумывать эти кейсы — генератор делает это за вас.
Как это работает под капотом
Три кирпича:
1. Генераторы (strategies) — описывают пространство входов: st.integers(), st.text(), st.lists(...), st.dictionaries(...), и комбинации.
2. Свойство — булево утверждение, которое должно держаться для всех входов из этого пространства.
3. Shrinking — когда свойство падает на каком-то монструозном случайном входе, фреймворк автоматически ищет минимальный вход, на котором оно всё ещё падает.
Именно shrinking превращает PBT из «рандомного фаззера» в инструмент отладки. Сравните два сообщения о падении:
# без shrinking — попробуй пойми, что тут сломалось: Falsifying example: s = 'a8Q\x00\udce2\n\t ZZ9?\r\x7f...(2000 символов)' # с shrinking — Hypothesis ужал до минимума: Falsifying example: test_roundtrip(s='\n')
Во втором случае сразу видно: баг в обработке перевода строки. Это и есть киллер-фича.
Каталог свойств: что вообще утверждать
Самое сложное в PBT — не код, а придумать свойство. Хорошая новость: есть готовые паттерны, которые покрывают большинство случаев.
1. Round-trip (туда-обратно). Самый мощный и частый. decode(encode(x)) == x: сериализация/парсинг, кодеки, JSON, URL-энкодинг, сжатие.
@given(st.text()) def test_json_roundtrip(s): assert json.loads(json.dumps(s)) == s
2. Инвариант. Что-то, что всегда истинно про результат: длина, сумма, упорядоченность, «баланс не ушёл в минус», «id уникальны».
3. Идемпотентность. f(f(x)) == f(x): нормализация, slugify, trim, дедупликация, применение миграции дважды.
@given(st.text()) def test_slugify_idempotent(s): assert slugify(slugify(s)) == slugify(s)
4. Оракул / две реализации. Сравнить быструю реализацию с медленной-но-очевидной (или со старой версией при рефакторинге): результаты должны совпадать на любом входе.
5. Метаморфические отношения. Когда «правильный ответ» неизвестен, но известно, как он должен меняться: sort(reverse(xs)) == sort(xs); добавление товара в корзину увеличивает итог ровно на цену товара.
6. «Никогда не падает». Самое дешёвое свойство для старта: на любом валидном входе функция не кидает необработанное исключение. Часто ловит первые баги ещё до того, как вы сформулировали что-то умнее.
Реальный баг за минуту
Допустим, мы написали «наивный» CSV-сериализатор:
def to_csv_row(fields: list[str]) -> str: return ",".join(fields) def from_csv_row(line: str) -> list[str]: return line.split(",")
Round-trip-свойство:
@given(st.lists(st.text())) def test_csv_roundtrip(fields): assert from_csv_row(to_csv_row(fields)) == fields
Hypothesis почти мгновенно находит и ужимает контрпример:
Falsifying example: test_csv_roundtrip(fields=[','])
Поле, внутри которого есть запятая, ломает round-trip: [','] → "," → ['', '']. Классический баг CSV-экранирования, который на «нормальных» примерах (['a','b']) никогда бы не вылез. Вы его не придумывали — генератор придумал за вас. Это и есть ценность.
Stateful / model-based testing
Высшая лига PBT: тестировать не функцию, а последовательность операций над системой (очередь, кэш, БД, API). Вы описываете возможные действия и модель (упрощённый эталон), а фреймворк генерирует случайные сценарии и сверяет систему с моделью после каждого шага.
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant class CacheModel(RuleBasedStateMachine): def __init__(self): super().__init__() self.real = LruCache(capacity=3) self.model = {} @rule(k=st.integers(), v=st.integers()) def put(self, k, v): self.real.put(k, v) self.model[k] = v @invariant() def size_within_capacity(self): assert len(self.real) <= 3
Так находят баги вытеснения, гонок состояний, рассинхрона — то, что обычным тестом ловится только случайно. Именно таким подходом Джон Хьюз с QuickCheck находил баги в распределённых БД и автомобильном софте — см. оригинальную статью QuickCheck (Claessen & Hughes, 2000), с которой всё началось.
Инструменты по языкам
Python — Hypothesis (де-факто стандарт, отличный shrinking, stateful из коробки).
JS/TS — fast-check.
JVM (Java/Kotlin) — jqwik, QuickTheories.
.NET — FsCheck.
Go — rapid, gopter.
Rust — proptest, quickcheck.
Haskell/Erlang — QuickCheck, PropEr (родина подхода).
Подводные камни — честно
Сформулировать свойство сложнее, чем кейс. Это главный барьер. Начинайте с «не падает» и round-trip, дальше войдёте во вкус.
Генератор должен покрывать реальное пространство. Если ограничить
st.text()только ASCII — не найдёте багов с Unicode. И наоборот: слишком широкий генератор даёт «нереальные» падения.Флак при скрытой недетерминированности. Свойство, зависящее от времени/глобального состояния/реального рандома, будет мигать. Фиксируйте время и seed.
Воспроизводимость. Hypothesis хранит базу упавших примеров и при следующем прогоне проверяет их первыми — коммитьте её в CI, иначе «поймал баг локально, в CI зелено».
Скорость. PBT-тест в сотни раз тяжелее одного юнита. Держите его в отдельном прогоне/наборе, а не в самом горячем pre-commit.
Это не замена примерам. Конкретный регресс-кейс на найденный баг по-прежнему полезен (быстрый, читаемый). PBT и примеры дополняют друг друга.
Как внедрить за один спринт
Возьмите одну «чистую» функцию с понятным контрактом: парсер, сериализатор, нормализатор, расчёт.
Напишите два свойства: «не падает» + round-trip (или инвариант).
Прогоните — почти наверняка что-то найдётся; ужатый контрпример превратите в обычный регресс-тест.
Закоммитьте базу примеров Hypothesis в репозиторий, добавьте PBT-набор в CI отдельным шагом.
Дальше добавляйте свойства на оракул (при рефакторинге — старая vs новая реализация) и, когда созреете, stateful-модель на ключевую структуру данных.
Вывод. Property-based тестирование закрывает слепое пятно тестов-на-примерах: оно systematically бьёт по входам, до которых вы не додумались, и при падении даёт минимальный, читаемый контрпример. Стоит начать с одной функции и пары свойств «не падает + round-trip» — и вы удивитесь, что найдёте уже в первый день.
Пишу про практику QA каждый день в Telegram-канале «QA — Quality Assurance»: t.me/qa10100011000001. Если зашло — забегайте.
Комментарии (4)

myxo
23.06.2026 11:14Пользуясь случаем порекламирую либу для раста в более императивном стиле, нежели proptest (собственно её сделал автор rapid для go) - https://github.com/flyingmutant/chaos_theory
Ещё порекламирую свое выступление на golang conf на эту тему - https://www.youtube.com/watch?v=YiV6rLvBk78
Zenitchik
Раскройте магию
shrinkingПо каким критериям определяется, какой пример минимальнее, чем исходный?
earthlyman Автор
У фреймворка есть отношение попроще / поменьше (частичный порядок) = Он берёт упавший вход и пытается заменить его на меньший по этому порядку = если тот тоже воспроизводит падение, шаг принимается - если нет — откатывается.
Так до тех пор, пока ни один шаг уменьшения больше не падает (локальный минимум).
То есть критерий двойной = кандидат должен быть А меньше по порядку и Б всё ещё falsifying
----
= целые — ближе к нулю / меньше по модулю (1000 → 0, бинпоиском)
= коллекции — сначала меньше элементов (удаляем куски списка), потом упрощаем сами элементы
= строки — короче и «проще» символы (Unicode → ASCII → к канонической букве)
-----
Конкретно в Hypothesis тонкость в том, что он шринкает не само значение, а внутренний поток байтов (choice sequence), который генератор "потратил" на построение значения.
Минимизация идёт в shortlex-порядке: сначала более короткий буфер, при равной длине = лексикографически меньший. Генераторы написаны так, что "проще" значение потребляет "меньший" буфер, поэтому минимизация буфера даёт интуитивно минимальное значение. Применяется набор проходов (удаление интервалов, обнуление, понижение чисел бинпоиском) по кругу до фикс-точки
Zenitchik
Вот как раз это отношение и вызывает вопросы.
На наивный взгляд, оно зависит от предметной области, и общие правила вывести нельзя.
Если с коллекциями и строками - это, кажется, логичным, то почему целые ближе к нулю должны быть удобнее с точки зрения анализа ошибки?