Python · Тестирование с pytest · Продвинутый

Property-Based тестирование с Hypothesis

5 задач

Автоматически генерируйте тысячи входных данных для тестов, чтобы находить граничные случаи.

От примерного к property-based тестированию

#
## Ограничения примерного тестирования Каждый написанный вами до сих пор тест является *примерным*: вы выбираете конкретные входные данные, вызываете функцию и проверяете конкретные выходные данные. ```python def test_reverse(): assert reverse('hello') == 'olleh' assert reverse('') == '' assert reverse('a') == 'a' ``` Вы выбрали эти три примера. Вы тестировали то, о чём подумали. Если вы не подумали о `'racecar'` (палиндром), или `'café'` (Unicode), или `' '` (пробелы), эти входные данные остаются непроверенными. Фундаментальный недостаток: **примерные тесты покрывают только те примеры, которые вы додумались написать.** ## Property-based тестирование: опишите инвариант, позвольте библиотеке найти контрпример Property-based тестирование использует другой подход. Вместо выбора примеров вы описываете *свойство* -- инвариант, который должен выполняться для любых допустимых входных данных, -- и позволяете библиотеке генерировать сотни случайных входных данных, пытаясь его опровергнуть. ```python from hypothesis import given from hypothesis import strategies as st @given(st.text()) def test_reverse_involution(s): # Свойство: двойное обращение строки даёт исходную строку. assert reverse(reverse(s)) == s ``` Hypothesis генерирует случайные строки -- короткие, длинные, пустые, Unicode, управляющие символы, строки с пробелами -- и запускает тело теста для каждой. Если какие-либо входные данные приводят к падению утверждения, hypothesis сообщает об этом как о неудачном примере и пытается *свернуть* его до наименьшего возможного падающего случая. ## Установка и импорт ```bash pip install hypothesis ``` ```python from hypothesis import given, assume, settings from hypothesis import strategies as st ``` ## Декоратор @given `@given(strategy)` -- основа hypothesis. Он превращает обычную тестовую функцию в тест свойств, внедряя сгенерированные значения как аргументы: ```python @given(st.integers()) def test_abs_is_non_negative(n): assert abs(n) >= 0 ``` Hypothesis генерирует целые числа во всём диапазоне -- положительные, отрицательные, ноль, `sys.maxsize`, `-sys.maxsize - 1` -- и запускает тело теста для каждого. Этот тест пройдёт для всех, поскольку `abs` корректна. При наличии баги в реализации: ```python def buggy_abs(n): return n # баг: отрицательные числа возвращаются без изменений ``` Hypothesis быстро находит минимальный контрпример: ``` Falsifying example: test_abs_is_non_negative(n=-1) ``` Исходное падающее входное значение могло быть `-273`. Hypothesis сворачивает его до `-1` -- наименьшего целого числа, опровергающего `abs(n) >= 0` для функции, возвращающей `n` без изменений. ## Стратегии: как hypothesis генерирует данные *Стратегия* описывает множество значений, которые может генерировать hypothesis. Модуль `hypothesis.strategies` (`st`) предоставляет обширную библиотеку: | Стратегия | Генерирует | |---|---| | `st.integers()` | Любое целое число | | `st.integers(min_value=0, max_value=100)` | Целое число в [0, 100] | | `st.floats()` | Любое float (включая NaN, inf) | | `st.floats(allow_nan=False, allow_infinity=False)` | Конечное float | | `st.text()` | Любая строка Unicode | | `st.text(alphabet=string.ascii_lowercase, min_size=1)` | Непустой строчный ASCII | | `st.booleans()` | True или False | | `st.lists(st.integers())` | Список целых чисел | | `st.lists(st.integers(), min_size=1, max_size=10)` | Непустой список до 10 элементов | | `st.dictionaries(st.text(), st.integers())` | Словарь с текстовыми ключами, целочисленными значениями | | `st.one_of(st.integers(), st.text())` | Либо целое число, либо текст | ## Сворачивание: поиск минимального падающего примера Когда hypothesis находит падающий ввод, она не останавливается и не сообщает его сразу. Она пытается *свернуть* его -- найти меньший, более простой ввод, который тоже падает. Это одна из самых ценных возможностей hypothesis. Например, если hypothesis генерирует 500-символьную строку, из-за которой падает тест, она будет пробовать всё более короткие строки, пока не найдёт самую короткую строку, которая всё ещё падает. Сообщаемый сбой -- минимальный пример, а не исходный случайный, что упрощает понимание бага. ``` Falsifying example: test_slugify(s='A B') # Не: s='Random Long String With Spaces And Punctuation!!!' ``` ## assume(): фильтрация недопустимых входных данных Иногда свойство имеет смысл только для подмножества входных данных. `assume()` сообщает hypothesis пропустить примеры, не удовлетворяющие предусловию: ```python from hypothesis import given, assume from hypothesis import strategies as st @given(st.integers(), st.integers()) def test_division(a, b): assume(b != 0) # пропускать примеры, где b равен нулю result = a / b assert isinstance(result, float) ``` Когда hypothesis генерирует `b=0`, `assume(b != 0)` вызывает внутреннее исключение и hypothesis отбрасывает этот пример, пробуя другой. Тест запускается только для допустимых входных данных. **Осторожно:** `assume()` следует использовать экономно. Если большинство генерируемых входных данных отбрасывается, hypothesis генерирует намного больше примеров для поиска допустимых и может сдаться с `Unsatisfied`. По возможности используйте ограниченные стратегии: ```python # Лучше, чем assume(b != 0): @given(st.integers(), st.integers().filter(lambda x: x != 0)) def test_division(a, b): result = a / b assert isinstance(result, float) ``` ## settings: управление поведением hypothesis ```python from hypothesis import settings @settings(max_examples=500) # запустить 500 примеров вместо дефолтных 100 @given(st.text()) def test_something(s): ... ``` | Настройка | По умолчанию | Описание | |---|---|---| | `max_examples` | 100 | Сколько допустимых примеров генерировать | | `deadline` | 200 мс | Максимальное время на пример (установите `None` для отключения) | | `suppress_health_check` | `[]` | Отключить конкретные проверки работоспособности | Для тестов, вызывающих реальный API, установите `deadline=None` для избежания ложных падений из-за сетевой задержки: ```python @settings(max_examples=10, deadline=None) @given(st.text(min_size=1, max_size=20, alphabet=string.ascii_lowercase)) def test_search(query): resp = requests.get(f'{BASE_URL}/api/search/?q={query}') assert resp.status_code in (200, 404) ``` ## Когда использовать hypothesis **Хорошо подходит:** - Чистые функции с математическими свойствами (сортировка, кодирование, разбор) - Функции с обратимыми операциями (`encode`/`decode`, `compress`/`decompress`) - Функции валидации, которые должны принимать или отвергать любой ввод типа - Инварианты структур данных (высота дерева, порядок списка, членство в множестве) **Плохо подходит:** - Тесты, требующие конкретного начального состояния в базе данных - Тесты, где ожидаемый вывод нетривиально зависит от входных данных (используйте parametrize) - Тесты побочно-эффектных функций (вызовы API, запись файлов) -- слишком медленно для 100 примеров Самый чёткий признак, что hypothesis подходит для теста: свойство является универсальным утверждением вида "для всех X выполняется property(X)".

