Как стать автором
Обновить

Паттерн PageObject в Kotlin для UI-тестирования Android

Время на прочтение7 мин
Количество просмотров4K
Автор оригинала: Bram Yeh


Это перевод статьи ведущего Android & iOS разработчика Yahoo (Verizon Media) Брама Йе. Он рассказывает о внедрении паттерна PageObject в свои инструментальные тесты, который делает их более гибкими и легко модифицируемым в зависимости от изменений пользовательского интерфейса. Более того, по словам Брама, благодаря DSL в Kotlin, паттерн PageObject стал более содержательным и более читабельным в тест-кейсах.

TL;DR


Определите базовый класс Page, у которого есть функция fun <reified T : Page> on(): T, которая создает экземпляр PageObject по типу T:

open class Page {
    companion object {
        inline fun <reified T : Page> on(): T {
	    return Page().on()
	}
    }

    inline fun <reified T : Page> on(): T {
        val page = T::class.constructors.first().call()
	page.verify()
        return page
    }
}

Затем все остальные объекты страницы наследуются от Page:

class ItemPage : Page() {
    fun withTitle(keyword: String): ItemPage {
        Espresso.onView(withId(R.id.productitem_name))
                .check(matches(ViewMatchers.withText(keyword)))
        return this

После этого мы можем написать наш тест-кейс следующим образом:

@Test
fun testSearchById() {
   Page.on<DiscoveryPage>()
       .on<SearchBoxPage>()
       .click()
       .on<SearchViewPage>()
       .searchKeyword("7882691")
       .on<ItemPage>()
       .withTitle("A1NJ5J02")
}

UI-тестирование Android


Тесты пользовательского интерфейса Android обычно выполняются на физических устройствах и эмуляторах (мы, например, пользуемся Espresso) – и в нашем проекте их очень много. Раньше мы настраивали множество вспомогательных методов для реализации UI-тестов. Это делало нашу тестовую функцию краткой, но трудной для понимания процессов поведения, навигации и пользовательского интерфейса, который мы тестировали. А когда UI приложения часто обновляется, его тестирование становится кошмаром для поддержки.

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

Паттерн PageObject


Основное правило для PageObject состоит в том, что он должен позволять программному клиенту делать и видеть все то же, что и пользователь. Они также должны предоставлять простой программный интерфейс, скрывая детали реализации экрана. – Мартин Фаулер

Я не объясню концепцию PageObject лучше, чем Мартин, даже несмотря на то, что он описал его использование при Web-разработке. Поэтому я настоятельно рекомендую прочитать его статью здесь.

Преимущества PageObject


  • Уменьшение количества дублируемого кода

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

  • Повышение удобства сопровождения тест-кейсов, особенно для проектов с частыми изменениями UI

C паттерном PageObject нам всего лишь нужно настроить один или несколько объектов страницы, когда пользовательский интерфейс меняется. Кроме того, мы можем легко узнать, какие объекты страницы должны быть также модифицированы. В итоге разработчики экономят много времени, не отслеживая код теста и выясняя, почему этот тестовый случай завершился неудачей.

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

  • Улучшение читабельности тест-кейсов

Я объясню наши сценарии и детали позже, а сейчас я хотел бы поделиться реальным кейсом.

image

@Test
fun testSearchById() {
   Page.on<DiscoveryPage>()
       .on<SearchBoxPage>()
       .click()
       .on<SearchViewPage>()
       .searchKeyword("7882691")
       .on<ItemPage>()
       .withTitle("A1NJ5J02")
}


Легко понять, что этот тест проходит через фрагмент Discovery, кликает на представление SearchBox, вводит ключевое слово в редактируемый SearchView, а затем показывает фрагмент Item с указанным заголовком.

  • PageObject может наследоваться от другого PageObject

Например, многие фрагменты содержат RecyclerView, поддерживающие общие функции, но отличающиеся проверками и некоторыми специальными функциями. Чтобы реализовать ScrollablePageObject, который проверяет наличие recyclerview и общих методов, таких как «щелкните n-й элемент», другому PageObject нужно расширить ScrollablePageObject и адаптировать (настроить) их.

class ScrollablePage : Page() {
    @IdRes
    open val recyclerViewId: Int = R.id.recycler_view


    fun clickItem(index: Int): Page {
        Espresso.onView(withId(recyclerViewId))
                .perform(RecyclerViewActions.scrollToPosition(index)
        Espresso.onView(withId(recyclerViewId))
		.perform(
		    RecyclerViewActions.actionOnHolderItem(
	                ItemMatcher(),
			click())
                    .atPosition(index)
                )
	return this
    }
}

class SearchResultPage: ScrollablePage() { 
	…
}

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

image

Более того, это привнесет читабельность, потому что вы увидите .on<NormalItemPage>() и .on<LimitedTimeSaleItemPage>() внутри тест-кейсов и не будете их путать.

Предпосылки


Распространенная реализация заключается в том, что каждый метод объекта страницы определяет, каким будет следующий и возвращает его. Однако это вызывает некоторые проблемы:

  • Разная навигация через одну и ту же операцию

Обычно одна и та же операция заставляет приложение переходить в разные фрагменты. Например, SearchViewPage.searchKeyword({id}) переходит к фрагменту товара, но .searchKeyword({brand name}) должен переходить к фрагменту бренда.

Одним из решений является разделение на разные методы, например searchById(id: String): ItemPage и searchByBrand(brand: String): BrandPage, но суть обоих реализуется идентично:

Espresso.onView(allOf(withId(R.id.search_input), isDisplayed()))
        .perform(clearText())
        .perform(replaceText(keyword))
        .perform(pressImeActionButton())

Этот код является дублированным, поэтому мы объединяем конечный объект страницы с нашим фактическим кейсом, который будет выглядеть так:

@Test
fun testSearchById() {
   Page.on<DiscoveryPage>()
       .on<SearchBoxPage>()
       .click()
       .on<SearchViewPage>()
       .searchKeyword("7882691")
       .on<ItemPage>()
       .withTitle("A1NJ5J02")
}

@Test
fun testSearchByBrand() {
   Page.on<DiscoveryPage>()
       .on<SearchBoxPage>()
       .click()
       .on<SearchViewPage>()
       .searchKeyword("timberland")
       .on<BrandPage>()
       .withTitle("Timberland"

  • Навигация «Назад» из разных entry points (*entry points — адрес в оперативной памяти, с которого начинается выполнение программы, другими словами: адрес, по которому хранится первая команда программы)

Некоторые фрагменты будут созданы из разных точек входа — например, страница товара из фрагмента поиска или страница бренда по клику на товар из списка. А иногда одни и те же фрагменты должны возвращаться в другой стек после различных результатов поведения. Как упоминалось выше, мы не позволим back() реагировать как-то определенно:

@Test
fun testItemDetail() {
    Page.on<ItemPage>()
        .clickDetail()
        .on<WebPage>()
        .withTitle("The Product Details")
        .back()
        .on<ItemPage>()

}

@Test
fun testBrandDetail() {
    Page.on<BrandPage>()
        .clickDetail()
        .on<WebPage>()
        .withTitle("The Brand Details")
        .back()
        .on<BrandPage>()
}

  • Шаг в дочерний компонент без каких-либо действий

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

image

@Test
fun testSearchById() {
   Page.on<DiscoveryPage>()
       .on<SearchBoxPage>()
       .click()
       .on<SearchViewPage>()
       .searchKeyword("7882691")
       .on<ItemPage>()
       .withTitle("A1NJ5J02")
}

Дизайн архитектуры


Мы определяем базовый класс Page, от которого наследуются все остальные объекты страницы. Базовый класс имеет второстепенную функцию fun <reified T : Page> on(): T, которая возвращает экземпляр PageObject по типу T, таким образом, мы можем объединить Page.on<{PageObject}>() в любое время и определить текущий объект страницы, полностью опираясь на тестовые операции, независимо от выполняемого им метода.

Эта идея возникла в результате доклада Вивиан Ли (Vivian Liu) Design Patterns in XCUITest. Благодарим Вивиан за то, что она поделилась им на iPlayground 2018 на Тайване.

Page.on() получает дженерик T и возвращает фактический экземпляр T. Мы могли бы сделать каждый объект страницы синглтоном и найти соответствующий, но это будет модифицировать Page.on() каждый раз, когда создается новый PageObject, это плохо поддерживается, поэтому мы используем T :: class.constructors.first().call(), чтобы получить конструкторы дженериков и получить первый, как правило непараметрический, конструктор для создания экземпляра T.

open class Page {

     companion object {
        inline fun <reified T : Page> on(): T {
           return Page().on()
        }
    }

    inline fun <reified T : Page> on(): T {
        val page = T::class.constructors.first().call()
        page.verify()
        return page
     }

    open fun verify(): Page {
        // Each subpage should have its default assurances here
       return this
    }

    fun back(): Page {
       Espresso.pressBack()
       return this
    }
}

Reified в Kotlin полезен, чтобы сделать тест-кейс более содержательным. Иначе нам приходилось бы писать его, создавая объекты страницы. Это не будет неправильным, но не будет иметь связи между действиями.

// with reified
Page.on<DiscoveryPage>()
    .on<SearchBoxPage>().click()
    .on<SearchViewPage>().searchKeyword("7882691")
// without reified
DiscoveryPage()
SearchBoxPage().click()
SearchViewPage().searchKeyword("7882691")

Page также реализует функцию fun back(): Page которая возвращает базовый Page-класс, потому что нам не нужно, чтобы back() реагировал на определенный объект страницы. Это решение позволяет нам легко указывать, какой объект страницы нам возвращается после действия назад.

И не забывайте, что другие объекты страницы должны наследовать Page и настраивать verify() для выполнения проверок по умолчанию.

class ItemPage : Page() {

    override fun verify(): Page {
        Espresso.onView(withId(R.id.productitem_name))
                .check(matches(withEffectiveVisibility(VISIBLE)))
        return this
    }

    fun withTitle(title: String): ItemPage {
        Espresso.onView(withId(R.id.productitem_name))
                .check(matches(ViewMatchers.withText(keyword)))
        return this
    }
}

class SearchViewOage : Page() {

    override fun verify(): SearchView {
        Espresso.onView(withId(R.id.search_input))
                .check(matches(withEffectiveVisibility(VISIBLE)))
        return this
    }

    fun searchKeyWord(keyword: String): Page {
        Espresso.onView(allOf(
                          withId(R.id.search_input),
                          isDisplayed()
                       ))
                .perform(clearText())
                .perform(replaceText(keyword))
                .perform(pressImeActionButton())
        return this
    }
}
Теги:
Хабы:
Всего голосов 5: ↑4 и ↓1+6
Комментарии1

Публикации

Истории

Работа

Ближайшие события