Как работает shrinker, пример minimization, фаза генерации и фаза сжатия
«Понимание того, как работает Hypothesis, сделает вас мастером property-based тестирования.»
Hypothesis состоит из нескольких слоёв:
┌─────────────────────────────────────────┐
│ Пользовательский код │
│ @given, стратегии, утверждения │
├─────────────────────────────────────────┤
│ Hypothesis Core API │
│ Декораторы, настройки, фазы │
├─────────────────────────────────────────┤
│ Conjecture Engine │
│ Генерация байт-кода, shrinker │
├─────────────────────────────────────────┤
│ Стратегии → Байты │
│ Преобразование стратегий в данные │
└─────────────────────────────────────────┘
Ключевой компонент — Conjecture, движок генерации данных на основе байт-кода.
Когда вы запускаете тест с @given, происходит следующее:
Hypothesis не генерирует значения напрямую. Вместо этого:
@given(st.lists(st.integers()))
def test_sort(lst):
...Внутреннее представление:
st.integers() → генерирует байты для целого числа (variable-length encoding)st.lists() → генерирует длину + байты для каждого элементаПочему байты? Это позволяет shrinker'у работать на низком уровне, манипулируя отдельными битами для минимизации failing case.
Conjecture не просто генерирует случайные байты. Он использует эвристики:
# Для st.integers() Hypothesis автоматически попробует:
0, 1, -1, # Частые источники багов
2147483647, -2147483648, # Границы 32-бит
2**63 - 1, -2**63, # Границы 64-бит
100, 50, 25, # Степени двойки и их половины
# Для st.text():
"", "0", "A", # Пустая, один символ
"\x00", "\n", "\t", # Специальные символы
"\U0001F600", # Unicode (эмодзи)
"A" * 100, # Длинные строкиНачиная с версии 5.0, Hypothesis отслеживает покрытие кода:
def parse_number(s: str) -> int | None:
if not s:
return None
if s.startswith('-'):
# Ветка для отрицательных чисел
...
if '.' in s:
# Ветка для чисел с точкой
...
return int(s)Hypothesis:
Результат: Hypothesis автоматически находит входные данные, которые покрывают все ветки кода, включая редкие corner cases.
Для каждого сгенерированного примера:
@given(st.integers(), st.integers())
def test_division(x, y):
assume(y != 0)
result = x / y
assert isinstance(result, float)assume(y != 0) → Falseassert isinstance(result, float) → TrueHypothesis ведёт статистику:
test_division:
- Generated 150 examples
- Invalid examples: 3 (из-за assume)
- Satisfied examples: 100
- Falsifying examples: 1
Настройки контролируют генерацию:
from hypothesis import settings, Phase
@settings(
max_examples=200, # Целевое количество примеров
deadline=None, # Отключить таймаут
phases=[Phase.generate, Phase.shrink], # Фазы
suppress_health_check=[HealthCheck.too_slow],
)
@given(st.integers(), st.integers())
def test_division(x, y):
...Когда утверждение нарушается:
@given(st.lists(st.integers()))
def test_sort_preserves_first_element(lst):
assume(len(lst) > 0)
sorted_lst = sorted(lst)
assert sorted_lst[0] == lst[0] # ЛОЖНОЕ свойство![3, 1, 2]sorted_lst = [1, 2, 3]assert sorted_lst[0] == lst[0] → assert 1 == 3 → AssertionErrorЭто самая впечатляющая часть Hypothesis.
Hypothesis пытается упростить failing case, сохраняя ошибку:
Исходный failing case: [3, 1, 2, 5, 8, 13, 21]
Шаг 1: Удаление элементов
Пробуем: [1, 2, 5, 8, 13, 21] # Удалили первый
Ошибка воспроизводится? Нет → Откат
Пробуем: [3, 2, 5, 8, 13, 21] # Удалили второй
Ошибка воспроизводится? Да → Продолжаем
Пробуем: [3, 5, 8, 13, 21] # Удалили третий
Ошибка воспроизводится? Нет → Откат
Шаг 2: Уменьшение чисел
Пробуем: [0, 2, 5, 8, 13, 21] # 3 → 0
Ошибка воспроизводится? Да → Продолжаем
Пробуем: [0, 0, 5, 8, 13, 21] # 2 → 0
Ошибка воспроизводится? Нет → Откат
Пробуем: [0, 1, 5, 8, 13, 21] # 2 → 1
Ошибка воспроизводится? Да → Продолжаем
Шаг 3: Удаление хвоста
Пробуем: [0, 1] # Оставили только первые два
Ошибка воспроизводится? Да → Это минимальный пример!
Итог: Вместо [3, 1, 2, 5, 8, 13, 21] вы видите [0, 1]
Минимальный failing case:
Hypothesis использует несколько техник:
| Техника | Пример |
|---|---|
| Удаление | [1, 2, 3] → [1, 2] |
| Уменьшение чисел | 100 → 0, -1, 1 |
| Укорочение строк | "hello" → "", "h" |
| Замена на None | "text" → None |
| Нормализация | float('nan') → 0.0 |
После нахождения failing case:
# Falsifying example: lst=[0, 1]Hypothesis сохраняет его в .hypothesis/examples/:
.hypothesis/
└── examples/
├── 1a2b3c4d5e6f # Хэш от теста
└── 7g8h9i0j1k2l # Хэш от другого теста
При повторном запуске:
Преимущества:
Рассмотрим st.lists(st.integers(), min_size=1, max_size=5):
Байты от Conjecture: [03] [00 01] [FF FF] [00 02]
│ │ │ │
│ │ │ └─ Третий элемент: 2
│ │ └────────── Второй элемент: -1 (two's complement)
│ └────────────────── Первый элемент: 1
└─────────────────────── Длина списка: 3
Hypothesis использует variable-length encoding:
st.tuples(
st.integers(min_value=0, max_value=100),
st.text(min_size=1, max_size=10),
st.booleans()
)Внутреннее представление:
Байты: [42] [05] [H e l l o] [01]
│ │ │ │
│ │ │ └─ Boolean: True
│ │ └────────────── String: "Hello" (длина=5)
│ └─────────────────── Длина строки
└──────────────────────── Integer: 42
Рассмотрим реальную задачу — функция для расчёта средней оценки:
from typing import List
def calculate_average(grades: List[int]) -> float:
if not grades:
return 0.0
# Нормализация оценок (шкала 1-10)
normalized = [max(1, min(10, g)) for g in grades]
# Исключаем выбросы (оценки вне 2σ)
mean = sum(normalized) / len(normalized)
variance = sum((g - mean) ** 2 for g in normalized) / len(normalized)
std_dev = variance ** 0.5
filtered = [g for g in normalized if abs(g - mean) <= 2 * std_dev]
if not filtered:
return mean
return sum(filtered) / len(filtered)Напишем тест:
from hypothesis import given, strategies as st
@given(st.lists(st.integers(), min_size=1))
def test_average_within_bounds(grades):
avg = calculate_average(grades)
assert 1 <= avg <= 10, f"Average {avg} out of bounds for {grades}"Запуск:
Falsifying example: test_average_within_bounds(grades=[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 10])
AssertionError: Average 1.0 out of bounds
Анализ:
1 и одна 10(10*1 + 10) / 11 = 20/11 ≈ 1.82[1, 1, ..., 1]1.0 — это валидно!Проблема в тесте: утверждение 1 <= avg <= 10 выполняется для 1.0.
Уточним тест:
@given(st.lists(st.integers(), min_size=1))
def test_average_within_input_range(grades):
"""Среднее должно быть в диапазоне входных значений"""
assume(len(set(grades)) > 1) # Хотя бы 2 разных значения
avg = calculate_average(grades)
assert min(grades) <= avg <= max(grades), \
f"Average {avg} not in [{min(grades)}, {max(grades)}]"Новый failing case:
Falsifying example: grades=[1, 10]
AssertionError: Average 1.0 not in [1, 10]
Нашли баг!:
grades = [1, 10]mean = 5.5, std_dev = 4.52 * std_dev = 9abs(1 - 5.5) = 4.5 <= 9 ✓abs(10 - 5.5) = 4.5 <= 9 ✓filtered = [1, 10]avg = 5.5Подождите, это должно работать... Проблема в другом.
Истинный баг: при определённых условиях filtered может стать пустым после фильтрации, и функция возвращает mean до фильтрации, что может быть вне диапазона.
Можно управлять фазами через settings:
from hypothesis import settings, Phase
# Только генерация, без shrinking (для скорости)
@settings(phases=[Phase.generate])
@given(st.lists(st.integers()))
def test_fast(lst):
...
# Только shrinking существующих примеров
@settings(phases=[Phase.shrink])
@given(st.lists(st.integers()))
def test_shrink_only(lst):
...
# Генерация + shrinking (по умолчанию)
@settings(phases=[Phase.generate, Phase.shrink])
@given(st.lists(st.integers()))
def test_default(lst):
...
# Расширенная генерация (target-based)
@settings(phases=[Phase.generate, Phase.shrink, Phase.target])
@given(st.lists(st.integers()))
def test_with_target(lst):
...Hypothesis может оптимизировать генерацию для нахождения edge cases:
from hypothesis import target
@given(st.lists(st.integers()))
def test_large_lists_stable(lst):
# Сообщаем Hypothesis, что нас интересуют большие списки
target(len(lst), label='list_size')
result = sorted(lst)
assert len(result) == len(lst)target() подсказывает Hypothesis, какие примеры «интересны». Движок будет генерировать больше примеров с большим list_size.
Понимание внутреннего устройства Hypothesis делает вас эффективнее:
В следующей теме вы изучите все встроенные стратегии и научитесь их комбинировать для генерации сложных структур данных.
Следующая тема: Глубокое погружение в стратегии — все встроенные стратегии, композиция, фильтрация, mapped strategies.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.