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