Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Внутреннее устройство Hypothesis
core_mechanics

Внутреннее устройство Hypothesis

Как работает shrinker, пример minimization, фаза генерации и фаза сжатия

Внутреннее устройство Hypothesis

«Понимание того, как работает Hypothesis, сделает вас мастером property-based тестирования.»

#Архитектура Hypothesis: обзор

Hypothesis состоит из нескольких слоёв:

┌─────────────────────────────────────────┐
│         Пользовательский код            │
│    @given, стратегии, утверждения       │
├─────────────────────────────────────────┤
│         Hypothesis Core API             │
│    Декораторы, настройки, фазы          │
├─────────────────────────────────────────┤
│         Conjecture Engine               │
│    Генерация байт-кода, shrinker        │
├─────────────────────────────────────────┤
│         Стратегии → Байты               │
│    Преобразование стратегий в данные    │
└─────────────────────────────────────────┘

Ключевой компонент — Conjecture, движок генерации данных на основе байт-кода.

#Фаза 1: Генерация примеров (Example Generation)

Когда вы запускаете тест с @given, происходит следующее:

#Шаг 1.1: Преобразование стратегий в генераторы байтов

Hypothesis не генерирует значения напрямую. Вместо этого:

  1. Каждая стратегия компилируется в генератор байтов
  2. Conjecture генерирует последовательности байтов
  3. Байты интерпретируются стратегиями для создания значений
@given(st.lists(st.integers())) def test_sort(lst): ...

Внутреннее представление:

  • st.integers() → генерирует байты для целого числа (variable-length encoding)
  • st.lists() → генерирует длину + байты для каждого элемента

Почему байты? Это позволяет shrinker'у работать на низком уровне, манипулируя отдельными битами для минимизации failing case.

#Шаг 1.2: Умная генерация с эвристиками

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, # Длинные строки

#Шаг 1.3: Покрытие кода (Coverage-Guided Generation)

Начиная с версии 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:

  1. Запускает тест с разными входными данными
  2. Отслеживает, какие ветки кода были выполнены
  3. Адаптивно генерирует новые данные для исследования новых веток

Результат: Hypothesis автоматически находит входные данные, которые покрывают все ветки кода, включая редкие corner cases.

#Фаза 2: Выполнение теста

Для каждого сгенерированного примера:

@given(st.integers(), st.integers()) def test_division(x, y): assume(y != 0) result = x / y assert isinstance(result, float)
  1. Conjecture генерирует байты → (x=42, y=0)
  2. Выполняется assume(y != 0) → False
  3. Пример отбрасывается, генерируется новый
  4. Новый пример: (x=42, y=2)
  5. Выполняется assert isinstance(result, float) → True
  6. Пример засчитывается

#Статистика выполнения

Hypothesis ведёт статистику:

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): ...

#Фаза 3: Нахождение failing case

Когда утверждение нарушается:

@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] # ЛОЖНОЕ свойство!
  1. Hypothesis генерирует: [3, 1, 2]
  2. sorted_lst = [1, 2, 3]
  3. assert sorted_lst[0] == lst[0] → assert 1 == 3 → AssertionError
  4. Переход к фазе shrinking

#Фаза 4: Shrinking (Сжатие)

Это самая впечатляющая часть Hypothesis.

#Алгоритм shrinking

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:

  • Легче отлаживать: сразу видна суть проблемы
  • Быстрее воспроизводится: не нужно генерировать большие данные
  • Лучше для регрессии: минимальный тест сохраняется в базу

#Стратегии shrinking

Hypothesis использует несколько техник:

ТехникаПример
Удаление[1, 2, 3] → [1, 2]
Уменьшение чисел100 → 0, -1, 1
Укорочение строк"hello" → "", "h"
Замена на None"text" → None
Нормализацияfloat('nan') → 0.0

#Фаза 5: Сохранение в базу

После нахождения failing case:

# Falsifying example: lst=[0, 1]

Hypothesis сохраняет его в .hypothesis/examples/:

.hypothesis/
└── examples/
    ├── 1a2b3c4d5e6f  # Хэш от теста
    └── 7g8h9i0j1k2l  # Хэш от другого теста

При повторном запуске:

  1. Hypothesis загружает сохранённые примеры
  2. Сначала проверяет их
  3. Если ошибка воспроизводится — сразу показывает failing case
  4. Затем продолжает генерацию новых примеров

Преимущества:

  • Ускорение отладки: не нужно ждать генерации
  • Детерминизм: тесты стабильны между запусками
  • CI/CD интеграция: можно коммитить failing cases

#Внутреннее представление стратегий

#Как стратегии генерируют данные

Рассмотрим 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:

  • Малые числа кодируются 1-2 байтами
  • Большие числа используют больше байтов
  • Это помогает shrinker'у уменьшать числа до 0

#Композиция стратегий

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

#Пример: отладка сложного failing case

Рассмотрим реальную задачу — функция для расчёта средней оценки:

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

Анализ:

  • 10 оценок 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.5
  • 2 * std_dev = 9
  • abs(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): ...

#Target-based тестирование

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 делает вас эффективнее:

  1. Генерация байтов объясняет, почему shrinking так эффективен
  2. Покрытие кода показывает, как Hypothesis находит редкие кейсы
  3. Алгоритм shrinking помогает писать тесты, которые легче отлаживать
  4. База примеров ускоряет CI/CD и отладку

В следующей теме вы изучите все встроенные стратегии и научитесь их комбинировать для генерации сложных структур данных.


Следующая тема: Глубокое погружение в стратегии — все встроенные стратегии, композиция, фильтрация, mapped strategies.

Далее: Глубокое погружение в стратегии