
Представьте, что можно тестировать android-код без эмулятора, запуская тесты за секунды вместо минут. Именно это обещает Robolectric — библиотека, которую либо любят, либо ненавидят, но точно не игнорируют.
За кажущейся простотой «просто добавь зависимость» скрывается удивительная магия модификации байткода, о которой не рассказывают в статьях. Предлагаю разобраться, как на самом деле работает магия Robolectric и почему эти знания пригодятся любому android-разработчику.
Ставим задачу
Представим, что мы — молодой android-разработчик, который недавно начал карьеру. На волне юношеского перфекционизма, начитавшись литературы о том, как правильно разрабатывать софт, бежим писать свой первый unit-тест.
Представим, что на нашем проекте есть такой ViewModel:
class SimpleViewModel : ViewModel() {
private val _data = MutableStateFlow(0)
val data: Flow<Int> = _data.filter { it > 0 }
fun onClick() = viewModelScope.launch {
withContext(Dispatchers.IO) {
// имитируем бурную деятельность, прям как на работе!
delay(100)
_data.value = 5
}
}
}
Подключаем нужные зависимости и накидываем первые строчки кода для теста:
@Test
fun test() = runTest {
viewModel.onClick()
assert(viewModel.data.first() == 5)
}
Но вот разочарование! Запустив тест, видим исключение:
Caused by: java.lang.RuntimeException: Method getMainLooper in android.os.Looper not mocked.
Пупер и лупер устроились android-разработчиками, но в бухгалтерии все перепутали и, ну вы знаете...
Оказывается, нельзя просто так взять и в unit-тесте проверить код, который использует хоть какую-то зависимость из пакета с названием, начинающимся на «android».

Дело в том, что все методы системы, которые мы используем при написании кода, находятся в android.jar. Правда, в нашем android.jar лежат лишь стабы, вместо реализации каждого метода бросается исключение. Gradle любезно подключает android.jar к основному sourceset наших модулей как compileOnly-зависимость, то есть только для компиляции.
Когда наше приложение запускается на реальном Android, там уже есть загруженные классы и все работает. Но в unit-тестах android.jar подключается уже как implementation, то есть подключается как обычная либа. Поэтому мы и падаем при попытке использовать какой угодно класс из пакета android.
У этой проблемы есть три основных решения:
Вынести весь код, который использует android-зависимости за интерфейсы. Для данной задачи этот метод подходит лучше всего: в корутинах легко можно подменить Dispatcher.Main на какой-то другой.
Сделать UI-тест. Способ дорогой как с настройкой инфры на CI, так и с точки зрения написания теста, особенно если их еще нет на проекте.
Использовать Robolectric.
В идеальном мире мы бы использовали только первый подход. Второй оставили для самых критичных кейсов. Но в реальном мире далеко не всегда есть возможность покрыть все UI-тестами или вынести все android-зависимости за интерфейс. Для таких кейсов и существует Robolectric.

Рассматриваем Robolectric
Robolectric — уникальная технология. Уникальная по той причине, что к ней невозможно относиться нейтрально. У тебя либо нет Robolectric на проекте, либо ты его ненавидишь. Те, кто использует его у себя на проектах, не дадут соврать. Но при всех проблемах нельзя отрицать, что это страшно крутое инженерное решение.
Библиотека, которая позволяет использовать все android-зависимости так, будто ты запускаешь тесты в реальном устройстве. Невольно возникает вопрос: а как работает эта магия?
Представим, что нам выпала задача: у нас куча классов, методы которых бросают исключение. При этом очень хочется написать тесты и как-то подменить эти методы на свою реализацию. Вынести их за интерфейс не вариант. Что делать будем?
Для примера возьмем библиотеку с одним-единственным классом SomeSystemApi. Реализации у нас есть, только API. Допустим мы хотим, чтобы в тестах он возвращал 42.
class SomeSystemApi {
fun getVersionOfSystem(): Int = throw IllegalArgumentException()
}
Можно просто взять и полностью скопировать библиотеку, сохранив API, только со своей реализацией. Дальше вынести ее в отдельный модуль и подключать его в тестах. Правда, переписать весь пакет android практически нереально. Так еще и при попытке поменять поведение в конкретном классе нужно эту самую либу менять.
Еще можно использовать mockito. Но, если мы говорим про Android, придется замокать полмира. При этом еще и не факт, что получится добраться до нужных классов. А можно поступить чуть изящнее и использовать Classloader.
Небольшая историческая справка про то, что такое Classloader. Вот мы написали код kotlin/java, затем отдали этот код компилятору, который сгенерировал файлы с расширением .class. Далее эти файлы упаковываются в jar (или в apk).

