Если никогда не слышали о 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
ссылка на оригинал статьи https://habr.com/ru/articles/744946/
Добавить комментарий