Hypothesis – генеративное тестирование

онлайн тренажер по питону
Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение

Обычные тесты проверяют, что функция работает при заданных значениях. Но что, если вы не знаете заранее, какие входные данные приведут к ошибке? Hypothesis – генеративное тестирование решает эту проблему автоматически.

Hypothesis — это мощная библиотека property-based testing для Python, которая кардинально меняет подход к тестированию. Вместо ручного создания тестовых данных, она автоматически генерирует тысячи различных входных значений, стремясь найти крайние, ошибочные или нестандартные случаи. Вместо написания множества тестов с конкретными значениями, вы описываете свойства и инварианты функции, а Hypothesis пытается их нарушить.

Что такое Hypothesis

Hypothesis представляет собой инструмент генеративного тестирования, основанный на концепции property-based testing. Эта библиотека была создана для решения фундаментальной проблемы традиционного тестирования: невозможности предусмотреть все возможные входные данные.

Основные принципы работы

Библиотека работает по принципу "найти контрпример". Когда вы определяете свойство функции (например, что результат сложения двух чисел всегда больше каждого из слагаемых), Hypothesis генерирует множество различных входных данных и проверяет, выполняется ли это свойство для каждого набора.

Преимущества перед традиционным тестированием

  • Автоматическое покрытие граничных случаев: Hypothesis самостоятельно находит редкие и сложные комбинации данных
  • Минимизация ошибок: При обнаружении падения тест автоматически упрощается до минимального примера
  • Высокая производительность: Генерация тысяч тестовых случаев происходит быстро
  • Воспроизводимость: Найденные ошибки можно легко воспроизвести

Установка и первые шаги

Установка библиотеки

pip install hypothesis

Для дополнительных возможностей можно установить с расширениями:

pip install hypothesis[numpy,pandas,django]

Базовый пример использования

from hypothesis import given
import hypothesis.strategies as st

@given(st.integers(), st.integers())
def test_addition_commutative(a, b):
    assert a + b == b + a

@given(st.integers(), st.integers())
def test_addition_associative(a, b, c):
    assert (a + b) + c == a + (b + c)

Запуск тестов

Тесты можно запускать через pytest, unittest или напрямую:

pytest test_file.py
python -m unittest test_file.py
python test_file.py

Основы генерации данных

Декоратор @given

Декоратор @given() является центральным элементом Hypothesis. Он указывает, какие стратегии генерации использовать для создания тестовых данных.

@given(st.integers())
def test_absolute_value(x):
    assert abs(x) >= 0

@given(st.text(), st.integers(min_value=0))
def test_string_multiplication(s, n):
    result = s * n
    assert len(result) == len(s) * n

Модуль hypothesis.strategies

Все стратегии генерации данных находятся в модуле hypothesis.strategies, который обычно импортируется как st. Этот модуль предоставляет богатый набор предопределенных стратегий для различных типов данных.

Стратегии генерации данных

Базовые стратегии

Hypothesis предоставляет обширный набор встроенных стратегий для генерации различных типов данных:

# Числовые типы
st.integers()  # Целые числа
st.floats()    # Числа с плавающей точкой
st.decimals()  # Десятичные числа

# Текстовые данные
st.text()      # Unicode строки
st.binary()    # Байтовые строки
st.characters() # Отдельные символы

# Логические и временные данные
st.booleans()  # True/False
st.dates()     # Объекты datetime.date
st.datetimes() # Объекты datetime.datetime
st.times()     # Объекты datetime.time

Коллекции и структуры данных

# Списки и кортежи
st.lists(st.integers())  # Списки целых чисел
st.tuples(st.text(), st.integers())  # Кортежи из строки и числа

# Словари и множества
st.dictionaries(st.text(), st.integers())  # Словари
st.sets(st.integers())  # Множества

# Фиксированные значения
st.just(42)  # Всегда возвращает 42
st.sampled_from(['a', 'b', 'c'])  # Случайный элемент из списка

Специализированные стратегии

# Интернет и данные
st.emails()    # Валидные email адреса
st.urls()      # URL адреса
st.ip_addresses()  # IP адреса
st.uuids()     # UUID идентификаторы

# Научные данные (требует numpy)
st.arrays(dtype=np.int32, shape=(3, 3))  # NumPy массивы

Таблица основных методов и функций