Когда приложение запускается, JVM нужно загрузить все эти классы, и тут приходит Classloader. Когда JVM считает, что ей нужно загрузить определенный класс, она просит Classloader это сделать.
При этом JVM не загружает все классы сразу, она подгружает их лениво. Могут быть классы, которые так и не подгрузятся JVM, хотя они лежат в apk.

Фишка Classloader в том, что можно сделать свою реализацию, в которой перехватывать момент загрузки класса и менять байткод на нужный. Именно это и делает Roboleсtric.
Реализуем пример
Ну, как говорится, не будем о плохом, а лучше сделаем. Наследуемся от ClassLoader и переопределяем метод loadClass:
override fun loadClass(
name: String,
resolve: Boolean
): Class<*> = synchronized(getClassLoadingLock(name)) {
// проверяем, не загружен ли уже класс в память?
var loadedClass = findLoadedClass(name)
if (loadedClass != null) return loadedClass
// загружаем только наши классы
loadedClass = if (name.startsWith("com.example.")) {
val resource = name.replace('.', '/') + ".class"
// используем parent classloader, чтобы получить доступ к файлу
// нашего нужного класса
val stream = parent.getResourceAsStream(resource)
?: throw ClassNotFoundException("Could not find class: $name")
// если это наш класс — тогда меняем, если нет — просто загружаем байты
val needTransformClass = name == "com.example.SomeSystemApi"
val classBytes = if (needTransformClass) {
transform(stream.readBytes())
} else {
stream.readBytes()
}
// собираем объект Class из массива байт
defineClass(name, classBytes, 0, classBytes.size);
} else {
// если это не наши классы, то делегируем parent
parent.loadClass(name)
}
// resolveClass, чтобы сопоставить название класса и загруженный класс,
// т. е., по сути, запомнить его в памяти, чтобы не загружать снова
if (resolve) resolveClass(loadedClass)
return loadedClass
}
Основная логика, которая нам интересна, скрыта в методе transform. Остальное — базовая реализация. Очень важный момент: мы должны сами загружать все классы нашего пакета, зачем — расскажу дальше.
Для изменения байткода нам понадобится либа asm. У нее довольно удобный API для работы с байткодом, в дополнительных пакетах уже есть готовые классы, чтобы не приходилось реализовать анализ самому. Поэтому подключаем зависимости:
implementation("org.ow2.asm:asm:9.7.1")
implementation("org.ow2.asm:asm-tree:9.7.1")
implementation("org.ow2.asm:asm-commons:9.7.1")
И теперь самое интересное:
private fun transform(originalClassBytes: ByteArray): ByteArray {
val classNode = ClassNode()
// считываем структуру класса из байтового массива
ClassReader(originalClassBytes).accept(classNode, 0)
// ищем наш конкретный метод
val method = classNode.methods.first { methodNode ->
val returnType = Type.getReturnType(methodNode.desc)
"getVersionOfSystem" == methodNode.name && returnType == Type.INT_TYPE
}
// удаляем все, что есть в методе
method.instructions.clear()
method.tryCatchBlocks.clear()
method.localVariables.clear()
val generator = GeneratorAdapter(method, method.access, method.name, method.desc)
// записываем в метод инструкцию return 42
generator.push(42)
generator.returnValue()
generator.endMethod()
// записываем все в байтовый массив
val classWriter = ClassWriter(
ClassWriter.COMPUTE_MAXS or ClassWriter.COMPUTE_FRAMES
)
classNode.accept(classWriter)
return classWriter.toByteArray()
}
Из массива байт вытаскиваем класс, ищем наш конкретный метод, удаляем все инструкции из него и генерим то, что нам нужно. После все запихиваем обратно в массив байт.
Осталось только подменить настоящий Classloader на наш, и все будет работать.
Правда, есть одна загвоздка. Нельзя просто так пойти и заменить Classloader у потока и ожидать, что все заработает.
fun main() {
thread(contextClassLoader = ChangeApiClassLoader()) {
val coolSystemApi = SomeSystemApi()
println(coolSystemApi.getVersionOfSystem())
}.join()
}
Допустим, у нас есть класс A, который загрузили при помощи Classloader A. Этот класс A использует под капотом класс B. Как бы мы ни извращались заменой Classloader в потоке, класс B все равно загрузится при помощи Classloader A.
Когда мы загружаем класс, в нем сохраняется информация о том, через какой Classloader он был загружен, и все зависимые классы загружаются также через него.
Поэтому нужно сделать так, чтобы наша функция main, а точнее — класс, в котором он находится, был загружен при помощи Classloader. Именно поэтому наш Classloader должен загружать сам все классы нашего пакета.

