Легкий фаззинг в интеграционных тестах с помощью hypothesis

от автора

Если никогда не слышали о 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, которая говорит о том, что в качестве параметров xy будут числа. А какие именно – выберет сам 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *