Разделы портала

Онлайн-тренинги

.
Цена регресса. Как мы организовали инфраструктуру для Е2Е-тестов
30.09.2021 00:00

Автор: Боков Максим
Оригинальная публикация

Мы, команда автоматизации Страхового Дома ВСК, подготовили небольшой рассказ о нашей инфраструктуре. Эта статья может быть входной точкой для специалистов, желающих внедрить автотестирование у себя в компании. Расскажем, какие системы, паттерны и фреймворки можно использовать, а также как интегрировать это в релизный цикл. Материал подойдёт как юным автоматизаторам, так и тем, кто желает поближе познакомиться с темой. Большое внимание уделим не только абстрактным вопросам, но и организации кода проекта. Добро пожаловать под кат.






Изложение поделено на две логические части. В первой опишем инфраструктуру и процесс, а во второй рассмотрим некоторые детали написания кода. Но для начала немного обрисуем рабочую среду:

  • задачи ведём в Jira;

  • тест-кейсы и тест-планы готовим в TestRail;

  • очередь запускаемых тестов определяется Sprut;

  • за деплой отвечает Azure DevOps;

  • за виртуалки в докере — Selenoid;

  • стек разработки автотестов — C# .NET 5.0.

Кому и зачем нужна команда автоматизаторов

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

Т. к. команда ручного тестирования является нашим заказчиком, мы постарались максимально прозрачно интегрироваться в их процесс.

Страховой Дом ВСК использует TestRail как систему управления тестированием. Выполнение регрессионного тестирования сводится к следующим простым этапам:

  1. Создание тестового плана, где выбирается необходимый список тестов. Сюда попадают как обычные, так и автоматизированные кейсы.

  2. Запуск тестов. После создания плана наша система (о ней чуть позже) распознаёт автоматизированные кейсы, ставит их в очередь и результат прогона возвращает обратно в TestRail.

  3. Получение результатов. После завершения всех тестов тестлид видит общий отчёт по состоянию ручного и автоматического тестирования.

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





Инфраструктура и автотестовый дашборд

Как уже упоминалось, процессом запуска автотестов руководит отдельная система. Многие компании предпочитают разрабатывать подобную инфраструктуру самостоятельно, чтобы лучше контролировать процесс. Мы не исключение, поэтому знакомьтесь со Sprut.

В первую очередь на Sprut возложена работа с очередью запускаемых тестов, мониторингом и управлением процессами. (За деплой отвечает Azure DevOps, а за виртуалки в докере — Selenoid. В будущем планируем внедрить автодеплой при запуске.) Также Sprut выполняет постобработку результатов прогонов: классификацию и кластеризацию. Из-за того, что тестам свойственно падать пачками, Sprut предоставляет прогон тестплана в сгруппированном по ошибкам виде. Автотестеру проще работать с такой системой, чем с TestRail.





Для каждого упавшего теста можно посмотреть логи, скриншоты и другие артефакты. Если тест упал из-за Assert, система относит инцидент к категории «Ошибка ПО» (т. е. не прошла целевая проверка). В силу природы самих Е2Е-тестов падения, которые случились не в секции Assert, попадают в отдельную категорию, где дежурный разработчик тестов уже проводит анализ, т. к. нельзя однозначно определить, это «Ошибка в автотесте» или «Ошибка ПО».

В ходе анализа Sprut позволяет сразу создать тикет в Jira и назначить ответственного. После исправления автотестов система позволяет выполнить перезапуск не прошедших ранее кейсов, а результат аккумулируется с предыдущим раном в тестовом плане. Так за несколько итераций мы можем разобрать все проблемы.

О сложности Е2Е-тестов

Проект автотестов — это такой же программный продукт, код которого нужно писать и поддерживать. И этот код следовало бы тоже тестировать, но тогда мы войдём в бесконечную рекурсию (тесты, которые тестируют тесты, которые тестируют...). Е2Е-тесты по своей сути обеспечивают самый высокий уровень защиты от багов, но являются очень хрупкими и нестабильными. Поэтому мы подошли к архитектуре со всей ответственностью и разработали структуру, о которой дальше пойдёт речь.





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

Для наглядности предлагаю разработать автотест для следующего упрощённого кейса:

  1. Пользователь заходит на страницу поиска авто.

  2. Вбивает несуществующий, но валидный номер паспорта ТС (транспортного средства).

  3. Указывает регистрационный номер ТС.

  4. Жмёт кнопку «Поиск».

Ожидаемый результат: появляется сообщение с предупреждением «Автомобиль не существует в базе».

Модель данных

