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

Property-Based тестування з Hypothesis

5 завдань

Автоматично генеруйте тисячі вхідних даних для тестів, щоб знаходити граничні випадки.

Від тестування на прикладах до тестування на властивостях

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

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) # коли a <= b # Збереження довжини assert len(transform(x)) == len(x) # Збереження елементів assert set(transform(x)) == set(x) # Невід'ємність assert result >= 0 ```
01

Тест: abs(n) >= 0 для всіх цілих чисел

#

Встановіть `hypothesis` і напишіть тест властивостей `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