Fast-Unit или декларативный подход к юнит-тестам

    03 сентября

Всем привет! Меня зовут Юрий Скворцов, наша команда занимается автоматизированным тестированием в Росбанке. Одной из наших задач является разработка средств для автоматизации функционального тестирования.

В этой статье я хочу рассказать о решении, которое задумывалось как небольшая вспомогательная утилита для решения прочих задач, а в итоге превратилось в самостоятельный инструмент. Речь пойдет о фреймворке Fast-Unit, который позволяет писать юнит-тесты в декларативном стиле и превращает разработку юнит-тестов в конструктор компонентов. Проект разрабатывался в первую очередь для тестирования нашего основного продукта -Tladianta – единого BDD-фреймворка для тестирования 4-х платформ: Desktop, Web, Mobile и Rest.

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

На первом этапе мы попытались воспользоваться готовыми инструментами, такими как assertJ и Mockito, но быстро столкнулись с некоторыми техническими особенностями нашего проекта:

Tladianta уже использует JUnit4 как зависимость, что делает сложным использование другой версии JUnit и усложняет работу с Before;
Tladianta содержит компоненты для работы с различными платформами, в ней есть множество сущностей “крайне близких” с точки зрения функционала, но с разной иерархией и разным поведением;
многие компоненты требуют значительной предварительной настройки и могут «фонить» (влиять на результаты других тестов) при массовом прогоне;
некоторые компоненты фреймворка используют другие активно развивающиеся зависимости, и, к сожалению, мы далеко не всегда можем положиться на их надежность и гарантии обратной совместимости, а значит проверять нужно и их работу;
некоторую функциональность фреймворка приходится полностью блокировать при запуске юнит-тестов (например, запуск Appium во время тестов крайне нежелателен, тем более, что он закончится ошибкой, поскольку сервер для него никто не поднимал);
необходимость толстых обвязок, а также реализации ожиданий: Mockito тут нам помочь полноценно не смог.

Первый подход к снаряду

Изначально, когда мы только научились подменять драйвер, создавать фейковые элементы селениума и написали базовую архитектуру для тестовой обвязки, тесты выглядели примерно так:

@Test
public void checkOpenHint() {
ElementManager.getInstance().register(xpath,ElementManager.Condition.VISIBLE,
ElementManager.Condition.DISABLED);
new HintStepDefs().open(("Подсказка");
assertTrue(TestResults.getInstance().isSuccessful("Open"));
assertTrue(TestResults.getInstance().isSuccessful("Click"));
}

@Test
public void checkCloseHint() {
ElementManager.getInstance().register(xpath);
new HintStepDefs().close("Подсказка");
assertTrue(TestResults.getInstance().isSuccessful("Close"));
assertTrue(TestResults.getInstance().isSuccessful("Click"));
}

Или вообще вот так:

@Test
public void fillFieldsTestOld() {
ElementManager.getInstance().register(ElementManager.Type.CHECK_BOX,"//check-box","",
ElementManager.Condition.NOT_SELECTED);
ElementManager.getInstance().register(ElementManager.Type.INPUT,"//input","");
ElementManager.getInstance().register(ElementManager.Type.RADIO_GROUP,
"//radio-group","");
DataTable dataTable = new Cucumber.DataTableBuilder()
.withRow("Чекбокс", "true")
.withRow("Радио", "not selected element")
.withRow("Текстовое поле", "text")
.build();
new HtmlCommonSteps().fillFields(dataTable);
assertEquals(TestResults.getInstance().getTestResult("set"),
ElementProvider.getInstance().provide("//check-box").force().getAttribute("test-id"));
assertEqualsTestResults.getInstance().getTestResult("sendKeys"),
ElementProvider.getInstance().provide("//input").force().getAttribute("test-id"));
assertEquals(TestResults.getInstance().getTestResult("selectByValue"),
ElementProvider.getInstance().provide("//radio-group").force().getAttribute("test-id"));
}

