В автоматизации тестирования существует много разных подходов, решений и способов. Самый распространенный и незаменимый — паттерн Page Object (Screen Object). Я столкнулась с двумя подходами при работе с этим паттерном: c фабрикой для своих page object-ов и без нее. В этой статье на примере наших автотестов сравним оба подхода, их плюсы и минусы. Покажу, как выглядит наша фабрика page object-ов. Также расскажу о проблемах, с которыми мы столкнулись в автотестах с фабрикой и как их решили.
Все примеры в статье буду приводить на языке Swift, но для Android автотестов все работает аналогично.
Привет, меня зовут Оля. Я — тестировщик мобильных приложений в компании hh.ru. За два с хвостиком года мы перевели 90% ручного тестирования на автоматизированное. За это время мы успели множество раз наткнуться на всевозможные подводные камни, переспорить сотни споров, и теперь хочется поделиться своим опытом с миром.
Мы пишем автотесты на android (Kotlin, Kaspresso) и на iOS (Swift, XCUITest). UI-тесты стараемся делать небольшими, проверяющими только отдельные сценарии. Благо железа на это хватает, и регресс из ~300-400 UI-автотестов на каждой платформе занимает в среднем 30-40 минут.
В своих автотестах мы используем паттерн Page Object. О нем написано тысячи статей, поэтому останавливаться на нем подробнее не будем.
Исторически так сложилось, что при создании первых автотестов на iOS и на Android мы выбрали разные подходы работы с page object-ами. На Android мы просто следуем паттерну, а на iOS сделали еще и фабрику page object-ов. Фабрика — это место, где все page object-ы инициализируются. С помощью нее в методах наших экранов мы можем передавать другие page object-ы, тем самым выстраивая цепочки взаимодействий, аналогичные поведению приложения.
После создания сотен тестовых кейсов, мы пришли к выводу, что использование фабрики page object-ов — это дело вкуса тестировщика, который будет писать автотесты. По производительности и стабильности разницы при работе с разными подходами нет. Но чтобы в дальнейшем не страдать от собственного кода, нужно как можно раньше определиться, будет ли в архитектуре автотестов место для фабрики page object-ов или нет.
Для наглядности приведу два примера одного и того же теста: написанного с фабрикой и без нее.
Что делает этот тест: с главного экрана приложения пользователь пытается перейти на вкладку профиля. В профиле он выбирает «Войти», далее способ входа по логину и вводит свои логин и пароль, после чего нажимает на кнопку «Войти».
Если после этого примера вы решили, что тесты без фабрики выглядят годно — просто берите и пишите без нее. Этот совет особенно актуален, если у вас не очень большое приложение, с разным неповторяющимся контентом и элементами. Или если пишите тесты, в которых не больше 10-15 шагов. В этом случае фабрика погоды не сделает.
Но если это не так, или если тесты с фабрикой откликнулись в вашем сердечке, то эта статья для вас.
Давайте разберемся, для чего вообще нужна фабрика page object-ов, как она выглядит и что делает.
Стандартный тест-кейс представляет собой набор шагов:
Каждый шаг тест-кейса соответствует шагу в автотесте. Также можно сказать, что каждый шаг — это отдельный метод page object-а соответствующего экрана.
Если мы хотим в точности по шагам повторить этот тест-кейс в автотесте, выстроить такую же непрерывающуюся цепочку вызовов методов, нужно, чтобы каждый метод возвращал page object следующего экрана.
При создании page object-а его нужно инициализировать. Получается, если мы хотим создавать page object-ы внутри других, в каждом page object-е будет множество одинаковых инициализаций. Чтобы этого не делать, инициализации всех page object-ов выносятся в фабрику — pageObjectFactory (или screenFactory и т.п.). В конце каждого метода мы просим фабрику создать нужный нам экран.
Все page object-ы наследуются от BasePageObject, в котором находятся основные параметры. В нем мы должны прописать обязательный (required) конструктор, чтобы фабрика могла создавать любые его наследники. В нашем случае базовый класс выглядит так:
А инициализация экранов в фабрике в итоге выглядит так:
В результате, в методе любого page object-а мы можем вызвать фабричный метод создания экрана, который его сам инициализирует.
Без фабрики в каждой строчкетолько точки теста явно прописывается экран и действие, которое совершается на нем. Это позволяет не читать весь код теста с самого начала, например, при отладке и фиксах. Отсутствие фабрики особенно добавляет читаемости, когда действия или проверки совершаются на одном экране.
Даже если вы не сильно погружены по все тонкости работы приложения, то из примера выше сразу будет понятно, что действия enterLogin / enterPassword выполняются на одном и том же экране. В тесте с фабрикой такой ясности не будет — можно подумать, что действие enterLogin перевело пользователя на следующий экран.
При написании тестов без фабрики не нужно задумываться о том, что любое действие пользователя в зависимости от состояния приложения может вести себя по-разному, ты просто описываешь логику происходящего:
или
В случае фабрики page object-ов придется продумать, как, в зависимости от авторизованности юзера, вернуть в методе tapResponseButton() нужный экран (или продублировать метод наподобие tapResponseButtonAndLogin() )
У нас подобных методов на этом экране получилось 6, при том, что все они тапают на одну и ту же кнопку.
Методам page object-ов без фабрики не нужно возвращать следующий экран для продолжения цепочки, соответственно, для отдельных действий можно не создавать отдельные методы.
Например, тап по кнопке в тесте без фабрики может выглядеть так:
По моему опыту, при написании автотестов без фабрики проблем с page object-ами практически не возникает. Описать новый page object — легкий и быстрый процесс.
Фабрика вместе со своими возможностями добавляет и некоторые сложности, о которых я расскажу далее.
Все методы page object-ов — это звенья одной цепи. Каждое новое звено (метод) должно сцепиться со следующим. Это происходит за счет того, что все методы возвращают какой-либо page-object (себя или другой), из-за чего при написании теста у нас нет возможности выбрать метод любого экрана, только следующего.
Во-первых, это значительно ускоряет написание своего первого автотеста новым тестировщиком. IDE сама подсказывает какие действия и на каком экране ты можешь осуществлять далее.
Во-вторых, такая архитектура автотестов позволяет через их написание узнавать приложение, а не наоборот. (опять же — хорошая мотивация юных автотестировщиков).
В-третьих, отсутствует возможность пропустить какой-то шаг, так как нужного метода просто не появится, пока не пройдешь сценарий правильно. Такие пропуски довольно часто встречаются при написании тестов без фабрики, и о них узнаешь уже только при прогоне теста.
Этот пункт вытекает из предыдущего. Все методы page object-ов повторяют логику и поведение приложения. Если в приложении меняется логика переходов между экранами или добавляются новые — мы правим нужный нам метод и меняем его return. В автотестах с фабрикой, чтобы найти все тесты, которые затронуты этим изменением, не нужно прогонять все тесты. IDE сама укажет на все места, в которых «цепочка» разорвалась. Тесты без фабрики за этим не следят.
Если не использовать фабрику, а тест проходит по сценарию через десятки экранов, то в начале класса с тестом придется написать целый блок создания объектов каждого экрана. И делать это нужно каждый раз для каждого класса тестов. Еще страшнее, если писать несколько тестов в одном классе. Тогда размер этого «блока» с созданием всех page object-ов увеличивается в разы.
Пример из реального теста, в котором юзер совершает отклик на вакансию, разлогинивается и авторизовывается другим юзером. Этот блок переезжает из класса в класс с небольшими изменениями для всех аналогичных тестов.
Есть вариант вынести инициализацию всех page object-ов в базовый класс тестов, используя lazy инициализацию. Тогда в каждом тесте будут доступны все экраны и не будут создаваться ненужные объекты. Но проблема с перечислением множества экранов вернется, когда мы захотим создавать объекты page object-ов в самих page object-ах, если будет нужно написать метод, который будет проходить через другой экран.
Фабрика же берет на себя задачу инициализации нужных page object-ов, когда они нужны.
Наличие фабрики page object-ов потребует оборачивать все действия с экраном в методы, которые будут возвращать следующий экран. Из-за этого page object-ы сильно разрастаются, что заставляет задуматься, как сделать всё красивее и аккуратнее. Ради этого пытаешься использовать архитектурные приёмы, смотреть реализацию различных экранов в коде приложения, прослеживать взаимодействие модулей, чтобы выстроить похожую систему в своих автотестах, которой будет приятно и удобно пользоваться.
Бонусом мы получаем знания о том, как работает приложение изнутри. Как минимум, это полезно для общего развития, а зачастую и очень помогает при тестировании.
Тут без комментариев, это действительно вкусовщина. О том, как для вас «аккуратнее и чище» можно решить по самому первому примеру.
Можно сделать вывод, что лучше все-таки использовать фабрику page object-ов при написании автотестов. У кого ни спроси, почему они используют фабрику, всегда ответ один: «А ты попробуй написать тест без нее и сравни». Фабрика действительно берет на себя весомую часть задач, ответственность за отсутствие ошибок в последовательности автотеста и проч. Также она открывает некоторые интересные возможности, о которых я уже написала выше.
Но хочется заметить, что не всегда с этими возможностями всё радужно. При автоматизации с фабрикой мы столкнулись с весьма неприятными проблемами, но в итоге решили их.
В любом мобильном приложении есть элементы, доступ к которым есть с любого экрана. Для примера можно взять таббар (меню). Возникает вопрос: как, не прерывая цепочку вызовов методов page object-ов, получить доступ к методам таббара в любой момент теста?
Самое очевидное и наше первоначальное решение — сделать extension (расширение) базового класса page object-ов этими методами.
В ходе обсуждения мы поняли, что таббар — это не единственный элемент, который нужен на всех экранах, и пришли к выводу, что добавляя всё больше расширений, мы быстро захламим базовый класс.
Еще один минус такого решения — методы становятся доступны совершенно для всех page object-ов, что неправильно. Это ломает контракт классов-наследников, которые, по идее, должны были содержать только характерные для них методы. Например, для page object-ов алертов методы таббара не нужны.
Наше конечное решение — мы сделали из page object-а таббара протокол TabBarUsable (аналог для Kotlin — интерфейс). И написали его extension (расширение, реализацию), который позволяет не дублировать код, и при этом заменили наследование композицией.
Для всех экранов, на которых есть таббар, мы добавляем соответствие этому протоколу (конформим).
Соответственно, все методы работы с таббаром становятся доступными на всех этих экранах.
При этом не ломается архитектура, методы таббара доступны только для нужных нам экранов и код одинаковых методов не дублируется.
Как уже говорилось ранее, при использовании фабрики page object-ов все действия и все проверки оборачиваются в методы. Через некоторое время, создавая очередной автотест, ты начинаешь замечать, что пишешь у каждого page object-а одни и те же методы, которые даже элементами-то не отличаются. Примерами таких методов могут быть проверки zeroscreen-ов, работа с одинаковыми элементами списков на разных экранах и так далее.
Возникает логичная мысль: «обожезачемяэтоделаю?!». Как перестать дублировать код?
Первый способ, о котором я уже писала, — выносить все подобные методы в базовый класс. Это можно, но очень осторожно. Во-первых, этот процесс сильно увлекает. Кажется, что один метод в базовом классе ничего не испортит, но рано или поздно базовый класс превращается в неподъемное, неструктурированное, захламленное всем подряд чудовище, короче говоря, становится совершенно не юзабельным. Мы пробовали, мы знаем. Избавляться от этого монстра еще тяжелее, чем сразу делать нормально.
Со временем мы осознали, что принцип DRY (Don't Repeat Yourself) придумали не просто так. Мы стали искать одинаковые методы, которые используются во многих page object-ах. Отыскав их, долго обсуждали, действительно ли эти методы используются везде одинаково, имеют одинаковую логику. И, наконец, решили, что если методы используются в 80% случаев одинаково, то их нужно выносить в отдельный протокол.
Одним из самых первых вынесенных методов стал метод waitView(). Мы описали специальный протокол ViewWaitable и его реализацию в protocol extension-е, и теперь, чтобы в page object-е был доступен этот метод, нужно просто добавить соответствие протоколу (конформ). Так как идентификаторы view у всех экранов разные, то все page object-ы, использующие протокол, должны объявить у себя view.
Еще один пример из нашего приложения hh.ru: у нас есть список вакансий, который встречается на множестве разных экранов, при этом единственным отличием опять же будет идентификатор view, на которой этот список отображается. С этим списком связано очень много действий и тонна проверок. Умножайте их примерно на 10 (по числу экранов, на которых встречается этот список), чтобы оценить масштаб дублирования кода.
Решением проблемы вновь стали протоколы. Чтобы сделать протоколы еще более понятными и приятными для использования, мы разделили элементы списка (VacancyListContainig), элементы ячеек (VacancyCellContainig), методы с проверками (asserts, checks) и методы взаимодействия (actions). Это разделение прекрасно решило проблему читаемости содержимого громадного page object-а. Конечная архитектура выглядит так:
Page object-ы, на которых есть список вакансий, могут добавить соответствие протоколу VacancyListPageObject. Тем самым экрану становятся доступны все методы из реализации этого протокола без дублирования кода.
В итоге, благодаря следованию принципа DRY, мы имеем очень чистые, аккуратные page object-ы, одинаковые названия методов, и отсутствие лишнего кода.
Системные ошибки, алерты, bottom sheet-ы — неотъемлемая часть регрессов, которую несомненно хочется покрыть автотестами. Ошибки и алерты возникают на разных экранах, в разных кейсах, отличаются при разных состояниях приложения, но сами элементы, в основном, имеют одинаковые идентификаторы и поведение. Очевидно, что однотипные алерты с двумя кнопками (например, Ок/Отмена) = один отдельный page object.
Возвращаемся к желанию не прерывать цепочку вызовов методов. Имеем — десятки экранов, с которых можем открыть один и тот же алерт. При закрытии такого алерта мы должны вернуться в тот page object, с которого он был открыт. При этом мы не хотим в page object-е такого алерта писать эти самые десятки дублирующих методов, которые будут отличаться друг от друга только возвращаемым page object-ом.
Самое простое решение – забить и разорвать разочек тест. В этом тоже ничего страшного нет. И так живется гораздо проще.
Наше решение посложнее, но как оно красиво! Мы добавили page object-у алерта тип-параметр (generic), им будет тип того page object-а, с которого мы открываем алерт. При этом page object этого экрана передается в page object алерта при инициализации и хранится как generic-переменная source.
Подробнее о том, как это работает: при открытии (и инициализации) алерта мы передаем нужный экран в generic-переменной source. Это делается в методе page object-а, открывающем алерт:
Далее этот source (в данном примере это VacancyPageObject) проходит через все нужные в тесте методы и попадает в конечный dismissAlert(). В итоге метод закрытия алерта возвращает нужный нам экран, на котором мы сразу можем продолжить тест.
Как я уже говорила, чтобы page object мог принимать и возвращать нужные экраны (sources), ему нужно присвоить generic-тип, ограниченный базовым типом page object-ов, и инициализировать его с использованием page object-а, который нужно будет вернуть в самом конце:
Инициализация дженерного page object-а в фабрике будет выглядеть так:
Та-даа! Все готово, и теперь мы можем продолжать тест, не разрывая цепочку вызовов.
Есть экраны (правда есть), на которых размещается очень много разного контента, различных элементов интерфейса, отдельных логических блоков. При этом все они находятся на одном экране, и взаимодействовать с ними хочется через page object, который описывает этот самый экран.
Для примера возьмем главный экран нашего мобильного приложения.
Этот экран можно разделить на три совершенно независимые друг от друга секции: строка поиска, блок истории поиска и вкладки со списками вакансий. Можно было бы описать всё в одном page object-e (ведь это отдельный полноценный экран), но пользоваться им будет не очень удобно, так как у каждой секции своя логика, свои проверки и т.п.
Также в разрастающихся page object-ах рано или поздно возникает проблема названий методов. Много разных ячеек, title-ов, subtitle-ов и т.д., в итоге вместо простых названий приходится давать длинные, с уточнениями, к чему именно они относятся. Писать тесты становится очень неудобно и сложно. Каждый раз при выборе метода в тесте нужно открывать код page object-а и внимательно в него вникать, чтобы не ошибиться.
Самое простое решение — не видеть в этом проблему. Каким бы большим ни стал page object, им все равно можно пользоваться (особенно если привыкнуть). Но привыкать к такому не хочется, а хочется чтобы было красиво и удобно.
Плюс эти «секции» могут быть разными модулями в коде, а значит могут быть переиспользованы на разных экранах приложения. Если мы выстроим аналогичную архитектуру page object-ами, то тоже сможем их переиспользовать.
Мы приняли волевое решение — теперь для таких секций мы пишем разные классы page object-ов. Каждому из них добавляем соответствие объединяющему пустому протоколу MainScreenSection: BasePageObject { }.
Для примера возьмем секцию истории поиска на главном экране. Её page object будет выглядеть так:
В основном page object-е MainScreenPageObject, описывающем главный экран, мы сделали метод, с помощью которого из теста говорим в какой именно секции мы сейчас будем что-то делать. Выглядит он вот так.
В этот метод мы передаем тип секции, внутри инициализируем page object с помощью generic-метода фабрики.
В итоге мы получили непрерывающийся тест, отсутствие лишних инициализаций громадных page object-ов и читаемость кода.
Писать автотесты без фабрики page object-ов можно. Page object-ы при таком подходе пишутся очень быстро и просто, особенно если они не нуждаются в сложной инициализации. Но, с другой стороны, при написании теста нужно быть крайне внимательным. Ответственность за последовательность шагов в тесте, инициализацию page object-ов, отсутствие обертки над некоторыми действиями и пр. лежит на тестировщике. Если ваше приложение не очень сложное, то может и не стоит усложнять, а все перечисленные проблемы будут вообще незаметны.
Если же в самом начале немного посидеть над созданием фабрики, то в будущем она, во-первых, заберет на себя всю вышеперечисленную ответственность, а во-вторых, сделает процесс написания автотестов очень простым и практически без шансов на ошибку.
Так как единственно верного решения здесь нет, то решать только вам. Мы так и продолжаем использовать оба подхода, и всем удобно и хорошо. Удачи и красивых автотестов!
Все примеры в статье буду приводить на языке Swift, но для Android автотестов все работает аналогично.
Для начала
Привет, меня зовут Оля. Я — тестировщик мобильных приложений в компании hh.ru. За два с хвостиком года мы перевели 90% ручного тестирования на автоматизированное. За это время мы успели множество раз наткнуться на всевозможные подводные камни, переспорить сотни споров, и теперь хочется поделиться своим опытом с миром.
Мы пишем автотесты на android (Kotlin, Kaspresso) и на iOS (Swift, XCUITest). UI-тесты стараемся делать небольшими, проверяющими только отдельные сценарии. Благо железа на это хватает, и регресс из ~300-400 UI-автотестов на каждой платформе занимает в среднем 30-40 минут.
В своих автотестах мы используем паттерн Page Object. О нем написано тысячи статей, поэтому останавливаться на нем подробнее не будем.
Исторически так сложилось, что при создании первых автотестов на iOS и на Android мы выбрали разные подходы работы с page object-ами. На Android мы просто следуем паттерну, а на iOS сделали еще и фабрику page object-ов. Фабрика — это место, где все page object-ы инициализируются. С помощью нее в методах наших экранов мы можем передавать другие page object-ы, тем самым выстраивая цепочки взаимодействий, аналогичные поведению приложения.
Нужна ли фабрика page object-ов?
После создания сотен тестовых кейсов, мы пришли к выводу, что использование фабрики page object-ов — это дело вкуса тестировщика, который будет писать автотесты. По производительности и стабильности разницы при работе с разными подходами нет. Но чтобы в дальнейшем не страдать от собственного кода, нужно как можно раньше определиться, будет ли в архитектуре автотестов место для фабрики page object-ов или нет.
Для наглядности приведу два примера одного и того же теста: написанного с фабрикой и без нее.
Что делает этот тест: с главного экрана приложения пользователь пытается перейти на вкладку профиля. В профиле он выбирает «Войти», далее способ входа по логину и вводит свои логин и пароль, после чего нажимает на кнопку «Войти».
// тест без фабрики
class ExampleLoginTestSuit: BaseTestCase {
let mainScreen = MainScreen()
let profileScreen = ProfileScreen()
let authorizationScreen = AuthorizationScreen()
let loginScreen = LoginScreen()
func testExampleLogin() {
let user = userFixtureService.createUser()
mainScreen.openProfileTab()
profileScreen.goToAuthorization()
authorizationScreen.goToLogin()
loginScreen
.enterLogin(user.login)
.enterPassword(user.password)
.logIn()
}
}
// тест с фабрикой
class ExampleLoginTestSuit: BaseTestCase {
func testExampleLogin() {
let user = userFixtureService.createUser()
pageObjectsFactory
.makeMainScreenPageObject()
.openProfileTab()
.goToAuthorization()
.goToLogin()
.enterLogin(user.login)
.enterPassword(user.password)
.logIn()
}
}
Если после этого примера вы решили, что тесты без фабрики выглядят годно — просто берите и пишите без нее. Этот совет особенно актуален, если у вас не очень большое приложение, с разным неповторяющимся контентом и элементами. Или если пишите тесты, в которых не больше 10-15 шагов. В этом случае фабрика погоды не сделает.
Но если это не так, или если тесты с фабрикой откликнулись в вашем сердечке, то эта статья для вас.
Как выглядит наша фабрика
Давайте разберемся, для чего вообще нужна фабрика page object-ов, как она выглядит и что делает.
Стандартный тест-кейс представляет собой набор шагов:
- Запустить приложение
- Открыть экран Профиль
- Тапнуть на “Войти”
- Выбрать способ входа по логину и паролю
- Ввести данные пользователя (логин и пароль)
- Тапнуть кнопку “Войти”
Скринкаст теста
Каждый шаг тест-кейса соответствует шагу в автотесте. Также можно сказать, что каждый шаг — это отдельный метод page object-а соответствующего экрана.
Если мы хотим в точности по шагам повторить этот тест-кейс в автотесте, выстроить такую же непрерывающуюся цепочку вызовов методов, нужно, чтобы каждый метод возвращал page object следующего экрана.
При создании page object-а его нужно инициализировать. Получается, если мы хотим создавать page object-ы внутри других, в каждом page object-е будет множество одинаковых инициализаций. Чтобы этого не делать, инициализации всех page object-ов выносятся в фабрику — pageObjectFactory (или screenFactory и т.п.). В конце каждого метода мы просим фабрику создать нужный нам экран.
final class ProfilePageObject: BasePageObject {
func goToAuthScreen() -> AuthPageObject {
openAuthButton.tap()
return pageObjectsFactory
.makeAuthPageObject()
}
}
Все page object-ы наследуются от BasePageObject, в котором находятся основные параметры. В нем мы должны прописать обязательный (required) конструктор, чтобы фабрика могла создавать любые его наследники. В нашем случае базовый класс выглядит так:
class BasePageObject {
let pageObjectsFactory: PageObjectsFactory
let application: XCUIApplication
required init(pageObjectsFactory: PageObjectsFactory,
application: XCUIApplication) {
self.pageObjectsFactory = pageObjectsFactory
self.application = application
}
}
А инициализация экранов в фабрике в итоге выглядит так:
final class PageObjectsFactory {
private func initializePageObject<PageObject: BasePageObject>(ofType type: PageObject.Type) -> PageObject {
return type.init(pageObjectsFactory: self, application: application)
}
func makeAuthPageObject() -> AuthPageObject {
return initializePageObject(ofType: AuthPageObject.self)
}
}
В результате, в методе любого page object-а мы можем вызвать фабричный метод создания экрана, который его сам инициализирует.
Плюсы жизни автотестировщика без фабрики
1. Из теста всегда понятно на каком экране совершается действие
Без фабрики в каждой строчке
class ExampleLoginTestSuit : BaseTestCase {
// создаем объекты экранов, используемых в тесте
let mainScreen = MainScreen()
let profileScreen = ProfileScreen()
let authorizationScreen = AuthorizationScreen()
let loginScreen = LoginScreen()
func testExampleLogin() {
let user = userFixtureService.createUser() // создание тестового пользователя
mainScreen.openProfileTab() // с главного экрана открываем таб Профиль
profileScreen.goToAuthorization() // на экране профиля выбираем "Войти"
authorizationScreen.goToLogin() // на экране выбора способа входа выбираем по логину и паролю
loginScreen // на экране логина вводим логин и пароль и нажимаем кнопку Войти
.enterLogin(user.login)
.enterPassword(user.password)
.login()
}
}
Даже если вы не сильно погружены по все тонкости работы приложения, то из примера выше сразу будет понятно, что действия enterLogin / enterPassword выполняются на одном и том же экране. В тесте с фабрикой такой ясности не будет — можно подумать, что действие enterLogin перевело пользователя на следующий экран.
2. Удобнее писать тесты с действиями, которые могут вести на разные экраны
При написании тестов без фабрики не нужно задумываться о том, что любое действие пользователя в зависимости от состояния приложения может вести себя по-разному, ты просто описываешь логику происходящего:
// отклик неавторизованным юзером открывает экран входа в приложение
vacancyScreen.tapResponseButton()
authScreen.authUser(user)
responseScreen.checkScreenIsOpened()
или
// отклик авторизованным юзером сразу открывает экран отклика
vacancyScreen.tapResponseButton()
responseScreen.checkScreenIsOpened()
В случае фабрики page object-ов придется продумать, как, в зависимости от авторизованности юзера, вернуть в методе tapResponseButton() нужный экран (или продублировать метод наподобие tapResponseButtonAndLogin() )
final class VacancyPageObject: BasePageObject {
…
func tapResponseButton() -> ResponseSendPageObject {
responseButton.tap()
return pageObjectsFactory
.makeResponseSendPageObject()
}
func tapResponseButtonAndLogin(login: String) -> ResponseSendPageObject {
responseButton.tap()
pageObjectsFactory
.makeAuthorizationPageObject()
.goToLogin()
.logIn(login)
return pageObjectsFactory
.makeResponseSendPageObject()
}
}
У нас подобных методов на этом экране получилось 6, при том, что все они тапают на одну и ту же кнопку.
3. Не нужно оборачивать каждое действие и каждую проверку в отдельный метод
Методам page object-ов без фабрики не нужно возвращать следующий экран для продолжения цепочки, соответственно, для отдельных действий можно не создавать отдельные методы.
Например, тап по кнопке в тесте без фабрики может выглядеть так:
vacancyScreen.responseButton.tap()
4. Не приходится думать над сложными решениями, дополнительно продумывать архитектуру page object-ов
По моему опыту, при написании автотестов без фабрики проблем с page object-ами практически не возникает. Описать новый page object — легкий и быстрый процесс.
Фабрика вместе со своими возможностями добавляет и некоторые сложности, о которых я расскажу далее.
Несомненные плюсы фабрики
1. Во время написания теста нет возможности пропустить какой-то шаг
Все методы page object-ов — это звенья одной цепи. Каждое новое звено (метод) должно сцепиться со следующим. Это происходит за счет того, что все методы возвращают какой-либо page-object (себя или другой), из-за чего при написании теста у нас нет возможности выбрать метод любого экрана, только следующего.
Во-первых, это значительно ускоряет написание своего первого автотеста новым тестировщиком. IDE сама подсказывает какие действия и на каком экране ты можешь осуществлять далее.
Во-вторых, такая архитектура автотестов позволяет через их написание узнавать приложение, а не наоборот. (опять же — хорошая мотивация юных автотестировщиков).
В-третьих, отсутствует возможность пропустить какой-то шаг, так как нужного метода просто не появится, пока не пройдешь сценарий правильно. Такие пропуски довольно часто встречаются при написании тестов без фабрики, и о них узнаешь уже только при прогоне теста.
2. При изменении сигнатуры метода page object-а, IDE заставит изменить все связанные с объектом тесты
Этот пункт вытекает из предыдущего. Все методы page object-ов повторяют логику и поведение приложения. Если в приложении меняется логика переходов между экранами или добавляются новые — мы правим нужный нам метод и меняем его return. В автотестах с фабрикой, чтобы найти все тесты, которые затронуты этим изменением, не нужно прогонять все тесты. IDE сама укажет на все места, в которых «цепочка» разорвалась. Тесты без фабрики за этим не следят.
3. Архитектура и чистота кода не страдают от создания лишних объектов экранов
Если не использовать фабрику, а тест проходит по сценарию через десятки экранов, то в начале класса с тестом придется написать целый блок создания объектов каждого экрана. И делать это нужно каждый раз для каждого класса тестов. Еще страшнее, если писать несколько тестов в одном классе. Тогда размер этого «блока» с созданием всех page object-ов увеличивается в разы.
Пример из реального теста, в котором юзер совершает отклик на вакансию, разлогинивается и авторизовывается другим юзером. Этот блок переезжает из класса в класс с небольшими изменениями для всех аналогичных тестов.
final class CounterUpdateAfterOtherUserLoginTest: BaseTestCase {
private lazy var mainScreen = MainScreenPageObject()
private lazy var vacanciesScreen = VacanciesScreenPageObject()
private lazy var responseToVacancyScreen = ResponseToVacancyBottomSheet()
private lazy var successResponseBottomSheet = SuccessResponseBottomSheet()
private lazy var settingsScreen = UserSettingsScreenPageObject()
private lazy var chooseAuthScreen = ChooseAuthScreenPageObject()
private lazy var authScreen = NativeAuthScreenPageObject()
private lazy var navigation = NavigationPageObject()
private lazy var moreScreen = MoreScreenPageObject()
...
}
Есть вариант вынести инициализацию всех page object-ов в базовый класс тестов, используя lazy инициализацию. Тогда в каждом тесте будут доступны все экраны и не будут создаваться ненужные объекты. Но проблема с перечислением множества экранов вернется, когда мы захотим создавать объекты page object-ов в самих page object-ах, если будет нужно написать метод, который будет проходить через другой экран.
Фабрика же берет на себя задачу инициализации нужных page object-ов, когда они нужны.
4. Отличная возможность больше погружаться в код приложения, изучать его архитектуру, модули, их взаимодействие etc.
Наличие фабрики page object-ов потребует оборачивать все действия с экраном в методы, которые будут возвращать следующий экран. Из-за этого page object-ы сильно разрастаются, что заставляет задуматься, как сделать всё красивее и аккуратнее. Ради этого пытаешься использовать архитектурные приёмы, смотреть реализацию различных экранов в коде приложения, прослеживать взаимодействие модулей, чтобы выстроить похожую систему в своих автотестах, которой будет приятно и удобно пользоваться.
Бонусом мы получаем знания о том, как работает приложение изнутри. Как минимум, это полезно для общего развития, а зачастую и очень помогает при тестировании.
5. Код теста выглядит очень аккуратно и чисто
Тут без комментариев, это действительно вкусовщина. О том, как для вас «аккуратнее и чище» можно решить по самому первому примеру.
Подводные камни // как споткнулись и обошли
Можно сделать вывод, что лучше все-таки использовать фабрику page object-ов при написании автотестов. У кого ни спроси, почему они используют фабрику, всегда ответ один: «А ты попробуй написать тест без нее и сравни». Фабрика действительно берет на себя весомую часть задач, ответственность за отсутствие ошибок в последовательности автотеста и проч. Также она открывает некоторые интересные возможности, о которых я уже написала выше.
Но хочется заметить, что не всегда с этими возможностями всё радужно. При автоматизации с фабрикой мы столкнулись с весьма неприятными проблемами, но в итоге решили их.
Протоколы и общие для всего приложения элементы
В любом мобильном приложении есть элементы, доступ к которым есть с любого экрана. Для примера можно взять таббар (меню). Возникает вопрос: как, не прерывая цепочку вызовов методов page object-ов, получить доступ к методам таббара в любой момент теста?
Самое очевидное и наше первоначальное решение — сделать extension (расширение) базового класса page object-ов этими методами.
import XCTest
/*
Расширение для использования таббара в приложении.
*/
extension BasePageObject {
private lazy var tabBar = application.tabBars[Accessibility.TabBar.identifier].firstMatch
var searchTab = tabBar.buttons[Accessibility.TabBar.searchTab].firstMatch
…
func openSearchTab() -> MainScreenPageObject {
searchTab.tap()
return pageObjectsFactory.makeMainScreenPageObject()
}
…
}
В ходе обсуждения мы поняли, что таббар — это не единственный элемент, который нужен на всех экранах, и пришли к выводу, что добавляя всё больше расширений, мы быстро захламим базовый класс.
Еще один минус такого решения — методы становятся доступны совершенно для всех page object-ов, что неправильно. Это ломает контракт классов-наследников, которые, по идее, должны были содержать только характерные для них методы. Например, для page object-ов алертов методы таббара не нужны.
Наше конечное решение — мы сделали из page object-а таббара протокол TabBarUsable (аналог для Kotlin — интерфейс). И написали его extension (расширение, реализацию), который позволяет не дублировать код, и при этом заменили наследование композицией.
import XCTest
protocol TabBarUsable {
var searchTab: XCUIElement { get }
func openSearchTab() -> MainScreenPageObject
…
}
extension TabBarUsable where Self: BasePageObject {
private var tabBar: XCUIElement { application.tabBars[Accessibility.TabBar.identifier].firstMatch }
var searchTab: XCUIElement { tabBar.buttons[Accessibility.TabBar.searchTab].firstMatch }
func openSearchTab() -> MainScreenPageObject {
searchTab.tap()
return pageObjectsFactory.makeMainScreenPageObject()
}
…
}
Для всех экранов, на которых есть таббар, мы добавляем соответствие этому протоколу (конформим).
final class VacancyPageObject: BasePageObject, TabBarUsable {
…
}
Соответственно, все методы работы с таббаром становятся доступными на всех этих экранах.
class ExampleTabbarTestSuit: BaseTestCase {
func testExampleOpenSearchTab() {
pageObjectsFactory
.makeMainScreenPageObject()
.openVacanciesList()
.openVacancy() // метод возвращает .makeVacancyPageObject()
.openSearchTab() // метод из TabBarUsable
}
}
При этом не ломается архитектура, методы таббара доступны только для нужных нам экранов и код одинаковых методов не дублируется.
Протоколы и выDRY-ивание кода
Как уже говорилось ранее, при использовании фабрики page object-ов все действия и все проверки оборачиваются в методы. Через некоторое время, создавая очередной автотест, ты начинаешь замечать, что пишешь у каждого page object-а одни и те же методы, которые даже элементами-то не отличаются. Примерами таких методов могут быть проверки zeroscreen-ов, работа с одинаковыми элементами списков на разных экранах и так далее.
Возникает логичная мысль: «обожезачемяэтоделаю?!». Как перестать дублировать код?
Первый способ, о котором я уже писала, — выносить все подобные методы в базовый класс. Это можно, но очень осторожно. Во-первых, этот процесс сильно увлекает. Кажется, что один метод в базовом классе ничего не испортит, но рано или поздно базовый класс превращается в неподъемное, неструктурированное, захламленное всем подряд чудовище, короче говоря, становится совершенно не юзабельным. Мы пробовали, мы знаем. Избавляться от этого монстра еще тяжелее, чем сразу делать нормально.
Со временем мы осознали, что принцип DRY (Don't Repeat Yourself) придумали не просто так. Мы стали искать одинаковые методы, которые используются во многих page object-ах. Отыскав их, долго обсуждали, действительно ли эти методы используются везде одинаково, имеют одинаковую логику. И, наконец, решили, что если методы используются в 80% случаев одинаково, то их нужно выносить в отдельный протокол.
Одним из самых первых вынесенных методов стал метод waitView(). Мы описали специальный протокол ViewWaitable и его реализацию в protocol extension-е, и теперь, чтобы в page object-е был доступен этот метод, нужно просто добавить соответствие протоколу (конформ). Так как идентификаторы view у всех экранов разные, то все page object-ы, использующие протокол, должны объявить у себя view.
protocol ViewWaitable {
var view: XCUIElement { get }
}
extension ViewWaitable where Self: BasePageObject {
@discardableResult
func waitView() -> Self {
testWaiter.waitForElementToAppear(view)
return self
}
}
final class VacancyPageObject: BasePageObject, TabBarUsable, ViewWaitable {
lazy var view = application.otherElements[Accessibility.view].firstMatch
…
}
Еще один пример из нашего приложения hh.ru: у нас есть список вакансий, который встречается на множестве разных экранов, при этом единственным отличием опять же будет идентификатор view, на которой этот список отображается. С этим списком связано очень много действий и тонна проверок. Умножайте их примерно на 10 (по числу экранов, на которых встречается этот список), чтобы оценить масштаб дублирования кода.
Решением проблемы вновь стали протоколы. Чтобы сделать протоколы еще более понятными и приятными для использования, мы разделили элементы списка (VacancyListContainig), элементы ячеек (VacancyCellContainig), методы с проверками (asserts, checks) и методы взаимодействия (actions). Это разделение прекрасно решило проблему читаемости содержимого громадного page object-а. Конечная архитектура выглядит так:
Page object-ы, на которых есть список вакансий, могут добавить соответствие протоколу VacancyListPageObject. Тем самым экрану становятся доступны все методы из реализации этого протокола без дублирования кода.
final class SearchResultPageObject: BasePageObject, ViewWaitable, VacancyListPageObject {
lazy var view = application
.otherElements[Accessibility.SearchResults.view].firstMatch
lazy var listView = application
.tables[Accessibility.SearchResults.tableView].firstMatch
…
}
В итоге, благодаря следованию принципа DRY, мы имеем очень чистые, аккуратные page object-ы, одинаковые названия методов, и отсутствие лишнего кода.
Одинаковые алерты на разных экранах // Sources
Системные ошибки, алерты, bottom sheet-ы — неотъемлемая часть регрессов, которую несомненно хочется покрыть автотестами. Ошибки и алерты возникают на разных экранах, в разных кейсах, отличаются при разных состояниях приложения, но сами элементы, в основном, имеют одинаковые идентификаторы и поведение. Очевидно, что однотипные алерты с двумя кнопками (например, Ок/Отмена) = один отдельный page object.
Возвращаемся к желанию не прерывать цепочку вызовов методов. Имеем — десятки экранов, с которых можем открыть один и тот же алерт. При закрытии такого алерта мы должны вернуться в тот page object, с которого он был открыт. При этом мы не хотим в page object-е такого алерта писать эти самые десятки дублирующих методов, которые будут отличаться друг от друга только возвращаемым page object-ом.
Самое простое решение – забить и разорвать разочек тест. В этом тоже ничего страшного нет. И так живется гораздо проще.
class ExampleTestSuit: BaseTestCase {
func testExample() {
pageObjectsFactory
.makeMainScreenPageObject()
.openVacanciesList()
.openVacancy()
.openAlert()
.closeAlert() // не возвращает нам нужный далее экран, поэтому цепочка вызовов разрывается
pageObjectFactory
.makeVacancyPageObject() // создаем экран заново
…
}
}
Наше решение посложнее, но как оно красиво! Мы добавили page object-у алерта тип-параметр (generic), им будет тип того page object-а, с которого мы открываем алерт. При этом page object этого экрана передается в page object алерта при инициализации и хранится как generic-переменная source.
Подробнее о том, как это работает: при открытии (и инициализации) алерта мы передаем нужный экран в generic-переменной source. Это делается в методе page object-а, открывающем алерт:
final class VacancyPageObject: BasePageObject {
…
func openAlert() -> AlertPageObject<VacancyPageObject> {
button.tap()
return pageObjectsFactory
.makeAlertPageObject(from: self) // передаем source VacancyPageObject
}
}
Далее этот source (в данном примере это VacancyPageObject) проходит через все нужные в тесте методы и попадает в конечный dismissAlert(). В итоге метод закрытия алерта возвращает нужный нам экран, на котором мы сразу можем продолжить тест.
final class AlertPageObject<Source: BasePageObject>: BasePageObject {
func dismissAlert() -> Source {
cancelButton.tap()
return source // возвращаем экран, с которого открывали алерт — VacancyPageObject
}
}
Как я уже говорила, чтобы page object мог принимать и возвращать нужные экраны (sources), ему нужно присвоить generic-тип, ограниченный базовым типом page object-ов, и инициализировать его с использованием page object-а, который нужно будет вернуть в самом конце:
final class AlertPageObject<Source: BasePageObject>: BasePageObject {
// generic-переменная для хранения экрана для последующего возвращения в общую цепочку вызовов
private let source: Source
init(pageObjectsFactory: PageObjectsFactory,
application: XCUIApplication,
source: Source) {
self.source = source
super.init(pageObjectsFactory: pageObjectsFactory, application: application)
}
// В BasePageObject объявлен обязательный конструктор, но здесь мы запрещаем им пользоваться
required init(pageObjectsFactory: PageObjectsFactory, application: XCUIApplication) {
fatalError("init(pageObjectsFactory:application:) has not been implemented. Use another init")
}
}
Инициализация дженерного page object-а в фабрике будет выглядеть так:
final class PageObjectsFactory {
…
func makeAlertPageObject<Source: BasePageObject>(
from source: Source
) -> AlertPageObject<Source> {
return AlertPageObject(pageObjectsFactory: self,
application: application,
source: source)
}
}
Та-даа! Все готово, и теперь мы можем продолжать тест, не разрывая цепочку вызовов.
class ExampleSourcesTestSuit: BaseTestCase {
func testExampleSource() {
pageObjectsFactory
.makeMainScreenPageObject()
.openVacanciesList()
.openVacancy()
.openAlert()
.dismissAlert()
.checkVacancyScreenIsOpened() // после закрытия алерта оказываемся на том экране, с которого уходили и можем продолжить работу с ним
…
}
}
Огромный page object, как его понять
Есть экраны (правда есть), на которых размещается очень много разного контента, различных элементов интерфейса, отдельных логических блоков. При этом все они находятся на одном экране, и взаимодействовать с ними хочется через page object, который описывает этот самый экран.
Для примера возьмем главный экран нашего мобильного приложения.
Этот экран можно разделить на три совершенно независимые друг от друга секции: строка поиска, блок истории поиска и вкладки со списками вакансий. Можно было бы описать всё в одном page object-e (ведь это отдельный полноценный экран), но пользоваться им будет не очень удобно, так как у каждой секции своя логика, свои проверки и т.п.
Также в разрастающихся page object-ах рано или поздно возникает проблема названий методов. Много разных ячеек, title-ов, subtitle-ов и т.д., в итоге вместо простых названий приходится давать длинные, с уточнениями, к чему именно они относятся. Писать тесты становится очень неудобно и сложно. Каждый раз при выборе метода в тесте нужно открывать код page object-а и внимательно в него вникать, чтобы не ошибиться.
final class VacancyListPageObject: BasePageObject {
…
func assertHistoryListFirstCellTitleExists() -> Self { ... }
func assertHistoryListCellByTitleExists(title: String) -> Self { ... }
func assertRecommendationsListFirstCellTitleExists() -> Self { ... }
func assertVacancyNearbyListFirstCellTitleExists() -> Self { ... }
… и тд
}
Самое простое решение — не видеть в этом проблему. Каким бы большим ни стал page object, им все равно можно пользоваться (особенно если привыкнуть). Но привыкать к такому не хочется, а хочется чтобы было красиво и удобно.
Плюс эти «секции» могут быть разными модулями в коде, а значит могут быть переиспользованы на разных экранах приложения. Если мы выстроим аналогичную архитектуру page object-ами, то тоже сможем их переиспользовать.
Мы приняли волевое решение — теперь для таких секций мы пишем разные классы page object-ов. Каждому из них добавляем соответствие объединяющему пустому протоколу MainScreenSection: BasePageObject { }.
Для примера возьмем секцию истории поиска на главном экране. Её page object будет выглядеть так:
final class SearchHistoryPageObject: BasePageObject, MainScreenSection {
// переменные
// методы
}
В основном page object-е MainScreenPageObject, описывающем главный экран, мы сделали метод, с помощью которого из теста говорим в какой именно секции мы сейчас будем что-то делать. Выглядит он вот так.
final class MainScreenPageObject: BasePageObject, TabBarUsable {
…
func section<Section: MainScreenSection>(_ section: Section.Type) -> Section {
return section.init(pageObjectsFactory: pageObjectsFactory, application: application)
}
}
В этот метод мы передаем тип секции, внутри инициализируем page object с помощью generic-метода фабрики.
В итоге мы получили непрерывающийся тест, отсутствие лишних инициализаций громадных page object-ов и читаемость кода.
// открываем главный экран и работаем с секцией “История поиска”
.openSearchTab() // оказывается в MainScreenPageObject
.section(SearchHistoryPageObject.self) // переходим в секцию История поиска
.waitHistoryIsLoad() // работаем в page object-е истории поиска
.section(MainScreenPageObject.self) // аналогично можем вернуться обратно к главному экрану
Подведем итог
Писать автотесты без фабрики page object-ов можно. Page object-ы при таком подходе пишутся очень быстро и просто, особенно если они не нуждаются в сложной инициализации. Но, с другой стороны, при написании теста нужно быть крайне внимательным. Ответственность за последовательность шагов в тесте, инициализацию page object-ов, отсутствие обертки над некоторыми действиями и пр. лежит на тестировщике. Если ваше приложение не очень сложное, то может и не стоит усложнять, а все перечисленные проблемы будут вообще незаметны.
Если же в самом начале немного посидеть над созданием фабрики, то в будущем она, во-первых, заберет на себя всю вышеперечисленную ответственность, а во-вторых, сделает процесс написания автотестов очень простым и практически без шансов на ошибку.
Так как единственно верного решения здесь нет, то решать только вам. Мы так и продолжаем использовать оба подхода, и всем удобно и хорошо. Удачи и красивых автотестов!
cashby
Все хорошо, только вы описали не фабрику, а частный случай fluent interface. Я еще кое-как соглашусь, что под капотом используется фабричный метод, а вот фабрикой в принципе не пахнет.
А вообще рекомендую впилить IoC контейнер. Решает многие надуманные проблемы.
PS: тесты может и пишутся просто, а вот поддержка тестов с fluent interface может быть очень болезненной.
olgamsk4 Автор
IoC контейнер — это действительно интересно. Тоже думали об этом, и, вероятно, попробуем перейти на такой подход в ближайшем будущем