Property-based тестирование: как находить баги, которые вы не придумали

от автора

Тесты на примерах проверяют те случаи, до которых вы додумались. 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): нормализация, slugifytrim, дедупликация, применение миграции дважды.

@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/