Как стать автором
Обновить
52.27
Bimeister
Цифровизация промышленных объектов

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

Время на прочтение8 мин
Количество просмотров2.1K

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

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

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

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

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

// 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 Invoсation. Насколько такие тесты соответствует теории тестирования, пусть останется за рамками наших рассуждений, давайте сосредоточимся на практике.

Решение 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 Invocation: позволяет нам не разрывать цепочку вызовов при сохранении состояния, и нет 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.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Какой способ для вас выглядит более предпочтительным?
33.33% Разрыв цепочки вызовов2
16.67% Out переменные1
0% Словарь0
16.67% Атрибут1
33.33% Декоратор2
0% Свой вариант (напишу в комментариях)0
Проголосовали 6 пользователей. Воздержались 5 пользователей.
Теги:
Хабы:
Всего голосов 4: ↑4 и ↓0+4
Комментарии0

Публикации

Информация

Сайт
bimeister.com
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия