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

Life can only be understood backwards, but it must be lived forwards.

Кьеркегор

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

Robolectric — это мощный инструмент для UI‑тестирования Android‑кода без эмулятора. Когда я использовал Robolectric в работе, испытал удивление от того, как работает библиотека.

Помимо возможности в юнит‑тестах работать с Android‑компонентами, Robolectric позволяет работать с View и получать из неё Bitmap, что решает не только задачу проверки взаимодействия с View, но и позволяет проверить отображение экрана. И хотя в Android‑комьюнити у Robolectric неоднозначная репутация из‑за трудностей совместимости с другими библиотеками, его почти бесценные возможности пробудили любопытство копнуть глубже и осмыслить этот инструмент.

Цель: что я хотел на старте от Robolectric?

Хочу разобраться, как Robolectric работает с Android‑компонентами без эмулятора.

Что не так с Java

В начале нужно понять, а достаточно ли обычной версии Java.

Из‑за юридических разногласий с Oracle Google пришлось отказаться от Java SE в пользу OpenJDK (свободная реализация спецификации Java SE). Вместе с этим Android также использует набор реализаций Java‑библиотек, предназначенных для работы в мобильном приложении. Они собраны на Libcore. Однако для написания юнит‑тестов дополнительные возможности нам не нужны, а в противном случае нам лучше использовать инструментальные тесты.

Android Platform Architecture

Архитектура Android содержит ряд слоёв, но нужно определить, нужны ли они для написания юнит‑тестов. Погружаться глубоко не буду, так как моя цель — лишь рассмотреть тему через призму юнит‑тестов, требующих минимум зависимостей, но позволяющих избавиться от мобильного устройства.

Linux Kernel
Linux Kernel

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

HAL, ART, Native Libraries
HAL, ART, Native Libraries

В тестах мы не сможем так просто поднять целую платформу для работы с аппаратными функциями, а значит не сможем получить доступа к OpenGL (Native Library), Camera (Kernel) и прочим специфическим элементам, а следовательно, интерфейс для работы с аппаратурой (HAL) нам тоже не пригодится.

ART — это исполняемая среда, используемая на реальных Android‑устройствах для выполнения байт‑кода приложений. Её функции: сборка мусора (GC), Just‑In‑Time (JIT) и Ahead‑Of‑Time (AOT) компиляция. Наш код должен выполняться в рамках JVM, поэтому ART нам не потребуется. Если ваши тесты основаны на специфике работы с Native Libraries или ART, придётся использовать инструментальные тесты.

Android Framework
Android Framework

А вот Android Java Framework нам будет нужен, чтобы работать с Android‑компонентами в тестах. В частности здесь же расположены Handler и Looper.

С самого начала Android существовал Binder IPC, в том числе для коммуникации между компонентами Android, а начиная с Android 8, начал использоваться и для коммуникации между Android Framework и HAL‑слоем.

Binder IPC — это важный компонент, но эту часть системы скопировать уже не сможем, поэтому всё взаимодействие с ним закрыто моками.

Ситуация с View

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

Однако благодаря включению параметра NativeMode в Robolectric можно получить приближённую к реальности Bitmap из View. NativeMode позволяет переключить легаси Shadows на NativeShadows, содержащие больше реального кода классов, например, Bitmap или BitmapFactory.

android-all

В репозитории AOSP расположен ряд jar‑файлов для каждой версии Android, это и есть наш Android Framework, используемый для Robolectric. Jar‑файлы собираются с появлением новой версии Android, что позволяет нам писать юнит‑тесты, используя в тестах Android Framework разных версий.

Но погодите, а как нам использовать собранные jar‑файлы в тестах?

ClassLoader

С ClassLoader JVM может не знать заранее о функционале, которым она будет пользоваться. Он позволяет загружать в память классы, которые были запрошены, причём загружать код можно динамически во время выполнения программы, как и подгружать его, скажем, из сети.

val androidAllJarPath = "android-all.jar"
val savePath = context.getDir("cache", Context.MODE_PRIVATE)

val classLoader = DexClassLoader(
  androidAllJarPath,
  savePath.absolutePath,
  null,
  commonClassLoader,
)

val loadedClass = classLoader.loadClass("android.widget.TextView")
val instance = loadedClass.newInstance()
val method = loadedClass.getMethod("requestLayout")

method.invoke(instance)

ClassLoader в Robolectric

Перед запуском Robolectric инициализирует Sandbox, который помимо высокоуровневых настроек содержит SandboxClassLoader. Он делает возможным загружать Android‑классы не только из android.jar (здесь только Stub), но также из android‑all.jar.

Кстати, помимо прочего, Sandbox также создает ExecutorService для симуляции работы главного потока.

Bytecode

Одних скомпилированных android‑all.jar файлов, лежащих в MavenCentral, недостаточно. Нужно произвести ряд изменений в текущем коде перед работой, в чём помогает ObjectWeb ASM. С ним можно производительно манипулировать bytecode, что и происходит внутри библиотеки Robolectric в рамках ClassInstrumentor.

override fun visitMethod(
    access: Int,
    name: String?,
    descriptor: String?,
    signature: String?,
    exceptions: Array<out String>?
): MethodVisitor {
    val methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions)
    return if (name == "doSomething") {
        object : MethodVisitor(ASM9, methodVisitor) {
            override fun visitCode() {
                super.visitCode()
                methodVisitor.visitFieldInsn(
                    GETSTATIC,
                    "java/lang/System",
                    "out",
                    "Ljava/io/PrintStream;"
                )
                methodVisitor.visitLdcInsn("Hello world!")
                methodVisitor.visitMethodInsn(
                    INVOKEVIRTUAL,
                    "java/io/PrintStream",
                    "println",
                    "(Ljava/lang/String;)V",
                    false
                )
            }
        }
    } else {
        methodVisitor
    }
}

