Способы сохранения промежуточных состояний системы при автоматизированном тестировании

от автора

Вместо введения

Если ваше ПО проходит путь от прототипа до Enterprise решения, то вместе с продуктом развиваются и ваши системные тесты: от лаконичных и компактных, до сложных и объемных. Рост сложности можно охарактеризовать увеличением количества пользовательских сценариев, что в свою очередь связано с ростом числа страниц, компонентов, элементов и их взаимосвязями, состояние которых неплохо бы проверять. Под состоянием подразумевается значение любой характеристики произвольного объекта, который мы тестируем: наименование, количество, цвет, факт присутствия или отсутствия, положение и т.д. И в какой-то момент может возникнуть потребность запоминать несколько промежуточных состояний по мере выполнения сценария. Например, сначала у вас было двести ТК, а через год их стало больше тысячи, и в этот момент было принято решение об автоматизации. Если строго следовать принципу атомарности ТК и гнаться за высоким процентом покрытия — велика вероятность в автотестах утонуть. Ведь еще через год их может стать и пять, и десять тысяч. А если следовать с некоторыми допущениями, то можно снизить скорость роста объема тестовой документации, общее время на написание и выполнение тестов, и, как следствие, время на доставку продукта пользователям. В таком контексте придерживаться лучших практик автотестирования: Page Object (PO), Fluent Invocation и AAA — становится болезненно трудно, поскольку и понятность, и поддерживаемость начинают страдать.

За поиском ответов на вопрос «как соблюсти паттерны PO, AAA, Fluent Invokation и запоминать несколько промежуточных состояний в автотестах» предлагаю отправиться вместе.

Решение 1. Разрыв цепочки вызовов

Это скорее не решение, а игнорирование проблемы. Пренебрегаем Fluent Invokation и разрываем. В качестве иллюстрации представим тест-кейс, в котором проверяется создание папки и документа разными способами: через меню и по нажатию кнопки. Тогда наш тест может выглядеть так:

// Arrange   // Act var isDocumentCreatedViaCreateButton = Page     .CreateDocument(DocumentName)     .IsDocumentCreated(DocumentName);   var isFolderCreatedViaDropdown = Page     .CreateObject(ObjectType.Folder, FolderName)     .IsFolderCreated(FolderName);   var isDocumentCreatedViaDropdown = Page     .CreateObject(ObjectType.Document, DocumentName)     .IsFolderCreated(FolderName);   // Assert Assert.IsTrue(isDocumentCreatedViaCreateButton); Assert.IsTrue(isFolderCreatedViaDropdown); Assert.IsTrue(isDocumentCreatedViaDropdown);

Когда таких тестов штук двадцать на проект, они понятны и поддерживать их легко. Но в enterprise системах тесты могут быть объемнее в несколько раз, и тогда сколько значений нужно запомнить, столько раз цепочку и прерываем. Не по «фэншую», то есть, не по Fluent Invokation. Насколько такие тесты соответствует теории тестирования, пусть останется за рамками наших рассуждений, давайте сосредоточимся на практике.

Решение 2. Out переменные

Как альтернативу прямому разрыву можно использовать out переменные. С одной стороны, проблему это как-бы решает, но с другой стороны — методы PO начинают отвечать не только за изменение состояния веб-драйвера, но и за хранение этого состояния. Не совсем Single Responsibility. Кроме того, если метод принимает несколько параметров и отдает состояние через out переменную, это начинает выглядеть неэстетично. А если нужно отдать два состояния? Три? Делать dto для этого? Короткая иллюстрация возможного применения:

// Arrange   // Act Tree     .IsTreeDisplayed(out var isTreeDisplayedByDefault)     .GetTreeNodesCount(out var treeNodesCountBefore)     .ExpandAllNodes()     .GetTreeNodesCount(out var treeNodesCountAfter)     .Hide(WorksTree.ToggleTreeVisibilityButtonTag)     .IsTreeDisplayed(out var isTreeDisplayedAfterHide)     .Show(WorksTree.ToggleTreeVisibilityButtonTag)     .IsTreeDisplayed(out var isTreeDisplayedAfterShow);   Page     .CreateObject(ObjectName, ClassName)     .Tree.WaitForTreeHasNewNode(ObjectName)     .SelectTreeNodeByTreeNodeName(ObjectName)     .IsTreeNodeSelected(ObjectName, out var isTreeNodeSelected);   // Assert Assert.IsTrue(isTreeDisplayedByDefault); Assert.IsTrue(treeNodesCountBefore != treeNodesCountAfter); Assert.IsFalse(isTreeDisplayedAfterHide); Assert.IsTrue(isTreeDisplayedAfterShow); Assert.IsTrue(isTreeNodeSelected);

