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

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

@potapov_me

Платформа

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

Контент

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

Компания

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

Аккаунт

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

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

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Введение в Property-Based Testing
intro

Введение в Property-Based Testing

Философия PBT, почему unit tests недостаточны, базовые концепции hypothesis

Введение в Property-Based Testing

«Вместо того чтобы доказывать правильность каждого конкретного случая, докажите правильность общего свойства.»

#Почему unit tests недостаточны

Вы опытный разработчик. Вы пишете тесты. Ваш coverage выше 80%. Вы уверены в своём коде?

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

def merge_sort(lst: list[int]) -> list[int]: if len(lst) <= 1: return lst mid = len(lst) // 2 left = merge_sort(lst[:mid]) right = merge_sort(lst[mid:]) return merge(left, right) def merge(left: list[int], right: list[int]) -> list[int]: result = [] i = j = 0 while i < len(left) and j < len(right): if left[i] <= right[j]: result.append(left[i]) i += 1 else: result.append(right[j]) j += 1 result.extend(left[i:]) result.extend(right[j:]) return result

Как вы будете тестировать эту функцию? Типичный подход — unit tests:

def test_merge_sort_basic(): assert merge_sort([3, 1, 2]) == [1, 2, 3] assert merge_sort([]) == [] assert merge_sort([1]) == [1] assert merge_sort([5, 4, 3, 2, 1]) == [1, 2, 3, 4, 5]

Проблема: эти тесты проверяют только те случаи, о которых вы подумали. А что если:

merge_sort([0, float('inf'), -float('inf')]) # [0, -inf, inf] — неправильно! Должно быть [-inf, 0, inf] merge_sort([float('nan'), 1, 2]) # Поведение с NaN не определено — сравнение NaN всегда False merge_sort([1, 2] * 10000) # Рекурсия достигнет предела для больших списков

Unit tests не находят эти проблемы, потому что вы не написали тесты для этих случаев. Вы не знали, что они существуют.

#Философия Property-Based Testing

Вместо проверки конкретных значений PBT проверяет свойства, которые должны выполняться для всех входных данных.

#Что такое свойство (property)?

Свойство — это утверждение, которое должно быть истинным для всех допустимых входных данных.

Для функции сортировки можно сформулировать такие свойства:

  1. Сохранение длины: len(sorted(lst)) == len(lst) для любого lst
  2. Идемпотентность: sorted(sorted(lst)) == sorted(lst)
  3. Сохранение элементов: отсортированный список содержит те же элементы, что и исходный
  4. Упорядоченность: каждый элемент <= следующего

Запишем эти свойства на Python с Hypothesis:

from hypothesis import given, strategies as st import pytest @given(st.lists(st.integers())) def test_sort_preserves_length(lst): assert len(sorted(lst)) == len(lst) @given(st.lists(st.integers())) def test_sort_is_idempotent(lst): assert sorted(sorted(lst)) == sorted(lst) @given(st.lists(st.integers())) def test_sort_preserves_elements(lst): # Используем мультимножество для сравнения (учитывает дубликаты) assert sorted(lst) == pytest.approx(sorted(lst)) # Более строгая проверка: from collections import Counter assert Counter(sorted(lst)) == Counter(lst) @given(st.lists(st.integers(), min_size=2)) def test_sort_is_ordered(lst): result = sorted(lst) for i in range(len(result) - 1): assert result[i] <= result[i + 1]

#Что произойдёт при запуске?

Hypothesis сгенерирует сотни различных списков:

  • Пустой список: []
  • Один элемент: [0]
  • Отрицательные числа: [-1, -100, 0]
  • Границы типов: [-2147483648, 2147483647]
  • Большие списки: [0, 1, 2, ..., 99]

Если свойство нарушается, Hypothesis покажет минимальный failing case:

Falsifying example: test_sort_is_ordered(lst=[0, -1])

#Первый тест с Hypothesis: пошагово