В нашей системе модель содержит всю необходимую для выполнения теста информацию. Мы выделяем два основных типа моделей:

Общие модели

Это классы, описывающие бизнес-объекты тестируемого приложения и принципы генерации их полей. Например: ФЛ (физическое лицо), Паспорт, Договор.

Модели менеджера

Являются композицией общих моделей и включают некоторую служебную информацию теста и специфичные данные для конкретного тестового метода (например, TestID).

Создадим для нашего примера общую модель:

public sealed class CarData {
   public string PasportNumber { get; set; }
       = RandomString.GenerateByTemplate("00DD 0000");
  public string RegNumber { get; set; };
}

Обратите внимание, что значение свойства по умолчанию генерируется случайным образом. Такой подход позволяет нам избегать коллизий и тестировать систему с широким диапазоном входных данных.

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

public sealed class CarFindData : ModelBase {
   public CarData Car { get; set; }
       = new CarData();
}

ModelBase содержит определение служебных полей: TestID, Login, Password. Но прежде чем мы перейдём к написанию теста — ещё пара слов о модели.

Для каждого тестового метода мы подготавливаем отдельный XML-файл, который будет десериализоваться в конкретный объект, переопределяя интересующие свойства. Так, для нашего теста создадим файл, где будет переопределённый ID теста:

<?xml version="1.0" encoding="utf-8" ?>
<ArrayOfCarFindData xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
	<CarFindData>
	   <TestID>19773</TestID>
	</CarFindData>
</ArrayOfCarFindData>

В большинстве случаев нас будут устраивать значения свойств по умолчанию. Но если тесты похожи, мы можем переиспользовать тестовый метод путём вставки в XML дополнительных блоков. В XML мы указываем только специфичные для данного теста свойства. Этот трюк во многом облегчает поддержку: для изменения входных данных не требуется знаний языка программирования и можно безболезненно расширять модели CarFindData и CarData.

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

<?xml version="1.0" encoding="utf-8" ?>
<ArrayOfCarFindData xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<CarFindData>
   <TestID>19773</TestID>
</CarFindData>
<CarFindData>
   <TestID>19774</TestID>
   <Car>
     <PasportNumber>62ОК 112112</PasportNumber>      
     <RegNumber>a222cc750</RegNumber>
   </Car>  
</CarFindData>
<CarFindData>
   <TestID>19775</TestID>
   <Car>  
     <RegNumber>р333оо84</RegNumber>
   </Car>  
</CarFindData>
</ArrayOfCarFindData>

Тестирование UI

Мы выделяем три основных типа объектов:

  • Тестовый класс. Содержит тестовые методы, схожие по шагам и тестовым параметрам.

  • Менеджер. Предоставляет набор законченных логических операций — шагов теста в конкретной области приложения или отдельных проверок результатов.

  • FormObject/PageObject/APIObject. Инкапсулируют сложность взаимодействия с интерфейсом, предоставляя атомарные действия над элементами страницы. Для APIObject это может быть поиск конкретных значений по тегам в ответе сервиса.

Теперь напишем код теста для нашего примера:

[TestFixture]
public sealed class CarFindTest : BaseTest {
 
   public IEnumerable<CarFindData> NotFoundWarningWhenNotFoundCarDataFromXML() {
       return ModelDataFromXmlFile<CarFindData>("NotFoundWarningWhenNotFoundCar.xml");
   }
 
   [Test, TestCaseSource(nameof(NotFoundWarningWhenNotFoundCarDataFromXML))]
   public void NotFoundWarningWhenNotFoundCarTest(CarFindData data) {
       Main.Manager.Login(data)
           .OpenFindCar()
           .FindCar(data)
           .CheckCarNotFound();
   }
}

Мы разбиваем тестовый метод на два этапа. Этап подготовки данных читает XML-файл, который мы создали ранее, и возвращает список десериализованных моделей. Метод NotFoundWarningWhenNotFoundCarTest будет запущен для каждой модели из списка, что позволяет автоматизировать сразу несколько похожих тест-кейсов, используя один и тот же код.

Для описания последовательности шагов мы пользуемся Fluent-синтаксисом, что в совокупности с IntelliSense делает написание кода таким же лёгким, как и его чтение. Main.Manager является точкой входа для работы с любыми другими менеджерами. Код в нём может содержать действия корректной инициализации и будет специфичен для каждого отдельного приложения. Его мы рассматривать не будем и перейдём сразу к созданию менеджера страницы поиска автомобиля:

public sealed class CarFindManager : BaseManager {
   private readonly CarFindPage _carFindPage;
 