Hypothesis на практике: инварианты, сворачивание и составные стратегии

#
## Настройка ```bash pip install hypothesis ``` ## Пример 1: Простейшее свойство -- abs неотрицателен ```python # test_abs.py from hypothesis import given from hypothesis import strategies as st @given(st.integers()) def test_abs_non_negative(n): assert abs(n) >= 0 ``` ``` $ pytest test_abs.py -v test_abs.py::test_abs_non_negative PASSED (ran 100 examples) ``` Hypothesis сгенерировала 100 целых чисел -- включая большие положительные, большие отрицательные, ноль и граничные случаи вроде `sys.maxsize` -- и проверила свойство для каждого. Это тщательнее любого ручного списка parametrize. ## Пример 2: Обратимая операция -- двойное обращение ```python # test_reverse.py from hypothesis import given from hypothesis import strategies as st def reverse(s: str) -> str: return s[::-1] @given(st.text()) def test_reverse_involution(s): assert reverse(reverse(s)) == s ``` "Двойное обращение равно тождественному преобразованию" -- классическое свойство для обращения строки. Hypothesis попробует пустые строки, одиночные символы, символы Unicode (`'é'`, `'🐍'`), строки с нулевыми байтами и многое другое. Свойство выполняется для всех них. ## Пример 3: Инварианты отсортированного списка ```python # test_sorting.py from hypothesis import given from hypothesis import strategies as st @given(st.lists(st.integers())) def test_sorted_list_is_ordered(lst): result = sorted(lst) for i in range(len(result) - 1): assert result[i] <= result[i + 1] @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_preserves_elements(lst): assert sorted(lst, reverse=True) == list(reversed(sorted(lst))) assert set(sorted(lst)) == set(lst) ``` Обратите внимание: три отдельных свойства для одной функции -- упорядоченность, длина, множество элементов. Каждое свойство простое; вместе они дают уверенность в корректности `sorted`. ## Пример 4: assume() для ограничения входных данных ```python # test_division.py from hypothesis import given, assume from hypothesis import strategies as st def safe_divide(a: int, b: int) -> float: return a / b @given(st.integers(), st.integers()) def test_safe_divide_result_type(a, b): assume(b != 0) result = safe_divide(a, b) assert isinstance(result, float) @given(st.integers(min_value=1), st.integers(min_value=1)) def test_divide_positive_by_positive(a, b): result = safe_divide(a, b) assert result > 0 ``` Второй тест использует ограниченную стратегию (`min_value=1`) вместо `assume()` -- это эффективнее, поскольку hypothesis с самого начала генерирует только допустимые входные данные, а не генерирует и отбрасывает многие недопустимые. ## Пример 5: Обнаружение бага с помощью hypothesis -- сворачивание в действии ```python # test_buggy_max.py from hypothesis import given from hypothesis import strategies as st def my_max(lst): result = lst[0] for x in lst[1:]: if x > result: result = x return result @given(st.lists(st.integers(), min_size=1)) def test_my_max_equals_builtin(lst): assert my_max(lst) == max(lst) ``` Если внести баг -- например, `>` вместо `>=` может вызвать проблемы в некоторых случаях, или если в цикле есть ошибка на единицу -- hypothesis найдёт минимальный падающий список. Например, если баг проявляется только при повторяющихся максимальных значениях, hypothesis свернёт его до `[1, 1]`, а не сообщит `[5, 3, 1, 5, 8, 5]`. ## Пример 6: @st.composite -- пользовательская стратегия `@st.composite` позволяет построить стратегию, комбинирующую несколько выборок для получения структурированного значения: ```python # test_composite.py from hypothesis import given from hypothesis import strategies as st @st.composite def valid_user(draw): username = draw(st.text( alphabet='abcdefghijklmnopqrstuvwxyz0123456789_', min_size=3, max_size=20, )) age = draw(st.integers(min_value=13, max_value=120)) return {'username': username, 'age': age} def validate_user(user): if len(user['username']) < 3: raise ValueError('Username too short') if user['age'] < 13: raise ValueError('Too young') return True @given(valid_user()) def test_valid_user_always_validates(user): assert validate_user(user) is True ``` `draw(strategy)` внутри функции с `@st.composite` сэмплирует значение из `strategy`. Декорированная функция становится стратегией, которую можно передать в `@given`. Это правильный инструмент, когда нужно генерировать структурированные данные, где одно поле ограничивает другое. ## Запуск hypothesis с подробным выводом ```bash pytest test_abs.py -v --hypothesis-show-statistics ``` ``` test_abs_non_negative: - 100 passing examples - Typical runtimes: 0-1ms ``` Чтобы видеть каждый сгенерированный пример: ```bash pytest test_abs.py -v -s --hypothesis-verbosity=verbose ```

