Annual Open Tech Conference - ISsoft Insights 2021. June 19. Learn more.
×Закрыть

Как создать и оптимизировать общие Robolectric и Android Instrumentation тесты

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Всем, привет! Я — Александр Грибук, Android-разработчик в R&D-центре британской финтех-компании Wirex. Моя роль заключается в создании продукта, который позволяет клиентам управлять своими традиционными деньгами и криптовалютами в одном приложении.

Когда продукт работает с деньгами пользователей, нужно всегда быть уверенным, что все flow в приложении, и приложение в целом, работают должным образом. Вот почему мы уделили много времени созданию различных тестов для нашей кодовой базы — End-to-End (E2E), Unit, User Interface (UI) и т. д.

Самыми ценными тестами для нас всегда были E2E, потому что они могут проверять конкретные сценарии от начала до конца. Например, тестирование одного экрана, — мы подменяем ответы сервера и проверяем, что UI находится в правильном состоянии. В качестве альтернативы, — тест отдельного flow, который имитирует взаимодействие пользователя, например, заполнение полей формы. Такой тип теста проверяет, что UI выглядит так, как и должен на каждом этапе сценария, и при завершении сбора информации проверяет правильность вызовов API.

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

Однако после завершения миграции тестов на эмулятор мы поняли, что некоторые тесты, которые проходили с Robolectric, падали на Android Instrumentation, что позволило нам найти ранее неизвестные баги.

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

Проблема 1: Общий код

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

В следующем простом примере я хочу показать шаги для вынесения и повторного использования общего кода. Для этого мы будем использовать тест для проверки изменения видимости View элемента.

Для этого напишем два теста — один будет запускаться с помощью Robolectric, а второй — с помощью Android Instrumentation.

Robolectric:

@RunWith(RobolectricTestRunner::class)
class ViewHidingRobolectricTest {

    private val viewToCheck = onView(withId(R.id.viewToCheck))
    private val btnShow = onView(withId(R.id.btnShow))
    private val btnHide = onView(withId(R.id.btnHide))

    @Test
    fun checkViewHiding() {
        ActivityScenario.launch(ViewHidingActivity::class.java)
        
        // Check is view displayed by default
        viewToCheck.check(matches(isCompletelyDisplayed()))

        // Perform hide button click and check view visibility
        btnHide.perform(click())
        viewToCheck.check(matches(not(isCompletelyDisplayed())))

        // Perform show button click and check view visibility
        btnShow.perform(click())
        viewToCheck.check(matches(isCompletelyDisplayed()))
    }
}

Instrumentation:

class ViewHidingInstrumentationTest {

    private val viewToCheck = onView(withId(R.id.viewToCheck))
    private val btnShow = onView(withId(R.id.btnShow))
    private val btnHide = onView(withId(R.id.btnHide))

    @Test
    fun checkViewHiding() {
        ActivityScenario.launch(ViewHidingActivity::class.java)

        // Check is view displayed by default
        viewToCheck.check(matches(isCompletelyDisplayed()))

        // Perform hide button click and check view visibility
        btnHide.perform(click())
        viewToCheck.check(matches(not(isCompletelyDisplayed())))

        // Perform show button click and check view visibility
        btnShow.perform(click())
        viewToCheck.check(matches(isCompletelyDisplayed()))
    }
}

Как видите, вся логика одинакова для каждого типа тестов. Итак, вы можете создать некий общий тест для таких случаев и повторно использовать его для реализации Robolectric и Android Instrumentation. Для этого вам нужно создать директорию или папку в app/src, где вы будете размещать все общие компоненты и тесты. В этом примере я назвал его sharedTest.

Project structure

Также желательно, чтобы структуры папок внутри sharedTest, androidTest и test были консистентны.

sharedTest inner structure

Далее необходимо указать эту папку как source set для обоих типов тестов — Instrumentation и Robolectric.

android {
    
    //default gradle settings
    
    sourceSets {
        test {
            java.srcDirs += "src/sharedTest/kotlin"
        }
        androidTest {
            java.srcDirs += "src/sharedTest/kotlin"
        }
    }
}

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

