Python · Тестирование с pytest · Продвинутый
Property-Based тестирование с Hypothesis
Автоматически генерируйте тысячи входных данных для тестов, чтобы находить граничные случаи.
Краткое введение в тему и пояснения перед упражнениями (упражнения ниже):
Hypothesis на практике: инварианты, сворачивание и составные стратегии
#Справочная карточка: hypothesis
#Упражнения:
Проверить что 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)
Проверить что двойное обращение строки возвращает исходную строку
#Определите функцию `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) == ''
Использовать 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
Проверить что 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
Написать стратегию @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