Справочная карточка: hypothesis

#
## Настройка ```bash pip install hypothesis ``` ```python from hypothesis import given, assume, settings from hypothesis import strategies as st ``` ## Базовый паттерн ```python @given(strategy) def test_property(value): # утверждаем что инвариант выполняется для любого `value` assert some_property(value) ``` ## Шпаргалка по стратегиям | Стратегия | Что генерирует | |---|---| | `st.integers()` | Любое целое число | | `st.integers(min_value=0, max_value=100)` | Целое в [0, 100] | | `st.floats(allow_nan=False, allow_infinity=False)` | Конечное float | | `st.text()` | Любая строка Unicode | | `st.text(alphabet='abc', min_size=1)` | Непустая строка из `'abc'` | | `st.binary()` | Объект bytes | | `st.booleans()` | `True` или `False` | | `st.none()` | `None` | | `st.lists(st.integers())` | Список целых чисел | | `st.lists(st.integers(), min_size=1, max_size=10)` | 1-10 целых чисел | | `st.tuples(st.integers(), st.text())` | Кортеж `(int, str)` | | `st.dictionaries(st.text(), st.integers())` | Словарь `{str: int}` | | `st.one_of(st.integers(), st.text())` | Целое или текст | | `st.sampled_from([1, 2, 3])` | Одно из перечисленных значений | | `st.builds(MyClass, field=strategy)` | Экземпляр `MyClass` | ## Фильтрация стратегий ```python # По возможности предпочитайте ограниченные стратегии вместо assume(): st.integers().filter(lambda x: x != 0) st.text().filter(lambda s: s.strip()) ``` ## assume() ```python @given(st.integers(), st.integers()) def test_division(a, b): assume(b != 0) # пропускать примеры, где b == 0 assert a / b == a / b ``` ## settings ```python @settings(max_examples=500, deadline=None) @given(st.text()) def test_something(s): ... ``` | Настройка | По умолчанию | Применение | |---|---|---| | `max_examples` | 100 | Количество допустимых примеров для запуска | | `deadline` | 200 мс | Максимальное время на пример; `None` для медленных операций | | `suppress_health_check` | `[]` | Подавить конкретные предупреждения | ## @st.composite -- пользовательские стратегии ```python @st.composite def my_strategy(draw): x = draw(st.integers(min_value=0)) y = draw(st.integers(min_value=x)) # y >= x return (x, y) @given(my_strategy()) def test_pair(pair): x, y = pair assert y >= x ``` ## Распространённые паттерны свойств ```python # Обратимая операция assert decode(encode(x)) == x # Идемпотентность assert f(f(x)) == f(x) # Монотонность assert f(a) <= f(b) when a <= b # когда a <= b # Сохранение длины assert len(transform(x)) == len(x) # Сохранение элементов assert set(transform(x)) == set(x) # Неотрицательность assert result >= 0 ```
01