   public CarFindManager(){
       _carFindPage = new CarFindPage();
   }
 
   public CarFindManager FindCar (CarFindData data) {
       _carFindPage.InputPasportNumber(data.PasportNumber);
       If (data.RegNumber!=String.Empty)
       {
         _carFindPage.InputRegNumber(data.RegNumber);
       }
       _carFindPage.FindButton.Click();
    return this;
    }
 
   public CarFindManager CheckCarNotFound() {
       Assert.AreEquals(_carFindPage.Warning.Text, "Автомобиль не существует в базе");
       return this;
   }
}

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

Реализация CarFindPage зависит от конкретного типа приложения. В наших проектах мы используем два богатых фреймворка: Selenium (для работы с браузером) и FlaUI (для тестирования WinForm). Более подробно о них вы можете узнать в других статьях на Хабре.

Итак, мы рассмотрели в общем виде структуру наших проектов. Хороший автотест должен содержать простой, понятный и стабильный код. А каждый метод — писаться с мыслью о повторном использовании в будущем. Такая архитектура позволяет успешно масштабировать количество тестов за счёт переиспользования кода. Чтобы с ростом проекта код оставался таким же чистым, нужно не забывать о таких практиках, как:

  • обязательное code review,

  • единый code style и конвенция наименования типов.

Пишем логи правильно

Ещё одна важнейшая характеристика автотеста — его максимальная прозрачность и понятность. Т. к. с результатами прогона теста работают не только его создатели, требуется, чтобы ошибка была понятна и тестировщику, и разработчику без дополнительных пояснений. Это экономит гигантское количество времени для всей компании.

Добиваемся данной цели следующими способами:

  • отделяем целевые проверки от шагов,

  • автоматически делаем скриншот в момент падения теста,

  • логируем каждое действие теста.

Первые два пункта более-менее очевидны, а про третий мы бы хотели рассказать поподробнее. Вот так выглядит наш файл с логом:



Помимо этого лога формируется лог для Allure
Помимо этого лога формируется лог для Allure



Такой лог очень легко прочесть. Как можно заметить, в него попадают шаги вышеупомянутых менеджеров и действия с FormObject. Чтобы наглядно продемонстрировать, как в коде выглядит объявление этих шагов, модифицируем наш предыдущий пример:

[TraceAspect(Level.Manager)]
public sealed class CarFindManager : BaseManager {
   private readonly CarFindPage _carFindPage;
 
   public CarFindManager(){
       _carFindPage = new CarFindPage();
   }
 
   /// <summary>
   /// Выполнить поиск автомобиля по номеру паспорта {data.PasportNumber}
   /// </summary>
   /// <returns></returns>
   public CarFindManager FindCarByPasportNumber(CarFindData data) {
       _carFindPage.InputPasportNumber (data.PasportNumber);
       _carFindPage.FindButton.Click();
       return this;
   }
 
   /// <summary>
   /// Проверить, что появилось предупреждение о том,
   /// что автомобиля не существует в бд
   /// </summary>
   /// <returns></returns>
   public CarFindManager CheckCarNotFound() {
       Assert.AreEquals(_carFindPage.Warning.Text, "Автомобиль не существует в базе");
       return this;
   }
}

Нам всего лишь пришлось добавить один атрибут и обычные комментарии к методам. Атрибут TraceAspect сообщает, что для данного класса нужно сгенерировать код, отвечающий за логирование шагов. Такой трюк возможен с помощью NuGet-пакета Aspect Injector. А сам текст шага будет взят из XML-документации, которую можно сгенерировать на этапе билда.

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

Для добавления этого функционала к любому PageObject необходимо прописать аннотацию [NotNullArgumentAspect] к этому классу.

Алгоритм работы следующий:

  1. Если применено не к наследнику PageBase — будет вызвано исключение.

  2. Если метод не публичный, или тип результата метода не совпадает с PageObject, в котором хранится метод, или если это вообще не метод, а LINQ-запрос — управление передаётся методу без дополнительных проверок.

  3. Проверяются все аргументы, переданные в метод. Если хотя бы один из них равен null — записывается сообщение в лог и метод не выполняется (возвращается корректный PageObject). Если с аргументами всё хорошо — метод выполняется.

Подводим итоги

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

Мы постарались показать вертикальный срез нашей инфраструктуры тестирования. Невозможно в рамках одной статьи рассказать обо всех нюансах. Мы готовы ответить на ваши вопросы в комментариях и будем рады, если вы расскажете о своих техниках: как справляетесь с теми или иными проблемами.

Обсудить в форуме