Философия PBT, почему unit tests недостаточны, базовые концепции hypothesis
«Вместо того чтобы доказывать правильность каждого конкретного случая, докажите правильность общего свойства.»
Вы опытный разработчик. Вы пишете тесты. Ваш 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 не находят эти проблемы, потому что вы не написали тесты для этих случаев. Вы не знали, что они существуют.
Вместо проверки конкретных значений PBT проверяет свойства, которые должны выполняться для всех входных данных.
Свойство — это утверждение, которое должно быть истинным для всех допустимых входных данных.
Для функции сортировки можно сформулировать такие свойства:
len(sorted(lst)) == len(lst) для любого lstsorted(sorted(lst)) == sorted(lst)Запишем эти свойства на 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])
pip install hypothesis
# или
poetry add --dev hypothesisРассмотрим функцию для тестирования — валидатор 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 emailpytest test_email.py -vHypothesis автоматически:
.hypothesis/examples/Hypothesis не просто генерирует случайные данные. Он использует умные эвристики:
sys.maxsize, -sys.maxsize - 1float('inf'), float('nan')# 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Когда тест падает, 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]
Это происходит благодаря алгоритму сжатия, который:
Иногда нужно исключить определённые значения. Используйте 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() не вызывает провал теста — она отбрасывает текущий пример и генерирует новый.
Стратегии описывают, как генерировать данные:
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) # Непустые спискиРассмотрим реальный кейс — функция расчёта скидки в интернет-магазине:
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 может найти проблемы:
| Проблема | Решение в PBT |
|---|---|
| Вы придумываете тестовые случаи | Hypothesis генерирует сотни случаев |
| Вы не знаете, что упустили | Hypothesis находит неочевидные краевые случаи |
| Тесты устаревают с изменением кода | Свойства остаются актуальными |
| Сложно тестировать граничные условия | Hypothesis автоматически тестирует границы |
| Ложное чувство безопасности | Реальная уверенность в корректности |
✅ Идеально для:
⚠️ Менее подходит для:
Property-Based Testing — это не замена unit tests, а мощное дополнение. Unit tests хороши для документирования ожидаемого поведения, а PBT — для нахождения багов, о которых вы не знали.
В следующих темах вы изучите:
Ключевой вывод: перестаньте писать тесты на конкретные значения. Начните описывать свойства, которые должны выполняться всегда.
Следующая тема: Внутреннее устройство Hypothesis — узнайте, как работает shrinker и почему Hypothesis находит минимальные failing cases.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.