Если никогда не слышали о 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

Комментарии (0)