Проверить что abs(n) >= 0 для всех целых чисел

#

Установите `hypothesis` и напишите property-based тест `test_abs_non_negative`, который: - Использует `@given(st.integers())` для генерации любого целого числа - Проверяет что `abs(n) >= 0` Также напишите `test_abs_of_abs_equals_abs`, проверяющий `abs(abs(n)) == abs(n)` для любого целого числа. Оба теста должны проходить -- они тестируют корректный встроенный метод Python.

# test_abs_property.py
from hypothesis import given
from hypothesis import strategies as st


@given(st.integers())
def test_abs_non_negative(n):
    ...


@given(st.integers())
def test_abs_of_abs_equals_abs(n):
    ...
Решение
# test_abs_property.py
from hypothesis import given
from hypothesis import strategies as st


@given(st.integers())
def test_abs_non_negative(n):
    assert abs(n) >= 0


@given(st.integers())
def test_abs_of_abs_equals_abs(n):
    assert abs(abs(n)) == abs(n)
02

Проверить что двойное обращение строки возвращает исходную строку

#

Определите функцию `reverse(s: str) -> str`, обращающую строку с помощью срезов (`s[::-1]`). Напишите тест свойства `test_reverse_involution` с `@given(st.text())`, проверяющий: ``` reverse(reverse(s)) == s ``` Также напишите `test_reverse_empty` с `@given(st.text(max_size=0))` -- обращение пустой строки является пустой строкой.

# test_reverse.py
from hypothesis import given
from hypothesis import strategies as st


def reverse(s: str) -> str:
    ...


@given(st.text())
def test_reverse_involution(s):
    ...


@given(st.text(max_size=0))
def test_reverse_empty(s):
    ...
Решение
# test_reverse.py
from hypothesis import given
from hypothesis import strategies as st


def reverse(s: str) -> str:
    return s[::-1]


@given(st.text())
def test_reverse_involution(s):
    assert reverse(reverse(s)) == s


@given(st.text(max_size=0))
def test_reverse_empty(s):
    assert reverse(s) == ''
03

Использовать assume() для тестирования деления с ненулевым знаменателем

#

Напишите функцию `safe_divide(a: float, b: float) -> float`, возвращающую `a / b`. Напишите два теста свойств: 1. `test_divide_with_assume` -- используйте `@given(st.floats(min_value=-1e15, max_value=1e15, allow_nan=False, allow_infinity=False), st.floats(min_value=-1e15, max_value=1e15, allow_nan=False, allow_infinity=False))` и `assume(b != 0)`. Проверьте `safe_divide(a, b) * b == pytest.approx(a)` (умножение результата на знаменатель даёт исходный числитель). 2. `test_divide_positive_constrained` -- используйте `@given(st.floats(min_value=0.1, max_value=100), st.floats(min_value=0.1, max_value=100))`. Проверьте что результат положителен.

# test_division_property.py
import pytest
from hypothesis import given, assume
from hypothesis import strategies as st


def safe_divide(a: float, b: float) -> float:
    return a / b


