Часть седьмая: тестирование с отслеживанием состояния

Эта статья является частью серии. Если вы еще не сделали этого, ознакомьтесь с предыдущими статьями:

Часть первая: зачем мы тестируем
Часть вторая: основные концепции тестирования
Часть третья: написание базовых тестов
Часть четвертая: запуск ваших тестов
Часть пятая: инструменты и методы эффективного тестирования
Часть шестая: параметризация и тестирование на основе свойств
Часть седьмая: тестирование с отслеживанием состояния

Тестирование с отслеживанием состояния - это более продвинутый метод тестирования на основе свойств, используемый для тестирования сложных систем. В тесте с отслеживанием состояния вы определяете ряд действий, которые можно комбинировать различными способами, а гипотеза пытается найти последовательность этих действий, которая приводит к сбою. Это полезно для тестирования сложных контрактов или взаимодействий между контрактами, когда существует много возможных состояний.

Государственные машины

В основе каждого теста с отслеживанием состояния лежит класс, называемый конечным автоматом. Этот класс определяет начальное состояние теста, ряд действий, описывающих структуру, в которой будет выполняться тест, и инварианты, которые не должны нарушаться во время выполнения.

Конечные автоматы состоят из следующих компонентов:

Правила

В основе каждого конечного автомата лежит одно или несколько правил. Правила - это методы класса, которые очень похожи на тесты на основе @given; они получают значения, взятые из стратегий, и передают их в пользовательскую функцию тестирования. Ключевое отличие состоит в том, что там, где тесты на основе @given выполняются независимо, правила могут быть объединены в цепочку - один запуск теста с отслеживанием состояния может включать в себя несколько вызовов правил, которые могут взаимодействовать по-разному.

Как правило, обрабатывается любой метод конечного автомата с именем rule или начинающийся с rule_:

class StateMachine:

    def rule_one(self):
        # performs a test action

    def rule_two(self):
        # performs another, different test action

Инициализаторы

Существует также особый тип правил, известный как инициализатор. Это правила, которые гарантированно будут выполнены не более одного раза в начале выполнения (то есть перед вызовом любого нормального правила). Их можно вызывать в любом порядке или не вызывать вообще, и порядок будет меняться от запуска к запуску.

Любой метод конечного автомата с именем initialize или начинающийся с initialize_ рассматривается как инициализатор.

class StateMachine:

    def initialize(self):
        # this method may or may not be called prior to rule_one

    def rule_one(self):
        # once this method is called, initialize will not be
        # called during the test run

Стратегии

Конечный автомат должен содержать одну или несколько стратегий, чтобы предоставлять данные для правил.

Стратегии должны быть определены на уровне класса, обычно перед первой функцией. Им можно дать любое имя.

Подобно тому, как фикстуры работают в тестах pytest, правила конечного автомата получают стратегии, ссылаясь на них в своих аргументах. Это показано в следующем примере:

class StateMachine:

    st_uint = strategy('uint256')
    st_bytes32 = strategy('bytes32')

    def initialize(self, st_uint):
        # this method draws from the uint256 strategy

    def rule(self, st_uint, st_bytes32):
        # this method draws from both strategies

    def rule_two(self, value="st_uint", othervalue="st_uint"):
        # this method draws from the same strategy twice

Инварианты

Помимо правил, конечный автомат часто определяет инварианты. Это свойства, которые должны оставаться неизменными, независимо от каких-либо действий, выполняемых правилами. После выполнения каждого правила всегда вызывается каждый инвариантный метод, чтобы убедиться, что тест не завершился ошибкой.

Любой метод конечного автомата с именем invariant или начинающийся с invariant_ рассматривается как инвариант. Инварианты предназначены для проверки правильности состояния; они не могут получить стратегии.

class StateMachine:

    def rule_one(self):
        pass

    def rule_two(self):
        pass

    def invariant(self):
        # assertions in this method should always pass regardless
        # of actions in both rule_one and rule_two

Методы установки и разборки

Конечный автомат может дополнительно включать в себя процедуры настройки и демонтажа. Как и в случае с приборами pytest, доступны методы установки и удаления для выполнения логики для отдельных тестов и запусков.

  • __init__: этот метод вызывается один раз перед моментальным снимком цепочки, созданным перед первым запуском теста. Он запускается как метод класса - изменения, внесенные в конечный автомат, будут сохраняться при каждом запуске теста.
  • setup: этот метод вызывается в начале каждого тестового запуска, сразу после возврата цепочки к моментальному снимку. Изменения, внесенные в течение setup, повлияют только на предстоящий запуск.
  • teardown: этот метод вызывается в конце каждого успешного выполнения теста перед возвратом цепочки. teardown не вызывается, если запуск не удался.
  • teardown_final: этот метод вызывается после завершения последнего тестового запуска и возврата цепочки. teardown_final вызывается независимо от того, пройден тест или нет.

