Тесты на примерах проверяют те случаи, до которых вы додумались. 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, invariantclass 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. Если зашло — забегайте.
ссылка на оригинал статьи https://habr.com/ru/articles/1050850/