Создаем еще один файл, с реальным main:
fun main(args: Array<String>) {
val loader = ChangeApiClassLoader()
thread(contextClassLoader = loader) {
val mainClass = loader.loadClass("com.example.RealMainKt")
val mainMethod = mainClass.getMethod("main", Array<String>::class.java)
mainMethod.invoke(null, args as Any)
}.join()
}
Делаем main, который «как бы» запускается первым:
fun main() {
val coolSystemApi = SomeSystemApi()
println(coolSystemApi.getVersionOfSystem())
}
И-и-и при запуске получим наши 42, которые мы подменили вместо исключения.
На данном моменте можно было бы остановиться, ведь, кажется, уже понятно, как работает Robolectric. Но если уж взялись копировать Robolectric, то сделаем это до конца.
Наша реализация работает, но не будем же мы хардкодить поведение всех методов в одном Classloader. Методов огромное количество, поэтому было бы круто делать реализацию в каком-то своем, фейковом классе. Чтобы наш Classloader не менял байткод метода, а вызывал наш фейковый метод вместо настоящего. Именно для этого у Robolectric есть Shadows. И вот как они работают.
Чтобы провернуть такую магию, нам понадобится байткод-операция — invokeDynamic.

Представим, что каждый метод в программе — это телефон. Когда один метод вызывает другой, он уже знает номер этого телефона и звонит напрямую.
invokeDynamic — это когда один метод хочет вызвать другой, но не знает номер телефона. Для этого он звонит в диспетчерскую, узнает номер и только потом звонит. Именно это нам поможет в runtime редиректить вызовы API на нашу реализацию.
Первым делом немного меняем код в методе trasfrorm:
val type = Type.getObjectType(classNode.name);
val desc = "(" + type.descriptor + method.desc.substring(1)
generator.loadThis()
generator.loadArgs()
// вызываем метод bootstrap, который нас редиректит
generator.invokeDynamic(
method.name,
desc,
bootstrap,
)
generator.returnValue()
generator.endMethod()
Bootstrap — это ссылка на метод, который будет выполнять роль оператора. Для начала объявим этот самый метод:
class InvokeDynamicSupport {
companion object {
@JvmStatic
fun bootstrap(
caller: MethodHandles.Lookup,
name: String,
type: MethodType,
): CallSite {
return ...
}
}
Статическим мы делаем метод, чтобы не париться над созданием объекта, а просто редиректить на функцию. Далее аргументы — это обязательный набор, который необходимо объявить в функции, чтобы ее можно было вызывать через invokeDynamic.

Аргумент caller — это ссылка на оригинальный класс, то есть в случае Robolectric какой-то класс из пакета android, name — имя функции, type — объект, который позволяет получить всю информацию о функции. CallSite — это, по сути, номер телефона, возвращаем его, и операция invokeDynamic будет вызывать ту функцию, ссылку на которую мы обернем в CallSite.
Далее нам нужно создать ссылку на этот самый bootstrap-метод:
// Получаем полный путь к классу InvokeDynamicSupport, в котором наш метод bootstrap
val className = Type.getInternalName(InvokeDynamicSupport::class.java)
// Создаем описание метода bootstrap c перечислением параметров функции
// Первый аргумент — это возвращаемый тип
val bootstrapMethod = MethodType.methodType(
CallSite::class.java,
MethodHandles.Lookup::class.java,
String::class.java,
MethodType::class.java,
)
// Создаем ссылку (Handle – класс из библиотеки asm)
val bootstrap = Handle(
Opcodes.H_INVOKESTATIC,
className,
"bootstrap",
bootstrapMethod.toMethodDescriptorString(),
false
)
Осталось в методе bootstrap взять оригинальный класс, получить на основе него фейковый, который мы реализовали у себя. В Robolectric для этого есть огромная Map, ключом является оригинальный класс, а значением — фейковый. Затем в этом фейковом классе находим нужный метод и возвращаем ссылку на него через CallSite.
Пример реализации метода bootstrap:
class InvokeDynamicSupport {
companion object {
private val shadowMap = mutableMapOf(
"com.example.SomeSystemApi" to "com.example.FakeApi"
)
@JvmStatic
fun bootstrap(
caller: MethodHandles.Lookup,
name: String,
type: MethodType,
): CallSite {
// Получаем класс заместитель
val classOfCaller = caller.lookupClass()
val shadowClass = shadowMap.getValue(classOfCaller.name).let {
Class.forName(it, true, classOfCaller.classLoader)
}
// Получаем параметры метода. Первый аргумент мы дропаем, потому что это this,
// т. е. ссылка на класс метода
val originalParams = type.parameterArray().drop(1).toTypedArray()
// Ищем метод, который совпадает по сигнатуре
val method = shadowClass.methods.first { method ->
method.name == name && originalParams.contentEquals(method.parameterTypes)
}
// Создаем экземпляр класса заместителя
val fakeInstance = shadowClass.getConstructor().newInstance()
// Создаем «ссылку» на метод
val methodHandler = MethodHandles.lookup().unreflect(method)
// Соотносим наш метод с экземпляром объекта заместителя
val boundMethodHandler = methodHandler.bindTo(fakeInstance)
// Оригинальный метод ожидает первым параметром this на оригинальный объект класса
// Однако мы подсовываем другой объект, поэтому важно убрать первый аргумент из
// созданного метода
val dropArguments = MethodHandles.dropArguments(
boundMethodHandler,
0,
type.parameterType(0)
)
// Возвращаем объект CallSite
return MutableCallSite(dropArguments)
}
}
}
Заключение
В статье я рассказал очень верхнеуровнево про базу, на основе которой работает Robolectric. Помимо этого есть еще нативные функции, переопределение статики, конструкторов и еще куча всего, чего может хватить на целую книгу.
Robolectric — очень сложная и невероятно крутая технология. Я снимаю шляпу перед инженерами, которые не побоялись практически написать свой Android Framework, да еще и так, что ты просто подключаешь одну зависимость — и все сразу работает.
Все проблемы, которые есть у Robolectric, объясняются как раз безумным уровнем сложности задачи.
Я не буду давать советы о том, стоит ли использовать Robolectric у себя на проекте или нет. Но, возможно, подход вы сможете где-то применить у себя.
Если вам понравилась статья, подписывайтесь на мой канал, в нем я часто делаю разборы каких-то сложных технологий.