@given(
    st.floats(min_value=-1e15, max_value=1e15, allow_nan=False, allow_infinity=False),
    st.floats(min_value=-1e15, max_value=1e15, allow_nan=False, allow_infinity=False),
)
def test_divide_with_assume(a, b):
    assume(b != 0)
    result = safe_divide(a, b)
    assert result * b == pytest.approx(a, rel=1e-6)


@given(
    st.floats(min_value=0.1, max_value=100),
    st.floats(min_value=0.1, max_value=100),
)
def test_divide_positive_constrained(a, b):
    result = safe_divide(a, b)
    assert result > 0
Решение
# test_division_property.py
import pytest
from hypothesis import given, assume
from hypothesis import strategies as st


def safe_divide(a: float, b: float) -> float:
    return a / b


@given(
    st.floats(min_value=-1e15, max_value=1e15, allow_nan=False, allow_infinity=False),
    st.floats(min_value=-1e15, max_value=1e15, allow_nan=False, allow_infinity=False),
)
def test_divide_with_assume(a, b):
    assume(b != 0)
    result = safe_divide(a, b)
    assert result * b == pytest.approx(a, rel=1e-6)


@given(
    st.floats(min_value=0.1, max_value=100),
    st.floats(min_value=0.1, max_value=100),
)
def test_divide_positive_constrained(a, b):
    result = safe_divide(a, b)
    assert result > 0
04

Проверить что max(lst) >= min(lst) для любого непустого списка

#

Напишите тест свойства с `@given(st.lists(st.integers(), min_size=1))`, проверяющий: - `max(lst) >= min(lst)` - `max(lst)` является элементом `lst` (т.е. `max(lst) in lst`) - `min(lst)` является элементом `lst` (т.е. `min(lst) in lst`) Все три свойства должны быть отдельными утверждениями `assert` в одной тестовой функции `test_max_min_properties`.

# test_max_min.py
from hypothesis import given
from hypothesis import strategies as st


@given(st.lists(st.integers(), min_size=1))
def test_max_min_properties(lst):
    assert ...
    assert ...
    assert ...
Решение
# test_max_min.py
from hypothesis import given
from hypothesis import strategies as st


@given(st.lists(st.integers(), min_size=1))
def test_max_min_properties(lst):
    assert max(lst) >= min(lst)
    assert max(lst) in lst
    assert min(lst) in lst
05

Написать стратегию @st.composite для словаря пользователя

#

Напишите стратегию `@st.composite` `valid_user(draw)`, генерирующую словарь пользователя с: - `username` -- 3-20 символов из `string.ascii_lowercase + string.digits + '_'` - `age` -- целое число от 13 до 120 - `email` -- простой фиктивный email, построенный как `f'{username}@example.com'` Примечание: выведите `email` из того же значения `username`, которое вы сэмплировали (чтобы они были согласованы). Напишите функцию `validate_user(user)`, которая: - Вызывает `ValueError('Username too short')` если `len(username) < 3` - Вызывает `ValueError('Too young')` если `age < 13` - Возвращает `True` иначе Напишите `test_valid_user_always_validates` с `@given(valid_user())`, проверяющий `validate_user(user) is True`.

# test_composite.py
import string
from hypothesis import given
from hypothesis import strategies as st


@st.composite
def valid_user(draw):
    username = draw(st.text(
        alphabet=...,
        min_size=3,
        max_size=20,
    ))
    age = draw(st.integers(min_value=13, max_value=120))
    return {
        'username': username,
        'age': age,
        'email': f'{username}@example.com',
    }


def validate_user(user):
    if len(user['username']) < 3:
        raise ValueError('Username too short')
    if user['age'] < 13:
        raise ValueError('Too young')
    return True


@given(valid_user())
def test_valid_user_always_validates(user):
    assert validate_user(user) is True
Решение
# test_composite.py
import string
from hypothesis import given
from hypothesis import strategies as st


@st.composite
def valid_user(draw):
    username = draw(st.text(
        alphabet=string.ascii_lowercase + string.digits + '_',
        min_size=3,
        max_size=20,
    ))
    age = draw(st.integers(min_value=13, max_value=120))
    return {
        'username': username,
        'age': age,
        'email': f'{username}@example.com',
    }


def validate_user(user):
    if len(user['username']) < 3:
        raise ValueError('Username too short')
    if user['age'] < 13:
        raise ValueError('Too young')
    return True


@given(valid_user())
def test_valid_user_always_validates(user):
    assert validate_user(user) is True