Как создать и оптимизировать общие Robolectric и Android Instrumentation тесты
Всем, привет! Я — Александр Грибук, 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 ... } }
Это решение позволяет легко устранять различия в реализации общих функций, требующих использование специфических классов для конкретного типа теста, но оно приводит к нечеткой структуре и большому количеству шаблонного кода. Поскольку если использовать общую логику больше, чем в одной реализации, например в
Паттерн 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 }
Обратите внимание:
- Классы из этого файла будут использоваться в базовом тестовом классе и должны быть помещены в каталог
sharedTest
.
TestApplicationComponent
— это не Dagger@Component
, это просто общий интерфейс для реальных компонентов, позволяющий вызывать методinject ()
реализации компонента из общего тестового класса (где нет информации о реализациях компонентов).
- В приведенном выше коде вы можете увидеть использование
SharedTestsServiceLocator
(с рефлексией) вTestApplication
для предоставленияTestAppComponentProvider
— это единственное место, где вы должны его использовать.
- Чтобы использовать
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 за помощь в написании этой статьи — Александру Шауберту и Андрею Деркачу.
Немає коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів