## Ограничения примерного тестирования
Каждый написанный вами до сих пор тест является *примерным*: вы выбираете конкретные входные данные, вызываете функцию и проверяете конкретные выходные данные.
```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)".
## Настройка
```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
```
## Настройка
```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
```