Property-based тестирование: Hypothesis и fast-check на практике
Продвинутое3 минРедакция Innovialabs
Коротко
Вместо нескольких примеров вы описываете свойство, которое верно для любых входных данных, а Hypothesis или fast-check генерируют сотни случаев и сжимают найденную ошибку до минимального примера.
Свойства вместо примеров
Обычный тест проверяет пример: «для 2 и 3 сумма равна 5». Property-based тест проверяет утверждение, которое должно выполняться для любых входных данных: «результат сортировки упорядочен и содержит те же элементы».
Библиотека сама генерирует много входов — пустые списки, отрицательные числа, юникод, очень длинные строки — и ищет тот, на котором свойство нарушается. Это хорошо дополняет обычные тесты: примеры фиксируют известное поведение, свойства ищут то, о чём вы не подумали.
- Python — Hypothesis.
- JavaScript и TypeScript — fast-check.
Генераторы и shrinking
Генератор (в Hypothesis — strategy, в fast-check — arbitrary) описывает, какие данные создавать: st.integers(), st.lists(st.text()), fc.array(fc.integer()), fc.record({...}). Генераторы комбинируются, поэтому можно собрать объект заказа или пользователя.
Shrinking — главное удобство. Когда найдена ошибка на огромном случайном входе, библиотека упрощает его шаг за шагом, пока не получит минимальный пример, на котором ошибка всё ещё воспроизводится. Вместо списка из 80 случайных чисел вы видите [0, -1].
Какие свойства проверять
| Свойство | Пример |
|---|---|
| Round-trip | decode(encode(x)) == x, parse(format(x)) == x |
| Инвариант | после сортировки длина та же, элементы упорядочены |
| Идемпотентность | normalize(normalize(x)) == normalize(x) |
| Оракул | быстрая функция совпадает с простой медленной версией |
| Не падает | функция не бросает исключение на любом валидном входе |
Начинайте с round-trip и «не падает» — их проще всего сформулировать.
Hypothesis: деньги не делятся поровну
Функция делит сумму в тийинах на n частей:
from hypothesis import given, strategies as st
def split_amount(total, n):
part = round(total / n)
return [part] * n
@given(st.integers(min_value=0, max_value=10**9),
st.integers(min_value=1, max_value=100))
def test_parts_sum_to_total(total, n):
assert sum(split_amount(total, n)) == total
Примеры вроде (100, 4) проходят, но Hypothesis быстро находит нарушение и сжимает его до чего-то вроде total=1, n=2: части не складываются в исходную сумму. Исправление — распределить остаток:
def split_amount(total, n):
q, r = divmod(total, n)
return [q + 1] * r + [q] * (n - r)
fast-check: самодельная сериализация
Простая функция превращает объект в строку key=value&...:
import fc from "fast-check";
const stringify = (o: Record<string, string>) =>
Object.entries(o).map(([k, v]) => `${k}=${v}`).join("&");
fc.assert(
fc.property(fc.dictionary(fc.string(), fc.string()), (obj) => {
expect(parse(stringify(obj))).toEqual(obj);
})
);
fast-check находит ключ или значение со знаком & или =, после чего round-trip ломается. Ручной тест с { name: "Ali" } такую ошибку не показал бы. Исправление — экранировать значения, например через encodeURIComponent.
Как внедрять без боли
- Выберите чистые функции: парсеры, форматтеры, расчёты, преобразования данных.
- Сформулируйте одно свойство и ограничьте генератор реальным доменом (диапазоны, длины).
- Найденный контрпример сохраните как обычный пример-тест — это регрессия.
- Фиксируйте seed при падении в CI: обе библиотеки сообщают, как воспроизвести запуск.
- Не дублируйте реализацию в свойстве — иначе тест повторит ту же ошибку.
Типичные ошибки
- Слишком широкие генераторы, из-за которых большинство случаев отбрасывается фильтрами.
- Свойство, которое тривиально истинно и ничего не проверяет.
- Медленные тесты: тяжёлые операции с базой внутри каждой итерации.