В коде выше найти то, что именно тестируется, несложно, как и разобраться в проверках, но кода огромное количество. Если включить сюда софт проверки и описания ошибок, то читать станет очень сложно. А мы всего лишь пытаемся проверить, что метод вызвался у нужного объекта, при том что реальная логика проверок крайне примитивна. Для того чтобы написать такой тест, надо знать про ElementManager, ElementProvider, TestResults, TickingFuture (обертка, чтобы реализовать изменение состояния элемента в течении заданного времени). Эти компоненты отличались в разных проектах, мы не успевали синхронизировать изменения.

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

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

Меняем философию

Если взглянуть на код в целом, можно увидеть, что многие блоки кода повторяются “без смысла”. Мы тестируем методы, но постоянно используем конструкторы (чтобы избежать вероятности кэширования какой-либо ошибки). Первое преобразование – мы вынесли проверки и генерацию тестируемых инстансов в аннотации.

@IExpectTestResult(errDesc = "Не был вызван метод set", value = "set",
expected = "//check-box", convertedBy = Converters.XpathToIdConverter.class, soft = true)
@IExpectTestResult(errDesc = "Не был вызван метод sendKeys", value = "sendKeys",
expected = "//input", convertedBy = Converters.XpathToIdConverter.class, soft = true)
@IExpectTestResult(errDesc = "Не был вызван метод selectByValue", value = "selectByValue",
expected = "//radio-group", convertedBy = Converters.XpathToIdConverter.class, soft = true)
@Test
public void fillFieldsTestOld() {
ElementManager.getInstance().register(ElementManager.Type.CHECK_BOX, "//check-box", "",
ElementManager.Condition.NOT_SELECTED);
ElementManager.getInstance().register(ElementManager.Type.INPUT, "//input", "");
ElementManager.getInstance().register(ElementManager.Type.RADIO_GROUP,
"//radio-group", "");
DataTable dataTable = new Cucumber.DataTableBuilder()
.withRow("Чекбокс", "true")
.withRow("Радио", "not selected element")
.withRow("Текстовое поле", "text")
.build();
runTest("fillFields", dataTable);
}

Что изменилось?

Проверки были делегированы отдельному компоненту. Теперь не надо знать о том, как хранятся элементы, результаты тестов.
Что не менее важно: errDesc является обязательным полем, так что теперь даже не знакомый с кодом человек может сразу увидеть суть проверки.
Исчез конструктор, а определить при ревью, что именно проверяется, стало проще – это всегда один и тот же метод runTest, теперь уже не требуется вникать в код, чтобы понять какой метод проверяется.
Невозможно перепутать ожидаемое и реальное значение.
Софт-проверки теперь определяются просто параметром аннотации, так что использовать их становится гораздо удобнее.

Такая форма записи нам понравилась, и мы решили таким же образом упростить другой сложный компонент – генерацию элементов. Большая часть наших тестов посвящена готовым степам, и мы должны быть уверены, что они корректно работают, однако для таких проверок необходимо полностью “запустить” фейковое приложение и наполнить его элементами (напомним, что речь идет про Web, Desktop и Mobile, инструменты для которых отличаются достаточно сильно).

@IGenerateElement(type = ElementManager.Type.CHECK_BOX)
@IGenerateElement(type = ElementManager.Type.RADIO_GROUP)
@IGenerateElement(type = ElementManager.Type.INPUT)
@Test
@IExpectTestResult(errDesc = "Не был вызван метод set", value = "set",
expected = "//check-box", convertedBy = Converters.XpathToIdConverter.class, soft = true)
@IExpectTestResult(errDesc = "Не был вызван метод sendKeys", value = "sendKeys",
expected = "//input", convertedBy = Converters.XpathToIdConverter.class, soft = true)
@IExpectTestResult(errDesc = "Не был вызван метод selectByValue", value = "selectByValue",
expected = "//radio-group", convertedBy = Converters.XpathToIdConverter.class, soft = true)
public void fillFieldsTest() {
DataTable dataTable = new Cucumber.DataTableBuilder()
.withRow("Чекбокс", "true")
.withRow("Радио", "not selected element")
.withRow("Текстовое поле", "text")
.build();
runTest("fillFields", dataTable);
}