#Шаг 1: Установка

pip install hypothesis # или poetry add --dev hypothesis

#Шаг 2: Базовый тест

Рассмотрим функцию для тестирования — валидатор email:

import re def is_valid_email(email: str) -> bool: pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' return bool(re.match(pattern, email))

Напишем property-based тесты:

from hypothesis import given, strategies as st from hypothesis import assume @given(st.text()) def test_valid_email_has_at_symbol(email): """Валидный email всегда содержит @""" if is_valid_email(email): assert '@' in email @given(st.text()) def test_valid_email_has_dot_after_at(email): """Валидный email имеет точку после @""" if is_valid_email(email): assume('@' in email) at_index = email.index('@') after_at = email[at_index + 1:] if is_valid_email(email): assert '.' in after_at @given(st.text()) def test_valid_email_no_spaces(email): """Валидный email не содержит пробелов""" if is_valid_email(email): assert ' ' not in email assert '\t' not in email assert '\n' not in email

#Шаг 3: Запуск

pytest test_email.py -v

Hypothesis автоматически:

  1. Сгенерирует 100 примеров (по умолчанию)
  2. Проверит каждое свойство
  3. При нахождении ошибки — сожмёт до минимального примера
  4. Сохранит failing case в .hypothesis/examples/

#Ключевые концепции PBT

#1. Генерация данных (Example Generation)

Hypothesis не просто генерирует случайные данные. Он использует умные эвристики:

  • Граничные значения: 0, 1, -1, None, пустые строки
  • Границы типов: sys.maxsize, -sys.maxsize - 1
  • Специальные значения: float('inf'), float('nan')
  • Странные кодировки: строки с Unicode, эмодзи, нуль-символы
# Hypothesis автоматически протестирует: st.text() → "", "0", "A", "\x00", "\U0001F600", "A" * 1000 st.integers() → 0, 1, -1, 2147483647, -2147483648 st.floats() → 0.0, -0.0, inf, -inf, nan, 1e-308, 1e308

#2. Сжатие (Shrinking)

Когда тест падает, Hypothesis не просто показывает failing case — он находит минимальный failing case.

Без shrinking:

Falsifying example: lst=[3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9, 3]

Со shrinking:

Falsifying example: lst=[0, -1]

Это происходит благодаря алгоритму сжатия, который:

  • Удаляет лишние элементы из коллекций
  • Уменьшает числа до 0, -1, 1
  • Укорачивает строки
  • Заменяет сложные значения на простые

#3. Фильтрация (Filtering)

Иногда нужно исключить определённые значения. Используйте assume():

from hypothesis import assume @given(st.integers(), st.integers()) def test_division(x, y): assume(y != 0) # Исключаем деление на ноль result = x / y assert isinstance(result, float)

Важно: assume() не вызывает провал теста — она отбрасывает текущий пример и генерирует новый.

#4. Стратегии (Strategies)

Стратегии описывают, как генерировать данные:

from hypothesis import strategies as st # Простые типы st.integers() st.floats() st.text() st.booleans() # Коллекции st.lists(st.integers()) st.sets(st.text()) st.dictionaries(st.text(), st.integers()) # Специальные st.none() st.just(42) # Всегда возвращает 42 st.one_of(st.integers(), st.text()) # Ограничения st.integers(min_value=0, max_value=100) st.text(min_size=1, max_size=10) st.lists(st.integers(), min_size=1) # Непустые списки

#Production-пример: тестирование бизнес-логики

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

from decimal import Decimal from datetime import date def calculate_discount( cart_total: Decimal, customer_age: int, is_first_purchase: bool, purchase_date: date ) -> Decimal: """ Рассчитывает скидку: - Базовая скидка: 5% - Для клиентов старше 65: +10% - Первая покупка: +15% - В день рождения: +20% - Максимальная скидка: 40% - Минимальная сумма заказа для скидок: 1000 руб. """ if cart_total < 1000: return Decimal('0') discount = Decimal('0.05') # Базовая if customer_age >= 65: discount += Decimal('0.10') if is_first_purchase: discount += Decimal('0.15') # Проверка дня рождения (упрощённо) if purchase_date.month == 12 and purchase_date.day == 31: discount += Decimal('0.20') return min(discount, Decimal('0.40'))