Опять же, в тестах для сложных многокомпонентных систем можно встретить цепочки в 20-30 вызовов. При этом оба подхода, с разрывами и с out переменными, спокойно сосуществуют внутри одного теста.

Несмотря на некоторую противоречивость, зачастую, решение №2 — самое удобное.

Решение 3. Словарь

Вспоминается шутка-мем про решение проблем в коде путем добавления еще одного слоя абстракции. Именно так мы и поступим!

А что если поручить ответственность по запоминанию состояния PO классу-прослойке? В таком классе в качестве хранилища можно использовать, например, файл, базу данных или словарь.

Пример возможной реализации и со словарём
public abstract class ValueSaver<T> : PageObject where T : class {     private readonly Dictionary<string, string> _storage = new();       protected ValueSaver(IWebDriver webDriver) : base(webDriver)     {     }       public void SetStorage(Dictionary<string, string> storage)     {         _storage = storage ?? throw new ArgumentNullException(nameof(storage));     }       public T Save(string key, string value)     {         _storage.Add(key, value);         return this as T;     } }   public class Page : ValueSaver<Page> {     private const string Locator = "locator";       // Structure of PO     public Component ComponentA { get; }     public Component ComponentB { get; }       // State of PO     public int Count => // Some operation for getting state from IWebDriver       public Page(IWebDriver webDriver) : base(webDriver)     {         // Creates instances of components in memory of test app         ComponentA = new Component(webDriver);         ComponentB = new Component(webDriver);     }       public Page MethodA()     {         ComponentA.Method();         return this;     }       public Page MethodB()     {         ComponentB.Method();         return this;     } }   public class Component : PageObject {     private const string Locator = "locator";       // State of PO     public string Title => // Some operation for getting state from IWebDriver       public Component(IWebDriver webDriver) : base(webDriver)     {     }       public void Method()     {         // Some operation to change state of IWebDriver     } }   public class TestClass {     private Page _page;       [TestInitialize]     public void Initialize()     {         _page = new Page(driver);         _actualValues = new Dictionary<string, string>();         _page.SetStorage(_actualValues);     }       [TestMethod]     public void Test()     {         // Arrange         const string titleA = "A";         const string titleB = "B";         const string countA = "1";         const string countB = "2";         // Api calls, etc.           // Act         _page             .MethodA()             .Save(nameof(titleA), _page.ComponentA.Title)             .Save(nameof(countA), _page.Count.ToString())             .MethodB()             .Save(nameof(titleB), _page.ComponentB.Title)             .Save(nameof(countB), _page.Count.ToString());           // Assert         Assert.Equals(titleA, _actualValues[nameof(titleA)]);         Assert.Equals(titleB, _actualValues[nameof(titleB)]);         Assert.Equals(countA, _actualValues[nameof(countA)]);         Assert.Equals(countB, _actualValues[nameof(countB)]);     } }

Здесь хранилище устанавливается извне, чтобы не раздувать код примера методами по извлечению данных.

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

  • Продолжаем использовать строку, но для сохраняемых типов переопределяем ToString() и добавляем метод FromString(string str). Появляется много лишнего кода, и очень напоминает следующий вариант.

  • Сериализация/десериализация в json, xml, blob или любой нужный вам формат.

