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

Если ваше ПО проходит путь от прототипа до 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.

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