Категория Метод/Функция Описание Пример использования
Декораторы @given() Основной декоратор для генеративного тестирования @given(st.integers())
  @example() Добавляет конкретный пример к тесту @example(0)
  @settings() Настройка параметров выполнения @settings(max_examples=1000)
Базовые стратегии st.integers() Генерация целых чисел st.integers(min_value=0, max_value=100)
  st.floats() Генерация чисел с плавающей точкой st.floats(allow_nan=False)
  st.text() Генерация Unicode строк st.text(min_size=1, max_size=10)
  st.booleans() Генерация True/False st.booleans()
Коллекции st.lists() Генерация списков st.lists(st.integers(), min_size=1)
  st.dictionaries() Генерация словарей st.dictionaries(st.text(), st.integers())
  st.tuples() Генерация кортежей st.tuples(st.text(), st.integers())
  st.sets() Генерация множеств st.sets(st.integers())
Комбинаторы st.one_of() Выбор одной из стратегий st.one_of(st.integers(), st.text())
  st.sampled_from() Выбор из списка значений st.sampled_from(['a', 'b', 'c'])
  st.just() Фиксированное значение st.just(42)
Модификаторы .filter() Фильтрация значений st.integers().filter(lambda x: x > 0)
  .map() Преобразование значений st.integers().map(abs)
  .flatmap() Динамическое создание стратегий st.integers().flatmap(lambda x: st.lists(st.integers(), max_size=x))
Специализированные st.emails() Генерация email адресов st.emails()
  st.urls() Генерация URL st.urls()
  st.dates() Генерация дат st.dates(min_value=date(2020, 1, 1))
  st.datetimes() Генерация datetime объектов st.datetimes(timezones=st.timezones())
Утилиты assume() Условная фильтрация assume(x != 0)
  note() Добавление отладочной информации note(f"Testing with x={x}")
  event() Сбор статистики event(f"x > 100: {x > 100}")
Композитные @st.composite Создание пользовательских стратегий @st.composite def custom_strategy(draw): ...
  st.builds() Создание объектов st.builds(MyClass, arg1=st.integers())

Проверка свойств и инвариантов

Что такое property-based testing

Property-based testing фокусируется на проверке общих свойств и инвариантов функций, а не на конкретных входных и выходных значениях. Это позволяет найти ошибки, которые невозможно обнаружить традиционными методами.

Примеры проверки свойств

@given(st.text())
def test_string_properties(s):
    # Свойство: длина строки после upper() не изменяется
    assert len(s) == len(s.upper())
    # Свойство: двойное применение upper() не изменяет результат
    assert s.upper().upper() == s.upper()

@given(st.lists(st.integers()))
def test_list_properties(lst):
    # Свойство: длина списка после сортировки не изменяется
    sorted_lst = sorted(lst)
    assert len(lst) == len(sorted_lst)
    # Свойство: все элементы сохраняются
    assert sorted(lst) == sorted_lst

@given(st.integers(), st.integers())
def test_arithmetic_properties(a, b):
    # Коммутативность сложения
    assert a + b == b + a
    # Ассоциативность умножения с единицей
    assert a * 1 == a

Минимизация ошибок (Shrinking)

Принцип работы

Когда Hypothesis обнаруживает падение теста, она автоматически запускает процесс минимизации (shrinking). Цель этого процесса — найти наименьший возможный пример, который все еще вызывает ошибку.

Пример минимизации

@given(st.integers())
def test_fails_on_zero(x):
    assert x != 0

При запуске этого теста Hypothesis найдет, что он падает на значении 0, и выведет:

Falsifying example: test_fails_on_zero(x=0)

Процесс минимизации

  1. Обнаружение ошибки: Hypothesis находит значение, вызывающее падение
  2. Упрощение: Алгоритм пытается найти более простые значения
  3. Проверка: Каждое упрощенное значение проверяется на воспроизведение ошибки
  4. Результат: Выводится минимальный пример, вызывающий ошибку

Расширенные стратегии

Ограничение значений

# Числа в диапазоне
@given(st.integers(min_value=1, max_value=100))
def test_positive_numbers(x):
    assert x > 0

# Строки с ограничениями
@given(st.text(min_size=1, max_size=10, alphabet=st.characters(whitelist_categories=('Lu', 'Ll'))))
def test_alphabetic_strings(s):
    assert s.isalpha()

