MVVM и МBT в контексте автоматизации UI
Реактивные интерфейсы уже более 5 лет являются индустриальным стандартом в мире Frontend разработки. В данной статье будет продемонстрировано применение некоторых идей из этой сферы для решения задач автоматизации UI.
Проблематика
Пожалуй, самое простое, что есть в задачах автоматизации тестирования — это собственно автоматизация тестирования. Куда больше времени и энергии уходит на решение сопутствующих проблем, таких как:
Унификация тест-дизайна
Верификация автоматизации
Поддержка автоматизации
Остановимся на каждой из них более подробно.
Унификация тест-дизайна
Как правило, тест-кейсы пишутся на естественном языке аналитиками либо QA-инженерами, без каких-либо формальных правил и нотаций. Крайне редко можно наблюдать ситуацию, когда тест-дизайн выполнен в соответствии с правилами тест-дизайна.
Другими словами, по имени кейса не понятно, что именно и как он проверяет: на один кейс приходится большое количество различных проверок, много дублирований, отсутствие иерархической структуры. Это объясняется тем, что человеку быстрее и удобнее написать один большой кейс, нежели много маленьких. Ситуация усугубляется, когда над проектом трудятся сразу несколько тест-дизайнеров: каждый из них будет описывать тестируемое поведение немного по-своему.
Прежде чем приступить к автоматизации, автоматизатор должен унифицировать тест-дизайн, привести его к единому виду, структурировать. Без этих действий проводить автоматизацию — все равно что класть краску на поверхность, покрытую пылью. Создается лишь внешний вид законченной работы, но в скором времени все приходится переделывать, ведь в таком случае автоматизация больше создает проблем, чем решает.
Верификация автоматизации
Автоматизация начинает приносить реальную пользу только тогда, когда она выполнена очень качественно. Важно, чтобы все условия проверялись в точности так, как написано, а не так, как удобнее было бы реализовать.
Приведу пример: представим таблицу со списком строк, последний столбец которой содержит кнопку «удалить». Нужно проверить, удаляется ли строка из таблицы при нажатии на эту кнопку. «Простой» вариант автоматизации в данном случае — получить количество строк до и после удаления, а потом сравнить, уменьшилось ли количество на одну строку. Тогда как правильно было бы получить модель таблицы до и после удаления и получить строку, которая будет удалена, а затем сравнить модели таблицы, удостовериться, что удалена именно требуемая строка, а остальное осталось неизменным.
В первом, простом варианте мы потенциально можем пропустить несколько багов, например — баг, при котором будет удалена не та строка, по которой был клик. Такие ситуации — когда автотест не корректно проверяет семантику действия — встречаются довольно часто, и их обязательно нужно исправлять. В противном случае автоматизации нельзя будет доверять, и регресс нужно будет проводить вручную.
Поддержка автоматизации
Поддержка и сопровождение автотестов является одной из самых «больных» тем в данной сфере. Я неоднократно сталкивался с ситуацией, когда на адаптацию автотестов уходит намного больше человеко-часов, нежели на внесение изменений в код разработки. Автотесты отстают, не успевают адаптироваться к изменениям.
На практике это выглядит следующим образом: стартует регресс, запускаются автотесты, часть из которых падает, их начинают анализировать/править/перезапускать. И этот процесс отнимает больше ресурсов, чем прогон всего регресса вручную. А прогон делать надо, и автоматизаторы вынуждены проводить его вручную.
Чтобы избежать этих проблем, дизайн автотестов должен быть спроектирован с учетом возможности к будущим адаптациям, а именно — не должно быть нарушений принципа DRY. Должна быть организована иерархия зависимостей и последовательность запуска. Все проблемы с селекторами (в UI тестах) и атомарными функциями должны быть отображены на этапе Smoke тестов.
Одним из возможных решений проблем, перечисленных выше, являются декларативные подходы применительно к автоматизации, речь о которых пойдет ниже.
Model-Based Testing (MBT)
MBT — это декларативный подход к тест-дизайну. Предполагается, что разработчику-автоматизатору необходимо описать модель приложения, на основе которой тестовый Фреймворк сгенерирует необходимые тесты. Техника, когда тесты генерируются, а не создаются вручную, называется DDT.
DDT (Data Driven Testing) — техника тест-дизайна, когда один метод выступает в качестве параметризованного шаблона. Тесты генерируются на основе коллекции данных, применяемых к методу-шаблону.
MBT является расширением над DDT: при DDT данные для теста описываются вручную, а при MBT они генерируются на основе модели.
MBT для Rest API тестов
В качестве модели для Rest API тестов вполне можно взять DTO-объекты запросов, добавив к их полям некоторую мета-информацию о семантике данных полей, например:
Обязательность поля
Список допустимых значений (для перечеслений)
Максимальное/минимальное значение (для чисел)
Максимальная длина строки
Помимо описания значения атрибутов DTO объекта, для успешной работы необходимо реализовать маппинг объекта-запроса на объект-ответа. После чего можно реализовать алгоритм генерации позитивных и негативных тестов.
В рамках данной статьи мы не будем подробно рассматривать вопросы, связанные с API тестами. Сконцентрируемся на UI тестах.
MBT для UI тестов
Логику взаимодействия UI удобно описывать графовой моделью, вершинами для которой будут страницы тестируемого приложения, а ребрами — действия, по которым будут осуществляться переходы с одной страницы на другую.
Для случаев, когда на странице есть элементы ввода, и существует несколько различных состояний, в которых может быть приложение в рамках одной страницы (в зависимости от действий, совершенных пользователем), удобно использовать MVVM.
MVVM (Model View — View Model) или Реактивный UI — это подход к реализации UI, при котором изменения в модели автоматически применяются на View. Для этого в коде View должны быть ассоциации между значениями UI элементов и атрибутами модели.
Итак, модель для UI тестов удобно описывать в двух нотациях, в зависимости от функционала. Когда происходит навигация/смена состояния через нажатие на UI элементы, используем графы. Когда пользовательский ввод — через MVVM.
Графы и навигация
Для реализации возможности автоматической навигации по страницам приложения необходимо, чтобы тестовый фреймворк умел определять текущую страницу и умел находить кратчайший маршрут от текущей страницы до требуемой. Давайте представим архитектуру одного из возможных решений, где в качестве языка выберем Kotlin (это не принципиально, здесь подойдет любой современный язык).
interface PathBuilder {
fun findPath(from: Page, to: Page): Collection
}
interface Navigator {
val pages: MutableList
val pathBuilder: PathBuilder
fun register(page: Page) = pages.add(page)
val current: Page
get() = pages.firstOrNull { it.isPresent }
?: throw Exception("Current page was not found")
fun navigate(to: Page) = pathBuilder.findPath(
current, to
).forEach {
it.action()
}
}
interface Page {
val navigations: Collection
val isPresent: Boolean
}
open class NavEdge(
val from: Page,
val to: Page,
val action: ()->Unit
)
Разберем каждый из классов более подробно.
Page - выполняет роль вершины в графе навигации. Методы и свойства:
isPresent — предназначен для определения факта наличия страницы на экране
navigations — это ребра графа навигации, перечисляющие страницы, в которые можно перейти из данной страницы
NavEdge — инкапсулирует одно ребро графа навигации, где:
from — страница, от которой идет навигация
to — страница, куда приводит
action — лямбда-функция, которую необходимо выполнить для осуществления навигации
PathBuilder — интерфейс, предполагающий реализацию алгоритма поиска кратчайшего пути на графе. Для JVM-Based языков данный интерфейс может быть реализован при помощи библиотеки JGraph.
Navigator — интерфейс, отвечающий за навигацию. Предполагается, что в приложении будет один объект класса, реализующего данный интерфейс. Все страницы приложения будут добавлены в объект данного класса посредством вызова метода register. Метод current будет возвращать текущую страницу, а метод navigate будет осуществлять навигацию до требуемой страницы.
Когда выше приведенные интерфейсы будут реализованы, можно будет с легкостью сгенерировать тесты, которые будут проверять возможность навигации до каждой страницы из списка Navigator.pages.
MVVM
Аналогично примеру с Навигатором давайте спроектируем базовые классы для реализации данной техники, но сначала, для лучшего понимания, приведем пример ее использования.
В направлении Model-View, когда модель проецируется на отображение:
data class LoginModel(
var login: String = "",
val password: String = ""
)
val model = LoginModel()
model.login = "login"
model.password = "password"
Здесь, при выполнении 7-ой строчки, предполагается, что в поле формы, например, для браузера через Selenium будет введена строка «login». После чего в 8-ой строчке — в другое поле формы — будет введена строка «password».
Пример использования View-Model, когда мы читаем в модель то, что отображено в UI, выглядит аналогично, с той лишь разницей, что модель будет находиться слева от оператора присваивания.
Итак, приступим к реализации:
interface Selector {
fun toXpath(): String
fun input(value: String)
fun click()
}
abstract class Model {
fun input(selector: Selector, default: String = "") =
Delegates.observable(default) { _, _, new ->
selector.input(new)
}
abstract fun submit()
}
Здесь интефейс Selector представляет некий элемент UI, над которым может быть произведено действие. Реализацией может быть класс-обертка над WebElement.
Класс Model — базовый для определения модели, где input — это делегат, который по изменению значения атрибута вызывает функцию ввода текста в Селектор.
Далее, приведем POM с примером использования вышеприведенного кода.
POM (Page Object Model) — структурный паттерн, предлагающий организацию селекторов в классы, группируя их по страницам и блокам, к которым они принадлежат в UI.
object LoginPage {
val inputUsername = HTML.input(id="username")
val inputPassword = HTML.input(id="password")
val submit = HTML.submit()
class LoginModel: Model() {
var username = input(inputUsername)
var password = input(inputPassword)
override fun submit() {
submit.click()
}
}
}
Использовать данный класс можно следующим образом:
LoginPage.LoginModel().password = "test_password"
Кроме удобного интерфейса для операций ввода у данного подхода есть еще одно, не менее важное преимущество — возможность вынесения тестовых данных во внешние хранилища и чтение их через стандартные механизмы сериализации.
Далее будет приведен более наглядный пример совместного использования вышеперечисленных подходов.
Постановка задачи
Давайте представим приложение, состоящее из 4 форм. Первые две формы — это авторизация. СМС для пользователей на второй форме будем считать как второй пароль, т.е. неизменным. Далее — список существующих элементов и форма редактирования/добавления. Стандартный CRUD.
Для доступа к 3 и 4 формам необходимо пройти авторизацию. Все поля ввода, кроме Nickname, являются обязательными. В случае, когда обязательное поле не заполнено, рядом с ним отображается сообщение об ошибке: *Required.
Требуется спроектировать тест-кейсы и автоматизировать их.
Реализация
Попробуем реализовать поставленные задачи в декларативном стиле. Опишем структуру данных, отношения, ассоциации и построим модель.
Модель данных будет состоять из двух классов. Для форм авторизации:
class LoginModel {
var login: String = ""
var password: String = ""
var sms: String = ""
}
Для элементов, с которыми будут осуществляться операции:
class ItemModel {
var name: String = ""
var nickname: String = ""
var age: Int = 0
var employee: Boolean = false
}
Далее давайте опишем селектора для страниц: создадим POM классы. Для этих целей воспользуемся библиотеками проекта XpathQs,
object Login: Page() {
val login = HTML.input(name = "login")
val password = HTML.input(name = "password")
val submit = HTML.submit()
}
object SmsVerification: Page() {
val sms = HTML.input(name = "sms")
val submit = HTML.submit()
}
object Items: Page() {
val addNew = HTML.button(text = "Add new")
object Table: Block(
TABLE + TR
) {
val jobTitle = TD[1]
val age = TD[2]
val nickname = TD[3]
val employee = TD[4]
}
val edit = HTML.button(clsContains = "edit")
val delete = HTML.button(clsContains = "delete")
}
object CreateUpdate: Page() {
var jobTitle = HTML.input(name = "jobTitle")
var nickname = HTML.input(name = "nickname")
var age = HTML.input(name = "age")
var employee = HTML.input(name = "employee")
var add = HTML.button(text = "add")
var cancel = HTML.button(text = "cancel")
}
где классы Page/Block — контейнеры для селекторов. Методы из класса HTML создают селектора на базе Xpath запросов. Так, к примеру, вызов метода:
HTML.input(name = "login")
создаст объект класса Selector со следующим xpath:
//input[text() = "login"]
На примере выше мы создали 4 POM-класса и описали селектора. При классическом подходе этого достаточно для того, чтобы приступать к написанию тест-кейсов. Однако давайте добавим декларативную составляющую к этим четырем классам.
МVVM
Теперь, когда описаны классы модели и созданы POM, свяжем их вместе, для того, чтобы получить MVVM,
class LoginModel : Model() {
var login by Fields.input(Login.login)
var password by Fields.input(Login.login)
var sms by Fields.input(SmsVerification.sms)
}
где Fields предоставляет список методов, возвращающих делегаты для свойств. Аналогичным образом будет выглядеть модель для объектов Items.
Навигация
Добавим аннотации к POM классам, по которым тестовый фреймворк будет определять связи между страницами — строить граф навигации.
@UI.Nav.PathTo(bySubmit = SmsVerification::class)
object Login: Page() {
val login = HTML.input(name = "login")
val password = HTML.input(name = "password")
@UI.Widgets.Submit
val submit = HTML.submit()
}
@UI.Nav.PathTo(bySubmit = Items::class)
object SmsVerification: Page() {
val sms = HTML.input(name = "sms")
@UI.Widgets.Submit
val submit = HTML.submit()
}
Где аннотация PathTo указывает POM-класс, на который будет осуществляться навигация. Тим навигации для вышеприведенных классов — bySubmit, что означает, что тестовый фреймворк будет искать элемент помеченный аннотацией Submit и нажимать на него после ввода значений из модели в поля для того, чтобы перейти на страницу, указанную в PathTo.
Тест дизайн
Теперь давайте приведем пример класса, отвечающего за генерацию тестов на базе модели:
class SelectorsTest : UiTest() {
@Test(dataProvider = "getSelectors")
fun test(sel: BaseSelector) = checkSelector(sel)
@DataProvider
fun getSelectors() = getPageSelectors(
*Pages().pages()
)
}
Здесь для генерации использован фреймворк TestNG. В классе есть два метода. Первый метод проверяет видимость элемента на странице и, при необходимости, выполняет навигацию до страницы с этим элементом. Второй метод возвращает список всех селекторов всех страниц приложения. Для текущего примера этот класс сгенерировал бы около 20 тестов, при выполнении которых открылись бы все страницы приложения, где были бы проверены все описанные селектора.
Такие тесты вполне могут выступать в роли Smoke тестов, и их стоит запускать до функциональных тестов. В тех случаях, когда меняется UI, все, что нужно сделать — это обновить POM классы, добавить/удалить селектора. Сами же тесты будут сгенерированы при следующем запуске класса SelectorsTest.
Данный подход к автоматизации UI упрощает поддержку и сопровождение, однозначно покажет изменения в UI, влияющие функциональные тесты, а также гарантирует, что все селектора на странице будут протестированы единым образом.
Область применения MBT
Как правило, каждый новый способ к решению каких-то проблем создает свои, уникальные для данного способа проблемы. И MBT не исключение.
На данный момент на рынке автоматизации нет продукта, который являлся бы отраслевым стандартом для применения MBT. Разработчики вынуждены создавать свои решения в этой сфере. Усилия, затраченные на разработку фреймворка с поддержкой MBT, могут окупиться лишь на достаточно крупных и динамичных проектах.
Заключение
Данная статья носит обзорный характер. Если вы дочитали до этого момента, то, возможно, у вас сложилось ощущение, что нет ничего конкретного. Нет работающего кода, который можно было бы запустить у себя локально. Нет даже описания технологического стэка.
В рамках данной статьи я решил сконцентрироваться на проблематике и общем описании. Не хотелось бы, чтоб создавалось ощущение, будто вышеперечисленные техники тесно связаны с каким-то технологическим стэком.
В следующей части статьи по данной тематике я опишу реализацию фреймворка с поддержкой MBT/MVVM на языке Kotlin, на базе Selenium WebDriver.