Теперь код теста превратился в полностью шаблонный, параметры явно видны, а вся логика вынесена в шаблонные компоненты. Дефолтные свойства позволили убрать пустые строки и дали широкие возможности для перегрузок. Этот код почти соответствует BDD-подходу, предусловие, проверка, действие. К тому же, из логики тестов вылетели все обвязки, больше не нужно знать про менеджеры, хранилища тестовых результатов, код прост и легко читаем. Поскольку аннотации в Java почти не кастомизируются, мы ввели механизм конвертеров, которые из строки могут получать итоговый результат. Этот код не только проверяет сам факт вызова метода, но и id элемента, который его выполнил. Почти все существовавшие на тот момент тесты (более 200 единиц) мы достаточно быстро перевели на эту логику, приведя их к единому шаблону. Тесты стали тем, чем они должны быть – документацией, а не кодом, так мы пришли к декларативности. Именно этот подход лег в основу Fast-Unit – декларативность, самодокументируемость тестов и изоляция тестируемого функционала, тест полностью посвящен проверке одного тестируемого метода.

Продолжаем развитие

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

Package-generate – отработка аннотаций, связанных с package-info. Компоненты, связанные с ними, обеспечивают загрузку конфигураций и общую подготовку обвязки.
Class-generate – отработка аннотаций, связанных с классом теста. Здесь выполняются конфигурационные действия, относящиеся к фреймворку, адаптируя его к подготовленной обвязке.
Generate – отработка аннотаций, связанных с самим методом теста (точкой входа).
Test – подготовка инстанса и выполнение тестируемого метода.
Assert – выполнение проверок.

Обрабатываемые аннотации описываются примерно так:

@Target(ElementType.PACKAGE) //аннотация является пакетной
@IPhase(value = "package-generate", processingClass = IStabDriver.StabDriverProcessor.class,
priority = 1) //указываем фазу и обработчик (обычно обработчик регистрируется в том же классе)
public @interface IStabDriver {

Class<? extends WebDriver> value(); //здесь принимаем класс драйвера, который будет загружен вместо настоящего

class StabDriverProcessor implements PhaseProcessor<IStabDriver> { //класс обработчик
@Override
public void process(IStabDriver iStabDriver) {
//подмена драйвера фейковым
}
}
}

Особенность Fast-Unit в том, что жизненный цикл можно переопределить для любого класса – он описывается аннотацией ITestClass, которая предназначена для указания тестируемого класса и фаз. Список фаз указывается просто как строковый массив, допуская смену состава и последовательность фаз. Методы, обрабатывающие фазы, находятся так же с помощью аннотаций, поэтому возможно создать в своем классе необходимый обработчик и пометить его (плюс к этому — доступно переопределение в рамках класса). Большим плюсом стало то, что такое разделение позволило разделить тест на слои: если ошибка в готовом тесте произошла в рамках фазы package-generate или generate, значит повреждена тестовая обвязка. Если class-generate – есть проблемы в конфигурационных механизмах фреймворка. Если в рамках test – ошибка в тестируемом функционале. Фаза test технически может выдавать ошибки как в обвязке, так и в тестируемом функционале, поэтому возможные ошибки обвязки мы обернули в специальный тип – InnerException.

Каждая фаза является изолированной, т.е. не зависит и не взаимодействует напрямую с другими фазами, единственное что передается между фазами это ошибки (большинство фаз будут пропущены, если в предыдущих возникла ошибка, однако это необязательно, например, фаза assert сработает в любом случае).

Здесь, наверное, уже возник вопрос, а откуда берутся инстансы тестирования. Если конструктор пустой, это очевидно: с помощью Reflection API просто создается инстанс тестируемого класса. Но как в этой конструкции передавать параметры или выполнять настройку инстанса после срабатывания конструктора? Что делать, если объект строится билдером или вообще речь идет о тестировании статики? Для этого разработан механизм провайдеров, которые скрывают за собой сложность конструктора.

Параметризация по умолчанию:

@IProvideInstance
CheckBox generateCheckBox() {
return new CheckBox((MobileElement) ElementProvider.getInstance().provide("//check-box")
.get());
}