# Списки с ограничениями
@given(st.lists(st.integers(), min_size=1, max_size=5))
def test_small_lists(lst):
    assert 1 <= len(lst) <= 5

Комбинирование стратегий

# Использование one_of для выбора между стратегиями
@given(st.one_of(st.integers(), st.text()))
def test_mixed_types(value):
    assert isinstance(value, (int, str))

# Создание сложных структур
user_strategy = st.dictionaries(
    keys=st.sampled_from(['name', 'age', 'email']),
    values=st.one_of(st.text(), st.integers(min_value=0, max_value=120), st.emails())
)

@given(user_strategy)
def test_user_data(user):
    assert isinstance(user, dict)

Пользовательские стратегии

Создание композитных стратегий

@st.composite
def user_data(draw):
    name = draw(st.text(min_size=1, max_size=50))
    age = draw(st.integers(min_value=0, max_value=120))
    email = draw(st.emails())
    return {
        "name": name,
        "age": age,
        "email": email,
        "is_adult": age >= 18
    }

@given(user_data())
def test_user_validation(user):
    assert "name" in user
    assert user["is_adult"] == (user["age"] >= 18)

Использование builds для создания объектов

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int
    email: str

person_strategy = st.builds(
    Person,
    name=st.text(min_size=1),
    age=st.integers(min_value=0, max_value=120),
    email=st.emails()
)

@given(person_strategy)
def test_person_creation(person):
    assert isinstance(person, Person)
    assert person.age >= 0

Фильтрация и допущения

Использование filter()

@given(st.integers().filter(lambda x: x > 0))
def test_positive_only(x):
    assert x > 0

@given(st.text().filter(lambda s: len(s) > 0))
def test_non_empty_strings(s):
    assert len(s) > 0

Использование assume()

from hypothesis import assume

@given(st.integers(), st.integers())
def test_division(a, b):
    assume(b != 0)
    result = a / b
    assert result * b == a  # Может не сработать из-за точности float

@given(st.lists(st.integers()))
def test_list_operations(lst):
    assume(len(lst) > 0)
    first_element = lst[0]
    assert first_element in lst

Различия между filter() и assume()

  • filter(): Применяется на уровне стратегии, отфильтрованные значения не генерируются
  • assume(): Применяется во время выполнения теста, неподходящие примеры пропускаются

Параметризация и множественные сценарии

Использование @example()

@given(st.integers())
@example(0)  # Всегда проверяет ноль
@example(-1) # Всегда проверяет -1
@example(1000000)  # Всегда проверяет большое число
def test_with_specific_examples(x):
    assert abs(x) >= 0

Комбинирование с pytest.mark.parametrize

import pytest

@pytest.mark.parametrize("base", [2, 8, 10, 16])
@given(st.integers(min_value=0, max_value=1000))
def test_number_conversion(base, number):
    # Конвертация числа в различные системы счисления
    converted = format(number, f'0{base}')
    assert int(converted, base) == number

Настройка выполнения тестов

Декоратор @settings()

from hypothesis import settings

@settings(max_examples=1000, deadline=None)
@given(st.integers())
def test_with_more_examples(x):
    # Выполнится 1000 раз вместо стандартных 100
    assert isinstance(x, int)

@settings(verbosity=2)
@given(st.text())
def test_with_verbose_output(s):
    assert isinstance(s, str)

Профили настроек

from hypothesis import settings, Verbosity

# Создание профиля
settings.register_profile("debug", max_examples=10, verbosity=Verbosity.verbose)
settings.register_profile("ci", max_examples=1000, deadline=None)

# Использование профиля
settings.load_profile("debug")

Интеграция с фреймворками тестирования

Интеграция с pytest

Hypothesis прекрасно интегрируется с pytest без дополнительной настройки:

import pytest
from hypothesis import given, settings
import hypothesis.strategies as st

class TestMathOperations:
    @given(st.integers(), st.integers())
    def test_addition(self, a, b):
        assert a + b == b + a
    
    @pytest.mark.slow
    @settings(max_examples=10000)
    @given(st.floats(), st.floats())
    def test_multiplication(self, a, b):
        assert a * b == b * a

Интеграция с unittest

import unittest
from hypothesis import given
import hypothesis.strategies as st