Напишем property-based тесты:

from hypothesis import given, strategies as st from hypothesis import assume from decimal import Decimal from datetime import date @given( st.decimals(min_value=0, max_value=100000), st.integers(min_value=0, max_value=120), st.booleans(), st.dates() ) def test_discount_never_negative(total, age, first_purchase, purchase_date): """Скидка никогда не отрицательна""" discount = calculate_discount(total, age, first_purchase, purchase_date) assert discount >= 0 @given( st.decimals(min_value=0, max_value=100000), st.integers(min_value=0, max_value=120), st.booleans(), st.dates() ) def test_discount_max_40_percent(total, age, first_purchase, purchase_date): """Скидка не превышает 40%""" discount = calculate_discount(total, age, first_purchase, purchase_date) assert discount <= Decimal('0.40') @given( st.decimals(min_value=0, max_value=999), # Меньше минимума st.integers(min_value=0, max_value=120), st.booleans(), st.dates() ) def test_no_discount_below_minimum(total, age, first_purchase, purchase_date): """Нет скидки при сумме меньше 1000""" discount = calculate_discount(total, age, first_purchase, purchase_date) assert discount == 0 @given( st.decimals(min_value=1000, max_value=100000), st.integers(min_value=0, max_value=120), st.booleans(), st.dates() ) def test_discount_proportional_to_total(total, age, first_purchase, purchase_date): """Скидка пропорциональна сумме заказа""" discount_rate = calculate_discount(total, age, first_purchase, purchase_date) discount_amount = total * discount_rate assert discount_amount <= total

#Что найдёт Hypothesis?

При запуске этих тестов Hypothesis может найти проблемы:

  1. Переполнение Decimal: при очень больших значениях
  2. Граничные даты: 29 февраля, високосные годы
  3. Отрицательные значения: если забыть ограничения
  4. Special floats: inf, nan если используются float вместо Decimal

#Почему PBT меняет игру

#Традиционные тесты

ПроблемаРешение в PBT
Вы придумываете тестовые случаиHypothesis генерирует сотни случаев
Вы не знаете, что упустилиHypothesis находит неочевидные краевые случаи
Тесты устаревают с изменением кодаСвойства остаются актуальными
Сложно тестировать граничные условияHypothesis автоматически тестирует границы
Ложное чувство безопасностиРеальная уверенность в корректности

#Когда использовать PBT

✅ Идеально для:

  • Функции преобразования данных (сортировка, фильтрация, парсинг)
  • Математические вычисления
  • Валидация и сериализация
  • Бизнес-логика с чёткими правилами
  • Инварианты структур данных

⚠️ Менее подходит для:

  • UI-тестирование
  • Интеграционные тесты с внешними сервисами
  • Тесты, требующие сложной настройки окружения

#Заключение

Property-Based Testing — это не замена unit tests, а мощное дополнение. Unit tests хороши для документирования ожидаемого поведения, а PBT — для нахождения багов, о которых вы не знали.

В следующих темах вы изучите:

  • Внутреннее устройство Hypothesis (как работает shrinker)
  • Все встроенные стратегии и их композицию
  • Создание кастомных стратегий для доменных типов
  • Stateful testing для систем с состоянием
  • Интеграцию с FastAPI, Django, SQLAlchemy

Ключевой вывод: перестаньте писать тесты на конкретные значения. Начните описывать свойства, которые должны выполняться всегда.


Следующая тема: Внутреннее устройство Hypothesis — узнайте, как работает shrinker и почему Hypothesis находит минимальные failing cases.

Далее: Внутреннее устройство Hypothesis