Написание кастомных стратегий, @composite, recursive стратегии
«Когда встроенных стратегий недостаточно, вы создаёте свои — это сила Hypothesis.»
Встроенные стратегии покрывают базовые типы, но в production вы работаете с доменными объектами:
# Вместо этого:
@given(st.integers(), st.text(), st.floats())
def test_user(user_id, name, balance):
user = User(user_id, name, balance) # Собираете объект вручную
...
# Лучше так:
@given(user_strategy())
def test_user(user):
... # Работаете с готовым доменным объектомПреимущества кастомных стратегий:
from hypothesis import strategies as st
from hypothesis import given
@st.composite
def positive_integers(draw):
"""Генерирует положительные целые числа"""
return draw(st.integers(min_value=1, max_value=1000000))
@given(positive_integers())
def test_positive(x):
assert x > 0Ключевые моменты:
@st.composite превращает функцию в стратегиюdraw — метод для генерации промежуточных значенийdraw() принимает стратегию и возвращает сгенерированное значение:
@st.composite
def email_address(draw):
"""Генерирует валидный email"""
domains = draw(st.sampled_from(['gmail.com', 'yahoo.com', 'example.org']))
username = draw(st.text(
alphabet=st.characters(whitelist_categories=('Lu', 'Ll', 'Nd', '_')),
min_size=1,
max_size=20
))
return f"{username}@{domains}"from datetime import date
@st.composite
def birth_date(draw):
"""Генерирует валидную дату рождения для человека 18-100 лет"""
today = date.today()
max_year = today.year - 18
min_year = today.year - 100
year = draw(st.integers(min_value=min_year, max_value=max_year))
month = draw(st.integers(min_value=1, max_value=12))
# Корректный день для месяца
import calendar
max_day = calendar.monthrange(year, month)[1]
day = draw(st.integers(min_value=1, max_value=max_day))
return date(year, month, day)Преимущество перед builds(): логика для вычисления максимального дня в месяце.
from decimal import Decimal
@st.composite
def monetary_amount(draw, min_value=Decimal('0.01'), max_value=Decimal('999999.99')):
"""
Генерирует денежную сумму с двумя знаками после точки.
Args:
min_value: Минимальная сумма
max_value: Максимальная сумма
"""
# Генерируем количество центов для точного представления
min_cents = int(min_value * 100)
max_cents = int(max_value * 100)
cents = draw(st.integers(min_value=min_cents, max_value=max_cents))
return Decimal(cents) / 100Почему не floats? Floats неточны для денег. Decimal с фиксированными знаками — правильный выбор.
import re
@st.composite
def phone_number(draw, country_code='+1'):
"""Генерирует валидный телефонный номер"""
# Генерируем цифры
digits = draw(st.lists(
st.integers(min_value=0, max_value=9),
min_size=10,
max_size=10
))
# Форматируем: (XXX) XXX-XXXX
area = ''.join(map(str, digits[:3]))
prefix = ''.join(map(str, digits[3:6]))
line = ''.join(map(str, digits[6:]))
return f"{country_code} ({area}) {prefix}-{line}"import uuid
@st.composite
def uuid_with_version(draw, version=4):
"""Генерирует UUID определённой версии"""
if version == 4:
# UUID v4 — случайный
return draw(st.uuids())
elif version == 1:
# UUID v1 — на основе времени
return uuid.uuid1()
else:
# Для других версий генерируем случайный и модифицируем
base = draw(st.uuids())
# Модификация для нужной версии (упрощённо)
return basefrom typing import Optional, Union
from dataclasses import dataclass
@dataclass
class TreeNode:
value: int
left: Optional['TreeNode'] = None
right: Optional['TreeNode'] = None
@st.composite
def binary_tree(draw, min_value=0, max_value=100, max_depth=5):
"""
Генерирует бинарное дерево.
Args:
min_value: Минимальное значение узла
max_value: Максимальное значение узла
max_depth: Максимальная глубина дерева
"""
# Решаем, будет ли узел листом (None)
if max_depth == 0 or draw(st.floats()) < 0.3:
return None
value = draw(st.integers(min_value=min_value, max_value=max_value))
left = draw(binary_tree(min_value, max_value, max_depth - 1))
right = draw(binary_tree(min_value, max_value, max_depth - 1))
return TreeNode(value=value, left=left, right=right)
# Использование
@given(binary_tree())
def test_tree_size(tree):
def count_nodes(node):
if node is None:
return 0
return 1 + count_nodes(node.left) + count_nodes(node.right)
if tree:
assert count_nodes(tree) >= 1@st.composite
def json_value(draw, max_depth=5):
"""Генерирует валидное JSON-значение"""
if max_depth == 0:
# Базовые значения на глубине 0
return draw(st.one_of(
st.none(),
st.booleans(),
st.integers(),
st.floats(allow_nan=False, allow_infinity=False),
st.text()
))
# Рекурсивно: массив или объект
if draw(st.booleans()):
# Массив
return draw(st.lists(json_value(max_depth=max_depth - 1), max_size=5))
else:
# Объект
return draw(st.dictionaries(
st.text(min_size=1, max_size=10),
json_value(max_depth=max_depth - 1),
max_size=5
))@st.composite
def user_with_role(draw, role=None):
"""
Генерирует пользователя с опциональной ролью.
Args:
role: Если указана, пользователь будет с этой ролью
"""
user_id = draw(st.integers(min_value=1, max_value=1000000))
email = draw(email_address())
if role is None:
role = draw(st.sampled_from(['user', 'admin', 'moderator']))
return {
'id': user_id,
'email': email,
'role': role,
'created_at': draw(st.datetimes())
}
# Использование:
@given(user_with_role()) # Случайная роль
def test_any_user(user):
...
@given(user_with_role(role='admin')) # Только админы
def test_admin_user(user):
assert user['role'] == 'admin'from typing import List, Generic, TypeVar
T = TypeVar('T')
@st.composite
def paginated_response(draw, item_strategy, page_size=10):
"""
Генерирует пагинированный ответ API.
Args:
item_strategy: Стратегия для элементов
page_size: Размер страницы
"""
page = draw(st.integers(min_value=1, max_value=100))
total_items = draw(st.integers(min_value=0, max_value=10000))
items = draw(st.lists(item_strategy, max_size=page_size))
return {
'page': page,
'page_size': page_size,
'total_items': total_items,
'items': items,
'has_next': page * page_size < total_items,
'has_prev': page > 1
}
# Использование
@given(paginated_response(st.integers()))
def test_pagination(response):
if response['page'] == 1:
assert not response['has_prev']Hypothesis позволяет регистрировать стратегии для пользовательских типов:
from hypothesis import register_type_strategy
from dataclasses import dataclass
@dataclass
class Money:
amount: Decimal
currency: str
def money_strategy():
return st.builds(
Money,
amount=st.decimals(min_value=0, max_value=1000000, places=2),
currency=st.sampled_from(['USD', 'EUR', 'RUB'])
)
# Регистрация
register_type_strategy(Money, money_strategy())
# Теперь Hypothesis автоматически генерирует Money для аннотаций
@given(st.builds(lambda m: m, st.from_type(Money)))
def test_money(m: Money):
assert m.amount >= 0from enum import Enum
from datetime import datetime, timedelta
from decimal import Decimal
from typing import List
from dataclasses import dataclass, field
class OrderStatus(Enum):
PENDING = 'pending'
PAID = 'paid'
SHIPPED = 'shipped'
DELIVERED = 'delivered'
CANCELLED = 'cancelled'
@dataclass
class OrderItem:
product_id: int
quantity: int
price: Decimal
@dataclass
class Order:
id: int
customer_id: int
items: List[OrderItem]
status: OrderStatus
created_at: datetime
updated_at: datetime
discount: Decimal = field(default_factory=lambda: Decimal('0'))
@st.composite
def order_item(draw):
return OrderItem(
product_id=draw(st.integers(min_value=1, max_value=10000)),
quantity=draw(st.integers(min_value=1, max_value=100)),
price=draw(st.decimals(min_value=Decimal('0.01'), max_value=Decimal('10000'), places=2))
)
@st.composite
def order(draw):
created_at = draw(st.datetimes(
min_value=datetime(2020, 1, 1),
max_value=datetime.now()
))
# updated_at >= created_at
delta = draw(st.timedeltas(min_value=timedelta(0), max_value=timedelta(days=365)))
updated_at = created_at + delta
items = draw(st.lists(order_item(), min_size=1, max_size=10))
# Статус с весами (реалистичное распределение)
status = draw(st.sampled_from([
OrderStatus.PENDING,
OrderStatus.PENDING, # Больше pending
OrderStatus.PAID,
OrderStatus.PAID, # Больше paid
OrderStatus.SHIPPED,
OrderStatus.DELIVERED,
OrderStatus.CANCELLED,
]))
discount = draw(st.decimals(min_value=Decimal('0'), max_value=Decimal('0.5'), places=2))
return Order(
id=draw(st.integers(min_value=1, max_value=1000000)),
customer_id=draw(st.integers(min_value=1, max_value=100000)),
items=items,
status=status,
created_at=created_at,
updated_at=updated_at,
discount=discount
)
# Тесты
@given(order())
def test_order_updated_after_created(order):
assert order.updated_at >= order.created_at
@given(order())
def test_order_discount_in_range(order):
assert Decimal('0') <= order.discount <= Decimal('0.5')
@given(order())
def test_order_items_positive(order):
for item in order.items:
assert item.quantity > 0
assert item.price > 0Кастомные стратегии — это мощный инструмент для:
Ключевые выводы:
@st.composite + draw() — основа кастомных стратегийСледующая тема: Stateful Testing — RuleBasedStateMachine, инварианты, тестирование сложных систем с состоянием.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.