class TestStringOperations(unittest.TestCase):
    @given(st.text())
    def test_string_length(self, s):
        self.assertGreaterEqual(len(s), 0)
    
    @given(st.text(), st.text())
    def test_string_concatenation(self, s1, s2):
        result = s1 + s2
        self.assertEqual(len(result), len(s1) + len(s2))
        self.assertTrue(result.startswith(s1))
        self.assertTrue(result.endswith(s2))

Тестирование API и бизнес-логики

Тестирование REST API

@st.composite
def api_request_data(draw):
    return {
        "user_id": draw(st.integers(min_value=1)),
        "action": draw(st.sampled_from(["create", "update", "delete"])),
        "data": draw(st.dictionaries(
            keys=st.text(min_size=1, max_size=20),
            values=st.one_of(st.text(), st.integers(), st.booleans())
        ))
    }

@given(api_request_data())
def test_api_request_validation(request_data):
    # Проверка валидации входных данных API
    assert "user_id" in request_data
    assert request_data["user_id"] > 0
    assert request_data["action"] in ["create", "update", "delete"]

Тестирование бизнес-логики

@st.composite
def order_data(draw):
    items = draw(st.lists(
        st.dictionaries(
            keys=st.just("price"),
            values=st.floats(min_value=0.01, max_value=1000.0)
        ),
        min_size=1,
        max_size=10
    ))
    discount = draw(st.floats(min_value=0.0, max_value=0.5))
    return {"items": items, "discount": discount}

@given(order_data())
def test_order_total_calculation(order):
    # Тестирование расчета общей стоимости заказа
    total_before_discount = sum(item["price"] for item in order["items"])
    expected_total = total_before_discount * (1 - order["discount"])
    calculated_total = calculate_order_total(order)
    assert abs(calculated_total - expected_total) < 0.01

Генерация пользовательских типов и моделей

Работа с dataclasses

from dataclasses import dataclass
from typing import List, Optional

@dataclass
class Product:
    name: str
    price: float
    category: str
    in_stock: bool

@dataclass
class Customer:
    name: str
    email: str
    age: int
    orders: List[Product]

product_strategy = st.builds(
    Product,
    name=st.text(min_size=1, max_size=50),
    price=st.floats(min_value=0.01, max_value=10000),
    category=st.sampled_from(["electronics", "clothing", "books", "food"]),
    in_stock=st.booleans()
)

customer_strategy = st.builds(
    Customer,
    name=st.text(min_size=1, max_size=50),
    email=st.emails(),
    age=st.integers(min_value=18, max_value=100),
    orders=st.lists(product_strategy, max_size=10)
)

@given(customer_strategy)
def test_customer_validation(customer):
    assert customer.age >= 18
    assert "@" in customer.email
    assert all(isinstance(order, Product) for order in customer.orders)

Работа с Pydantic моделями

from pydantic import BaseModel, EmailStr, validator
from typing import List

class User(BaseModel):
    name: str
    email: EmailStr
    age: int
    is_active: bool = True
    
    @validator('age')
    def validate_age(cls, v):
        if v < 0:
            raise ValueError('Age must be non-negative')
        return v

user_strategy = st.builds(
    User,
    name=st.text(min_size=1, max_size=50),
    email=st.emails(),
    age=st.integers(min_value=0, max_value=120),
    is_active=st.booleans()
)

@given(user_strategy)
def test_user_model_validation(user):
    assert isinstance(user, User)
    assert user.age >= 0
    assert "@" in user.email

Контроль повторяемости и отладка

Управление случайностью

from hypothesis import seed

@seed(12345)
@given(st.integers())
def test_with_fixed_seed(x):
    # Этот тест всегда будет генерировать одну и ту же последовательность
    assert isinstance(x, int)

Отладка и статистика

from hypothesis import note, event

@given(st.integers())
def test_with_debugging(x):
    note(f"Testing with value: {x}")
    event(f"Sign: {'positive' if x > 0 else 'negative' if x < 0 else 'zero'}")
    
    if x > 1000:
        event("Large number")
    
    assert abs(x) >= 0

Сохранение примеров

Hypothesis автоматически сохраняет падающие примеры в файл .hypothesis/examples для воспроизведения. Это поведение можно настроить:

@settings(database=None)  # Отключить сохранение примеров
@given(st.integers())
def test_without_database(x):
    assert x >= 0

Продвинутые техники

Использование flatmap для динамических стратегий