open class ViewHidingTest {

    private val viewToCheck = onView(withId(R.id.viewToCheck))
    private val btnShow = onView(withId(R.id.btnShow))
    private val btnHide = onView(withId(R.id.btnHide))

    protected fun checkViewHiding() {
        ActivityScenario.launch(ViewHidingActivity::class.java)

        // Check is view displayed by default
        viewToCheck.check(matches(isCompletelyDisplayed()))

        // Perform hide button click and check view visibility
        btnHide.perform(click())
        viewToCheck.check(matches(not(isCompletelyDisplayed())))

        // Perform show button click and check view visibility
        btnShow.perform(click())
        viewToCheck.check(matches(isCompletelyDisplayed()))
    }
}

Примечание. Нет необходимости использовать @RunWith или @Test аннотации в общем тестовом классе, так как эти аннотации будут указаны в конкретных реализациях.

И теперь вы можете упростить 2 других теста:

Robolectric:

@RunWith(RobolectricTestRunner::class)
class ViewHidingRobolectricTest : ViewHidingTest() {

    @Test
    fun checkViewHidingRobolectric() = checkViewHiding()
}

Instrumentation:

class ViewHidingInstrumentationTest : ViewHidingTest() {

    @Test
    fun checkViewHidingInstrumentation() = checkViewHiding()
}

Что ж, это уже хороший результат — общий код используется повторно, и у вас есть четкая структура тестов. Однако, если вы создаете тест с более сложной логикой, чем наш пример, вы столкнетесь с действиями и функциями, которые требуют различных реализаций для тестов на Robolectric и Android Instrumentation.

Проблема 2: Test type specific code

Давайте немного приблизим этот тест к реальным кейсам. Представьте, что первым этапом теста нам нужно сделать некий шаг с подготовкой, и этот шаг должен использовать определенную логику для инструментальных и Robolectric тестов (например, InstrumentationRegistry для Instrumentation и Shadow для Robolectric).

Основная проблема здесь в том, что если вы хотите создать правильную структуру модулей и пакетов, то тогда общий тестовый класс не должен использовать специфические классы и методы для какого-либо типа теста. В Wirex мы достигли этого, разделив файлы build.gradle для тестов Robolectric и Instrumentation, поэтому мы даже не можем импортировать, например, Shadow в общий тестовый класс.

Исходя из того, что мы разделили build.gradle нам необходимо реализовать нужное поведение в наследниках базового тестового класса. Этого можно достичь, используя разные подходы, и мы рассмотрим каждый из них, от простого (и наименее функционального и расширяемого) до сложного.

Паттерн Factory Method

Вы можете решить проблему кода, зависящего от типа теста, используя паттерн Factory Method.

abstract class SomeTest {

    protected fun test(){
        preparation()
        
        //common part for all test below
        ...
    }

    protected abstract fun preparation()
}

После этого вам необходимо определить конкретную логику в обеих реализациях.

Instrumentation:

class SomeInstrumentationTest : SomeTest() {
    
    @Test
    fun instrumentationTest() = test()

    override fun preparation() {
        //specific code for instrumentation tests below
        ...
    }
}

Robolectric:

@RunWith(RobolectricTestRunner::class)
class SomeRobolectricTest : SomeTest() {
    
    @Test
    fun robolectricTest() = test()

    override fun preparation() {
        //specific code for robolectric tests below
        ...
    }
}

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

Паттерн Service Locator

Еще один способ вынести общий код с возможностью осуществлять разные реализации определенных методов под разные типы тестов, — переместить определенную логику в классы, соответствующие типу теста, и предоставить ее в общем тестовом классе, используя простую реализацию паттерна Service Locator.

Первым делом вам нужно создать интерфейс для вспомогательного класса, который будет использоваться в том самом шаге подготовки, о котором писалось выше. Этот интерфейс будет использоваться в общем тестовом классе, поэтому его следует поместить в папку sharedTest:

interface PreparationHelper {

    fun prepare()
}