Нет параметров – нет проблем (мы тестируем класс CheckBox и регистрируем метод, который и будет создавать инстансы для нас). Поскольку здесь переопределен дефолтный провайдер, в самих тестах не потребуется добавлять ничего, они автоматически будут использовать этот метод как источник. Этот пример явно иллюстрирует логику Fast-Unit — скрываем сложное и ненужное. С точки зрения теста, совершенно неважно как и откуда возьмется мобильный элемент, оборачиваемый классом CheckBox. Нам важно лишь, что существует некоторый объект CheckBox, удовлетворяющий заданным требованиям.

Автоматическая инъекция аргументов: положим у нас есть вот такой конструктор:

public Mask(String dataFormat, String fieldFormat) {
this.dataFormat = dataFormat;
this.fieldFormat = fieldFormat;
}

Тогда тест этого класса с использованием инъекции аргументов будет выглядеть вот так:

Object[] dataMask={"_:2_:2_:4","_:2/_:2/_:4"};

@ITestInstance(argSource = "dataMask")
@Test
@IExpectTestResult(errDesc = "Неверно сконвертировано значение", value = FAST_RESULT,
expected = "12/10/2012")
public void convert() {
runTest("convert","12102012");
}


Именованные провайдеры

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

@IProvideInstance("Дата")
Mask createDataMask(){
return new Mask("_:2_:2_:4","_:2/_:2/_:4");
}

@ITestInstance("Дата")
@Test
@IExpectTestResult(errDesc = "Неверно сконвертировано значение", value = FAST_RESULT,
expected = "12/10/2012")
public void convert() {
runTest("convert","12102012");
}

IProvideInstance и ITestInstance – связанные аннотации, позволяющие указать методу, откуда брать тестируемый инстанс (для статики просто возвращается null, поскольку в конечном итоге этот инстанс используется через Reflection API). Подход с провайдерами дает гораздо больше информации о том, что на самом деле происходит в тесте, заменяя вызов конструктора с какими-то параметрами на текст, описывающий, предварительные условия, так что, если конструктор вдруг поменяется, мы должны будем лишь поправить провайдер, но тест останется неизменным, пока не поменяется реальный функционал. Если же при ревью, вы увидите несколько провайдероов, вы обратите внимание на разницу между ними, а значит и особенности поведения тестируемого метода. Даже совершенно не зная фреймворка, а лишь зная принципы работы Fast-Unit, разработчик сможет прочесть код теста и понять что делает тестируемый метод.

Выводы и итоги

У нашего подхода оказалось немало плюсов:

Легкая переносимость тестов.
Сокрытие сложности обвязок, возможность их рефакторинга без разрушения тестов.
Гарантия обратной совместимости — изменения имен методов будут зафиксированы как ошибки.
Тесты превратились в достаточно подробную документацию для каждого метода.
Качество проверок значительно выросло.
Разработка юнит-тестов стала конвейерным процессом, а скорость разработки и ревью значительно выросла.
Стабильность разработанных тестов – хотя и фреймворк, и сам Fast-Unit активно развиваются, деградации тестов не происходит

Несмотря на кажущую сложность, мы смогли достаточно быстро внедрить этот инструмент. Сейчас на нем написана большая часть юнитов, и они уже подтвердили надежность при достаточно сложной и объемной миграции, смогли выявить достаточно сложные дефекты (например, в ожиданиях элементов и проверках текста). Мы смогли достаточно быстро ликвидировать технический долг и наладить эффективную работу с юнитами, сделав их неотъемлемой частью разработки. Сейчас мы рассматриваем варианты более активного внедрения этого инструмента и в другие проекты, за пределами нашей команды.

Текущие проблемы и планы:

Рефлексивные вызовы создают некоторую сложность, поскольку не работает прямое перемещение в редакторе и убиваются примитивы. Мы рассматриваем варианты замены рефлексии на интерсепторы, однако при этом потеряются проверки приватных методов (сейчас фаст-юнит игнорирует модификаторы доступа тестируемых методов).
Обработчики фаз на данный момент могут обрабатывать только одну фазу.
Обработчики фаз определяются однозначно и не могут быть переопределены.
На данный момент написано достаточно мало готовых компонентов, то есть сейчас инструмент является в первую очередь именно ядром юнит-тестов.
На данный момент Fast-Unit реализован на базе junit4, но в ближайшее время мы собираемся добавить поддержку junit5 и testng

Подписка на новости