В коде выше я привёл пример части функционала, в котором я метод doSomething своего класса дополнил в начале строкой println("Hello world!")

Robolectric Architecture

Мы пришли к тому, что в недрах Robolectric использует набор Jar‑файлов, собранных под разные релизы в Android platform sources. Набор Jar (android‑all) собирается в MavenCentral и может быть загружен оттуда. Это позволяет библиотеке использовать как можно больше фичей самого Android.

Какие манипуляции производит ObjectWeb ASM:

  1. Модификация всех методов, конструкторов и статических блоков, чтобы перехватить выполнение и делегировать его другим сущностям для механизма shadowing

  2. Отдельно изменяются конструкторы для создания shadow‑объектов

  3. Из‑за разницы между libcore (модифицированная реализация Java) и JDK некоторые методы переписываются

  4. Из классов и методов удаляется ключевое слово final

  5. Трансформация native‑методов и поддержка специальных инструментов

Shadowing

Ряд классов в Android SDK работает по дефолту без модификаций кода, потому что основаны на чистой Java. Такие классы можно спокойно запустить на JVM (например, Intent).

Однако в ряде случаев приходится перехватывать и подменять вызовы методов, используя механизм Shadowing (Shadow classes). С Robolectric также можно создавать кастомные Shadow classes.

Custom Shadow

@Implements(ImageView::class)
open class CustomShadowImageView : ShadowView() {
    @RealObject
    lateinit var realImageView: ImageView

    var longClickPerformed: Boolean = false
        private set

    @Implementation
    protected override fun performLongClick(): Boolean {
        longClickPerformed = true
        return super.performLongClick()
    }
}

Invokedynamic

Invokedynamic — это байт‑код инструкция, чтобы динамически связывать вызов метода во время выполнения, а не во время компиляции, чтобы мы в момент выполнения программы понимали, использовать нам реальный класс или его Shadow.

Что получаем в итоге

Мы можем писать тесты, задействуя Android‑классы, в том числе взаимодействовать с интерфейсами и отрисовывать свои экран. Отдельно подчеркну, что можно получать Bitmap из View и строить решения по скриншот‑тестированию на основе Robolectric.

Robolectric in action

val controller = Robolectric.buildActivity(ConfiguredActivity::class.java)
controller.setup()
controller.applyState(state)
val activity = controller.get()
val view = activity.findViewById<View>(android.R.id.content)
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
view.draw(canvas)

С кодом выше вы можете получить отображение вашей Activity в Bitmap.

private fun ActivityController<out FragmentActivity>.applyState(
    state: ForComponentState
): ActivityController<out FragmentActivity> {
    val newConfig = Configuration(this@applyState.get().resources.configuration)
    return this@applyState.configurationChange(newConfig.applyState(state))
}

private fun Configuration.applyState(state: ForComponentState): Configuration {
    val newConfig = this@applyState
    state.uiMode?.let { newConfig.uiMode = it.configurationInt }
    state.displaySize?.let { newConfig.densityDpi = it.configurationInt }
    state.fontScale?.let { newConfig.fontScale = it }
    state.locale?.let { newConfig.setLocale(it) }
    return newConfig
}

Пермишены

Robolectric не эмулирует показ системного диалога запрошенных разрешений, а все запросы разрешений по дефолту будут рассматриваться как DENIED. Для работы с разрешениями в тесте нужно явно указывать выдачу или удаление пермишенов:

Shadows.shadowOf(activity).grantPermissions(Manifest.permission.CAMERA)
Shadows.shadowOf(activity).denyPermissions(Manifest.permission.CAMERA)

Сетевые запросы

Реальные сетевые запросы возможны в тестах, но есть проблемы:

  1. Тесты могут замедлиться из‑за блокировок главного потока.

  2. Стабильность тестов может начать зависеть от стабильности тестовой среды, что повышает риск флака.

Когда я тестировал работу с сетью с библиотекой по работе с картинками, я обнаружил, что при наличии стратегии кэширования одни и те же картинки не грузятся по сети, а иногда успевают браться из кеша, из‑за чего могут успеть отрисоваться. Но это всё равно достаточно нестабильно.

Если перехватывать сетевые запросы, но делать это не на главном потоке, а асинхронно (загрузка картинок для элементов отображения зачастую загружается именно так), то флак всё равно остаётся.

Зависимости

Экраны в рамках тестов могут требовать зависимости. При необходимости передачи параметров по фрагменту, можно явно установить значение аргументов. Activity можно сразу открыть с интентом, обогащённым данными в bundle:

buildActivity(Class<T> activityClass, Intent intent)

Compose

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

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

Анимации

В Robolectric нельзя полноценно тестировать анимации, но вы всё ещё можете проверять, например, конечные состояния анимации или колбеки анимации.

WebView

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

Robolectric и JUnit 5

Обратной стороной Robolectric в моём понимании является несовместимость с JUnit 5. Подробнее о данной теме можно почитать в статье моих коллег.

Интересные факты о Robolectric

  • Позволяет работать с Room и SQLite;

  • Поддерживает параметризованные тесты;

  • Работает с MotionEvent, Телефонией, Media;

  • Возможно тестирование мемори‑ликов GcFinalization.awaitClear(weekReference);

  • Совместим с библиотеками для мокирования Mockito и Mockk;

  • Возможно тестирование безопасности (Java Security Providers);

  • Поддерживает работу со SparseArray.

Итог

Надеюсь, вам, как и мне, приоткрылась тайна устройства Robolectric. Оказывается, мы имеем дело со знакомым, хоть и редким набором инструментов, собранных в интересный коктейль возможностей.

Пишите, работали ли вы с Robolectric и что думаете об этом инструменте?

Комментарии (3)