После этого создайте реализации для разных типов тестов. InstrumentationPreparationHelper следует поместить в каталог androidTest, а RobolectricPreparationHelper — в каталог test.

class InstrumentationPreparationHelper : PreparationHelper {

    override fun prepare() {
        //specific code for instrumentation tests below
        ...
    }
}
class RobolectricPreparationHelper: PreparationHelper {

    override fun prepare() {
        //specific code for robolectric tests below
        ...
    }
}

Следующий шаг — создание класса Service Locator, который предоставит необходимую реализацию PreparationHelper. Поскольку классы в sharedTest не имеют представления о других тестовых папках и их классах, невозможно предоставить их реализацию без использования рефлексии. Например, вы можете найти аналогичный подход в классе UiControllerModule из пакета Espresso.

Класс Service Locator также следует разместить в папке sharedTest, чтобы можно было использовать локатор в общих тестовых классах.

class SharedTestsServiceLocator {

    companion object {

        fun preparationHelper(): PreparationHelper =
            // try to find RobolectricPreparationHelper
            createClassForName<PreparationHelper>(
                "com.example.projectname.utils.RobolectricPreparationHelper"
            ) ?: 
            // if there is no RobolectricPreparationHelper, try to find InstrumentationPreparationHelper
            createClassForName<PreparationHelper>(
                "com.example.projectname.utils.InstrumentationPreparationHelper"
            ) ?:
            // if there is no InstrumentationPreparationHelper - throw exception
            throw RuntimeException("There no PreparationHelper implementation found")


        @Suppress("UNCHECKED_CAST")
        private fun <T> createClassForName(className: String): T? {
            return try {
                Class.forName(className).newInstance() as T
            } catch (ex: ClassNotFoundException) {
                null
            }
        }
    }
}

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

Затем вам нужно обновить общий тестовый класс и его реализации под соответствующие типы тестов, чтобы использовать SharedTestsServiceLocator:

open class SomeTest {

    protected fun test() {
        val preparationHelper = SharedTestsServiceLocator.preparationHelper()
        preparationHelper.prepare()

        //common part for all test below
        ...
    }
}
class SomeInstrumentationTest : SomeTest() {

    @Test
    fun instrumentationTest() = test()
}
@RunWith(RobolectricTestRunner::class)
class SomeRobolectricTest : SomeTest() {

    @Test
    fun robolectricTest() = test()
}

В зависимости от типа теста будет выполнен разный код подготовки. Использовать этот подход довольно просто и быстро, но он также имеет серьезные недостатки. Дело в том, что если ссылка на ваши вспомогательные классы будет изменена, вы не сможете ее автоматически изменить внутри Service Locator. Кроме того, поскольку классы, реализующие PrepatationHelper определяют IDE как неиспользуемый, это также может привести к некоторым проблемам и неожиданным ситуациям, например, нежелательному удалению классов новыми членами команды, или ошибкам линтера, если он настроен определенным образом, и в том числе сообщает о неиспользуемых классах.

Dependency Injection (DI) & Service Locator

Чтобы свести к минимуму использование Service Locator и сделать кодовую базу более гибкой, понятной и читаемой, вы можете обновить свою логику, используя паттерн Dependency Injection в реализации Dagger2.

Прежде всего, вам нужно определить TestApplicationComponent — интерфейс для будущего Dagger @Component. В реальных случаях для некоторых компонентов может потребоваться Application или Context, поэтому рекомендуется также создать класс TestApplication, в котором вы будете создавать свои компоненты и провайдить им необходимые зависимости. Также вам понадобятся классы для предоставления различных реализаций TestApplicationComponent. В приведенном ниже примере это класс TestAppComponentProvider.

class TestApplication : Application() {

    lateinit var appTestComponent: TestApplicationComponent

    override fun onCreate() {
        super.onCreate()

        appTestComponent = SharedTestsServiceLocator
            .testAppComponentProvider()
            .getTestApplicationComponent()
    }
}

interface TestApplicationComponent {

    fun inject(someTest: SomeTest)
}