Последовательность выполнения теста

Тест с отслеживанием состояния выполняется в следующей последовательности:

  1. Фаза настройки всех фикстур pytest выполняется в обычном порядке.
  2. Если присутствует, вызывается метод StateMachine.__init__.
  3. Будет сделан снимок текущего состояния цепочки.
  4. Если присутствует, вызывается метод StateMachine.setup.
  5. Вызывается ноль или более StateMachine методов инициализации в произвольном порядке.
  6. Вызываются один или несколько StateMachine методов правила без определенного порядка.
  7. После каждой инициализации и каждого правила вызывается каждый StateMachine инвариантный метод.
  8. Если присутствует, вызывается метод StateMachine.teardown.
  9. Цепочка возвращается к снимку, сделанному на шаге 3.
  10. Шаги 4–9 повторяются 50 раз или до тех пор, пока тест не завершится неудачно.
  11. Если присутствует, вызывается метод StateMachine.teardown_final.
  12. Фаза демонтажа всех фикстур pytest выполняется в обычном порядке.

Написание тестов с отслеживанием состояния

Написание теста с отслеживанием состояния состоит из трех шагов:

  1. Создайте класс конечного автомата. Он должен включать хотя бы одно правило и инвариант.
  2. Создайте обычный тест в стиле pytest, который включает фикстуру state_machine
  3. В рамках теста вызовите state_machine с конечным автоматом в качестве первого аргумента.

В качестве примера давайте создадим конечный автомат для тестирования следующего контракта Vyper Depositer:

Это очень простой контракт с двумя функциями и общедоступным отображением. Любой может внести эфир на другой счет, используя метод deposit_for, или вывести внесенный эфир, используя метод withdraw_from.

Если вы присмотритесь, то можете заметить проблему в коде контракта. Если нет, не волнуйтесь! Мы собираемся найти это с помощью нашего теста.

Вот конечный автомат и функция тестирования, которые мы будем использовать для тестирования нашего контракта:

Когда этот тест выполняется, он будет вызывать rule_deposit и rule_withdraw, используя случайные данные из заданных стратегий, пока не встретит состояние, которое нарушает одно из утверждений. В этом случае тест повторяется, пытаясь найти кратчайший путь и наименьший возможный набор данных, воспроизводящий ошибку. Наконец, он сохраняет условия сбоя для использования в будущих тестах, а затем выдает следующий результат:

Из этого вывода мы можем увидеть, где в тесте инвариант не удался: self.contract.deposited(address) равно нулю, когда мы ожидали, что он будет равен единице. Мы также знаем последовательность вызовов, приводящих к ошибке. Из этой информации мы можем сделать вывод, что контракт неправильно корректирует сальдо в функции withdraw_from. Давайте еще раз взглянем на эту функцию:

В строке 4 вместо вычитания _value баланс установлен на _value. Мы нашли ошибку!

Выполнение тестов с отслеживанием состояния

По умолчанию тесты с отслеживанием состояния включаются при запуске набора тестов. Для их вызова не требуется никаких специальных действий. Вы можете исключить тесты с отслеживанием состояния или только запускать тесты с отслеживанием состояния с флагом --stateful.

Чтобы запускать только тесты с отслеживанием состояния:

brownie test --stateful true

Чтобы пропустить тесты с отслеживанием состояния:

brownie test --stateful false

Пока активен тест с отслеживанием состояния, на консоли отображается счетчик, который вращается каждый раз после завершения выполнения теста. Если цвет изменится с желтого на красный, это означает, что тест не прошел, и теперь гипотеза ищет кратчайший путь к провалу.

Примеры из реального мира

Чтобы узнать больше о тестировании с отслеживанием состояния, было бы полезно изучить несколько репозиториев, использующих этот метод:

…вот и все!

Если вы прочитали всю эту серию от начала до конца - спасибо, что остаетесь со мной! Я надеюсь, что вы узнали что-то новое по пути и почувствовали вдохновение пойти и написать несколько тестов.

Если вы ищете больше, посмотрите Тестирование основной сети Ethereum с помощью Python и Brownie - он почти вписывается в эту серию, я почти добрался до восьмой части, но стиль письма немного отличается, поэтому в конечном итоге я решил просто упомянуть это здесь в качестве бонуса.

Если вам понравилось, подпишитесь на Brownie Twitter, ставьте лайки и делитесь нашим контентом! Помогите рассказать о возможностях и покажите другим, что возможно при проверке их контрактов.

Вы также можете подписаться на меня на Medium и ознакомиться с другими статьями, которые я написал, и присоединиться к Brownie Gitter, чтобы общаться и учиться у других разработчиков-единомышленников.