  • Используем object, не забывая про boxing/unboxing и необходимость приведения типов.

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

Решение 4. Атрибут

Отлично, решение №3 удовлетворяет Fluent Invokation: позволяет нам не разрывать цепочку вызовов при сохранении состояния, и нет out переменных. Но… Но мы сохраняем одно состояние за один вызов. Наверняка хотя бы раз у вас возникало желание сохранять состояние последовательно. Давайте развивать нашу идею с хранилищем дальше: в качестве значения теперь возьмем не просто строку или объект, а коллекцию. Список, очередь, стек — на ваш вкус и под ваши нужды. Теперь мы можем организовать хранение последовательности состояний по одному и тому же ключу. Зачем? Допустим, вам нужно протестировать работу фильтров для поиска данных. Фильтров несколько, и их можно комбинировать. Атомарно фильтры уже протестированы, нужно убедиться, что их совокупное применение корректно. И вот тут было бы удобно результаты поисковой выдачи фиксировать для разных комбинаций фильтров: ввели предикат — зафиксировали результаты поиска, ввели следующий предикат — зафиксировали результаты.

А как насчет нескольких состояний сразу? Например, при проверке языка локализации страницы было бы неплохо одним махом запомнить весь нужный текст, а не перебирать 30-50 элементов. Как же этого добиться? В этом нам поможет рефлексия. Создаем собственный атрибут и отмечаем им те места, которые хотим запоминать. Можем сразу в атрибуте указать желаемый ключ, по которому потом будем извлекать значения. В момент вызова метода сохранения состояний получаем нужные нам значения при помощи механизма рефлексии и сохраняем. Множество состояний за один вызов. Несколько вызовов — и вот мы уже сохранили последовательность изменений множества состояний. Извлекли коллекцию по ключу, воспользовались LINQ — и тест на локализацию страницы можно написать с проверкой в одну строчку:

public partial class Page {     [Collectable(Key = CollectableKeys.Page.Localization)]     public string About => // Some operation for getting state from IWebDriver               [Collectable(Key = CollectableKeys.Page.Localization)]     public string Ads => // Some operation for getting state from IWebDriver               [Collectable(Key = CollectableKeys.Page.Localization)]     public string Services => // Some operation for getting state from IWebDriver               [Collectable(Key = CollectableKeys.Page.Localization)]     public string HowSearchWorks => // Some operation for getting state from IWebDriver               [Collectable(Key = CollectableKeys.Page.Localization)]     public string Privacy => // Some operation for getting state from IWebDriver               [Collectable(Key = CollectableKeys.Page.Localization)]     public string Terms => // Some operation for getting state from IWebDriver               [Collectable(Key = CollectableKeys.Page.Localization)]     public string Settings => // Some operation for getting state from IWebDriver }
[TestMethod] public void Localization_Ru() {     // Arrange     var page = new Page(driver);           var expected = new List<string>()     {         Localization.For(Language.Ru).About,         Localization.For(Language.Ru).Ads,         Localization.For(Language.Ru).Services,         Localization.For(Language.Ru).HowSearchWorks,         Localization.For(Language.Ru).Privacy,         Localization.For(Language.Ru).Terms,         Localization.For(Language.Ru).Settings     };           expected = expected.OrderBy(value => value).ToList();           // Act     page.CollectValues();       var actual = page       .Storage       .By(CollectableKeys.Page.Localization)       .OrderBy(value => value)       .ToList();       // Assert     CollectionAssert.AreEqual(expected, actual); }

Увидеть пример целиком можно в моём github.

«Рефлексия? Непроизводительно!» — скажете вы и будете абсолютно правы. Однако, скорость работы механизмов рефлексии в памяти на порядки может отличатся от скорости работы сети в зависимости от условий. Мы говорим про системные тесты, а не про бенчмарки. По сравнению с секундами на ожидание загрузки страницы, рефлексия очень быстрая.

Решение 5. Декоратор

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

public class PropertyDecorator<T> {     private readonly Action<T> _getterAction;     private readonly Action<T> _setterAction;     private T _value;       protected PropertyDecorator(Action<T> getterAction = null, Action<T> setterAction = null)     {         _getterAction = getterAction;         _setterAction = setterAction;     }           public T Value     {         get         {             _getterAction?.Invoke(_value);             return _value;         }         set         {             _setterAction?.Invoke(value);             _value = value;         }     } }

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

Вместо заключения

Событийная модель с реализацией интерфейса INotifyPropertyChanged и перехват вызовов путем внедрения аспектов из АОП ввиду их сравнительной сложности не рассматривались, поскольку обоснованность их применимости в системных тестах мне кажется сомнительной. Наверняка можно придумать и другие интересные и довольно простые способы сохранения промежуточных значений, будет здорово, если вы поделитесь ими в комментариях.

Ещё больше моих упражнений по автотестированию можно найти на моём github.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Какой способ для вас выглядит более предпочтительным?
0% Разрыв цепочки вызовов 0
0% Out переменные 0
0% Словарь 0
0% Атрибут 0
0% Декоратор 0
0% Свой вариант (напишу в комментариях) 0
Никто еще не голосовал. Воздержавшихся нет.

ссылка на оригинал статьи https://habr.com/ru/company/bimeister/blog/682530/


Комментарии

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

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