interface TestAppComponentProvider {

    fun getTestApplicationComponent(): TestApplicationComponent
}

Обратите внимание:

  1. Классы из этого файла будут использоваться в базовом тестовом классе и должны быть помещены в каталог sharedTest.
  1. TestApplicationComponent — это не Dagger @Component, это просто общий интерфейс для реальных компонентов, позволяющий вызывать метод inject () реализации компонента из общего тестового класса (где нет информации о реализациях компонентов).
  1. В приведенном выше коде вы можете увидеть использование SharedTestsServiceLocator (с рефлексией) в TestApplication для предоставления TestAppComponentProvider — это единственное место, где вы должны его использовать.
  1. Чтобы использовать TestApplication в тестах, вы должны создать собственный TestRunner, где вы определите, какой класс Application использовать для обоих вариантов теста.

Следующий шаг — определение реализаций TestApplicationComponent и TestAppComponentProvider для инструментальных и тестов на Robolectric. Вам также необходимо добавить аннотацию @Module, чтобы запровайдить необходимую реализацию PreparationHelper.

Instrumentation:

@Component(
    modules = [
        InstrumentationTestModule::class
    ]
)
interface InstrumentationApplicationComponent : TestApplicationComponent

@Module
class InstrumentationTestModule {

    @Provides
    fun providesPreparationHelper(
        instrumentationPreparationHelper: InstrumentationPreparationHelper
    ): PreparationHelper = instrumentationPreparationHelper
}

class InstrumentationTestAppComponentProvider : TestAppComponentProvider {
    override fun getTestApplicationComponent() =
        DaggerInstrumentationApplicationComponent.builder()
            .instrumentationTestModule(InstrumentationTestModule())
            .build()
}

Robolectric:

@Component(
    modules = [
        RobolectricTestModule::class
    ]
)
interface RobolectricApplicationComponent : TestApplicationComponent

@Module
class RobolectricTestModule {

    @Provides
    fun providesPreparationHelper(
        robolectricPreparationHelper: RobolectricPreparationHelper
    ): PreparationHelper = robolectricPreparationHelper
}

class RobolectricTestAppComponentProvider : TestAppComponentProvider {
    override fun getTestApplicationComponent() =
        DaggerRobolectricApplicationComponent.builder()
            .robolectricTestModule(RobolectricTestModule())
            .build()
}

Обновление Service Locator для предоставления необходимого TestAppComponentProvider :

class SharedTestsServiceLocator {

    companion object {

        fun testAppComponentProvider(): TestAppComponentProvider =
            createClassForName<TestAppComponentProvider>(
                "com.example.projectname.RobolectricTestAppComponentProvider"
            ) ?: createClassForName<TestAppComponentProvider>(
                "com.example.projectname.InstrumentationTestAppComponentProvider"
            ) ?: throw RuntimeException("There no TestAppComponentProvider implementation was found")


        @Suppress("UNCHECKED_CAST")
        private fun <T> createClassForName(className: String): T? =
            try {
                Class.forName(className).newInstance() as T
            } catch (ex: ClassNotFoundException) {
                null
            }
    }
}

Наконец, вы можете обновить общий тестовый класс. Код для реализации тестов на Robolectric и при помощи Instrumentation не меняется.

open class SomeTest {

    @Inject
    lateinit var preparationHelper: PreparationHelper

    @Before
    fun setUp() {
        ApplicationProvider
            .getApplicationContext<TestApplication>()
            .appTestComponent
            .inject(this)
    }

    protected fun test() {
        preparationHelper.prepare()

        //common part for all test below
        ...
    }
}

После этого обновления вы сократите размер SharedTestsServiceLocator, и таким образом он должен предоставить только необходимый TestAppComponentProvider.

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

Выводы

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

Особая благодарность команде Wirex Android за помощь в написании этой статьи — Александру Шауберту и Андрею Деркачу.

👍НравитсяПонравилось6
В избранноеВ избранном0
Подписаться на автора
LinkedIn

Похожие статьи

Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Подписаться на комментарии