## Обмеження тестування на прикладах
Кожен тест, який ви написали досі, є *тестом на прикладах*: ви обираєте конкретні вхідні дані, викликаєте функцію і перевіряєте конкретні вихідні дані.
```python
def test_reverse():
assert reverse('hello') == 'olleh'
assert reverse('') == ''
assert reverse('a') == 'a'
```
Ці три приклади обрали ви. Ви протестували те, про що подумали. Якщо ви не подумали про `'racecar'` (паліндром), або `'café'` (unicode), або `' '` (пробіли), ці вхідні дані залишаються непротестованими.
Фундаментальна слабкість: **тести на прикладах покривають лише ті приклади, які ви додумалися написати.**
## Тестування на властивостях: описуйте інваріант, нехай бібліотека знаходить контрприклад
Тестування на властивостях використовує інший підхід. Замість вибору прикладів ви описуєте *властивість* -- інваріант, який має виконуватися для будь-яких допустимих вхідних даних -- і дозволяєте бібліотеці генерувати сотні випадкових вхідних даних, намагаючись спростувати її.
```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) є True".
## Налаштування
```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) # коли a <= b
# Збереження довжини
assert len(transform(x)) == len(x)
# Збереження елементів
assert set(transform(x)) == set(x)
# Невід'ємність
assert result >= 0
```