@given(st.integers(min_value=0, max_value=10).flatmap(
    lambda n: st.lists(st.integers(), min_size=n, max_size=n)
))
def test_list_with_dynamic_size(lst):
    # Размер списка определяется динамически
    assert len(lst) <= 10

Статистическое тестирование

from hypothesis import target

@given(st.lists(st.integers()))
def test_sorting_performance(lst):
    import time
    start = time.time()
    sorted_lst = sorted(lst)
    end = time.time()
    
    # Hypothesis будет стремиться максимизировать время выполнения
    target(end - start)
    
    assert sorted_lst == sorted(lst)

Сравнение с другими инструментами

Инструмент Генерация данных Авто-поиск граничных Property-based Интеграция с pytest Минимизация ошибок
Hypothesis ✅ Отличная ✅ Да ✅ Да ✅ Отличная ✅ Да
Pytest ⚠️ Частично ❌ Нет ❌ Нет ✅ Да ❌ Нет
Unittest ❌ Нет ❌ Нет ❌ Нет ⚠️ Частично ❌ Нет
Fuzzing (AFL) ✅ Да ⚠️ Частично ❌ Нет ❌ Сложно ❌ Нет
QuickCheck ✅ Да ✅ Да ✅ Да ❌ Нет ✅ Да

Лучшие практики и рекомендации

Что тестировать с Hypothesis

  1. Инварианты функций: Свойства, которые должны выполняться всегда
  2. Обратные операции: Если есть функция и её обратная
  3. Идемпотентность: Операции, которые можно применять многократно
  4. Коммутативность и ассоциативность: Математические свойства
  5. Границы и ограничения: Проверка корректности обработки граничных случаев

Как писать эффективные тесты

# Хорошо: проверка свойств
@given(st.text())
def test_string_upper_property(s):
    assert s.upper().isupper() or len(s) == 0

# Плохо: проверка конкретных значений
@given(st.text())
def test_string_specific_case(s):
    if s == "hello":
        assert s.upper() == "HELLO"

Оптимизация производительности

# Избегайте медленных assume()
@given(st.integers().filter(lambda x: x > 0))  # Лучше
def test_positive_numbers(x):
    assert x > 0

@given(st.integers())  # Хуже
def test_positive_numbers_bad(x):
    assume(x > 0)
    assert x > 0

Обработка исключений

@given(st.integers(), st.integers())
def test_division_by_zero(a, b):
    if b == 0:
        with pytest.raises(ZeroDivisionError):
            a / b
    else:
        result = a / b
        assert isinstance(result, float)

Частые вопросы и решения

Как ускорить выполнение тестов?

Используйте настройки для уменьшения количества примеров:

@settings(max_examples=50)
@given(st.complex_strategy())
def test_fast_execution(data):
    # Тест выполнится быстрее
    pass

Как обрабатывать медленные тесты?

@settings(deadline=None)  # Отключить таймаут
@given(st.large_data_strategy())
def test_slow_operation(data):
    # Медленная операция
    pass

Как тестировать внешние зависимости?

from unittest.mock import Mock

@given(st.text())
def test_external_api_call(data):
    with Mock() as mock_api:
        mock_api.return_value = {"status": "ok"}
        result = call_external_api(data, api_client=mock_api)
        assert result["status"] == "ok"

Как избежать слишком большого количества assume()?

# Плохо: много assume()
@given(st.integers())
def test_with_many_assumes(x):
    assume(x > 0)
    assume(x < 1000)
    assume(x % 2 == 0)
    # Тест может работать медленно

# Хорошо: использование фильтров
@given(st.integers(min_value=1, max_value=999).filter(lambda x: x % 2 == 0))
def test_with_filters(x):
    # Быстрее и понятнее
    pass

Заключение

Hypothesis представляет собой мощный инструмент для повышения качества тестирования Python-приложений. Эта библиотека особенно эффективна для:

  • Обнаружения редких багов и граничных случаев
  • Тестирования математических и логических свойств
  • Проверки корректности парсеров и валидаторов
  • Тестирования API и бизнес-логики
  • Обеспечения надежности критически важного кода

Основные преимущества использования Hypothesis:

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

Для получения максимальной пользы от Hypothesis рекомендуется фокусироваться на тестировании инвариантов и свойств функций, а не на проверке конкретных входных и выходных значений. Это позволит обнаружить ошибки, которые невозможно найти традиционными методами тестирования.

 

 

Новости