Если никогда не слышали о hypothesis и хотите дополнить свои функциональные интеграционные тесты чем-то новым и попробовать найти баги там, где вроде бы уже искали – добро пожаловать в статью.
Очень коротко о самом hypothesis
Эта библиотека позволяет параметризовать тестовую функцию случайными (но не совсем) параметрами и таким образом находить хитрые баги. Пример использования из документации:
from hypothesis import given, strategies as st
@given(st.integers(), st.integers())
def test_ints_are_commutative(x, y):
assert x + y == y + x
st.integers()
– это так называемая “стратегия” в терминах hypothesis, которая говорит о том, что в качестве параметров x
, y
будут числа. А какие именно – выберет сам hypothesis. Чтобы уменьшить количество генерируемых кейсов и убрать совсем странные варианты в стратегию можно также передавать параметры и контролировать какие именно числа надо использовать. Т.е. можно, например, указать мин/макс значения:
from hypothesis import given, strategies as st
@given(
st.integers(min_value=0, max_value=100),
st.integers(min_value=0, max_value=100)
)
def test_ints_are_commutative(x: int, y: int):
assert x + y == y + x
По мимо чисел, в качестве параметров можно передавать почти все что угодно - стратегии есть на все случаи жизни, примеры здесь.
Но перейдем к интеграционным тестам
Для них обычно требуется хранить состояние системы и проверять не работу одного конкретного метода с разными параметрами, а всю систему целиком. Для начала, давайте представим, что мы разрабатываем структуру данных – словарь и хотим её по-всякому протестировать. Нас прежде всего интересуют методы - добавить значение по ключу, получить значение по ключу и получить размер словаря. Теперь вернемся обратно к hypothesis.
В этой библиотеке есть прекрасная вещь под названием - Rule-based state machine. По сути, это класс, который представляет собой конечный автомат, который эмулирует тестируемую систему. Методы класса являются переходами между состояниями системы, а само состояние хранится в переменных класса типа Bundle
и в переменной self
.
Методы-переходы вызываются в случайном порядке и в случайном количестве. Но порядок вызовов можно регулировать с помощью переменных класса типа Bundle
и с помощью декоратора @precondition
. Обозначение метода-перехода происходит через декоратор @rule
:
keys = Bundle("keys")
@rule(
target=keys,
key=st.text(alphabet=string.ascii_letters, min_size=1),
value=st.integers(min_value=0, max_value=100)
)
@precondition(lambda self: self.dictionary_under_test is not None)
def add_element(self, key, value):
self.dictionary_under_test[key] = value
self.ideal_dictionary[key] = value
return key
У @rule
есть важный параметр – target
, он обозначает, куда будет сохраняться значение, возвращаемое методом-переходом. В данном случае, мы сохраняем возвращаемый ключ в переменную класса keys
. Два других параметра – key
и value
, по аналогии с примером в начале статьи, являются входными параметрами уже для самого метода-перехода. Их непосредственные значения определяются стратегиями text()
и integers()
.
Про @precondition
- в нем мы указываем функцию, которая будет вызываться до вызова метода-перехода и определит будет ли этот метод-переход вызван или нет. В примере – перед вызовом метода мы удостоверяемся, что тестируемый словарь существует. Если нет – этот метод вызываться не будет.
Если необходимо убрать значение из переменной типа Bundle, используется метод consumes
:
@rule(key=consumes(keys))
def remove_element(self, key):
assert self.dictionary_under_test.pop(key) == self.ideal_dictionary.pop(key)
Ключ на вход поступает из переменной класса keys
. После вызова метода – полученный ключ удаляется из переменной keys
. Если не использовать consumes – ключ не удалится и методы remove_element
и add_element
могут быть вызваны повторно с уже удаленным ключом.
Есть еще один декоратор, который может пригодиться – @invariant
@invariant()
def length_are_equal(self):
assert len(self.dictionary_under_test) == len(self.ideal_dictionary)
Он вызывается каждый раз до и после вызовов методов-переходов и проверяет, что некое утверждение о состоянии системы все еще верно.
И еще одна важная вещь – метод teardown
:
def teardown(self):
self.dictionary_under_test = {}
self.ideal_dictionary = {}
Вызывается по окончании каждого кейса и позволяет почистить за собой.
По мимо всего этого, нужно как-то регулировать количество кейсов и количество переходов в рамках конкретных кейсов, которые сгенерирует hypothesis. Для этого есть вот такие настройки:
StorageSystemTest.TestCase.settings = settings(
max_examples=10, stateful_step_count=5
)
Вызывать все это дело можно так:
pytest -s --hypothesis-show-statistics --hypothesis-verbosity=debug test_python_dictionary.py
Пример вывода пары кейсов (генерируется автоматически при указании параметра --hypothesis-verbosity=debug):
Trying example:
state = DictionaryTest()
state.length_are_equal()
v1 = state.add_element(key='Kv', value=86)
state.length_are_equal()
v2 = state.add_element(key='YecDWVUvWC', value=64)
state.length_are_equal()
v3 = state.add_element(key='AdHM', value=93)
state.length_are_equal()
v4 = state.add_element(key='SXz', value=50)
state.length_are_equal()
v5 = state.add_element(key='pHZMnSmadRbZfUAvJ', value=45)
state.length_are_equal()
state.teardown()
Trying example:
state = DictionaryTest()
state.length_are_equal()
v1 = state.add_element(key='bTRLj', value=43)
state.length_are_equal()
state.remove_element(key=v1)
state.length_are_equal()
v2 = state.add_element(key='TuSdbcM', value=42)
state.length_are_equal()
v3 = state.add_element(key='JshrNbJJ', value=72)
state.length_are_equal()
state.remove_element(key=v3)
state.length_are_equal()
state.teardown()
Итоговый вид класса:
import string
import hypothesis.strategies as st
from hypothesis import settings
from hypothesis.stateful import Bundle, RuleBasedStateMachine, rule, precondition, invariant, consumes
class DictionaryTest(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.dictionary_under_test = {}
self.ideal_dictionary = {}
keys = Bundle("keys")
@rule(
target=keys,
key=st.text(alphabet=string.ascii_letters, min_size=1),
value=st.integers(min_value=0, max_value=100)
)
@precondition(lambda self: self.dictionary_under_test is not None)
def add_element(self, key: str, value: int) -> str:
self.dictionary_under_test[key] = value
self.ideal_dictionary[key] = value
return key
@rule(key=consumes(keys))
def remove_element(self, key: str):
assert self.dictionary_under_test.pop(key) == self.ideal_dictionary.pop(key)
@rule(key=keys)
def values_agree(self, key: str):
assert self.dictionary_under_test[key] == self.ideal_dictionary[key]
@invariant()
def length_are_equal(self):
assert len(self.dictionary_under_test) == len(self.ideal_dictionary)
def teardown(self):
self.dictionary_under_test = {}
self.ideal_dictionary = {}
DictionaryTest.TestCase.settings = settings(
max_examples=10, stateful_step_count=5
)
GoodTest = DictionaryTest.TestCase
Ссылки:
https://hypothesis.readthedocs.io/en/latest/quickstart.html
https://hypothesis.works/articles/rule-based-stateful-testing/
https://hypothesis.works/articles/how-not-to-die-hard-with-hypothesis/
https://hypothesis.readthedocs.io/en/latest/stateful.html