
Вместо введения
Если ваше ПО проходит путь от прототипа до 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.
ссылка на оригинал статьи https://habr.com/ru/company/bimeister/blog/682530/
Добавить комментарий