Погружение в Robolectric
В мире Android-разработки всё чаще используют unit-тестирование. Проверка корректности работы отдельных модулей приложения помогает выявить и устранить ошибки в коде уже на ранних этапах. Вкупе с автоматизацией сборки, компонентными и интеграционными тестами, unit-тесты позволяют делать качественный продукт, независимо от размера вашей команды разработчиков.
Под катом расскажу о внутреннем устройстве фреймворка для unit-тестирования Android-приложений — Robolectric.
Зачем тестировать Android-специфичный код?
Для начала постараемся ответить на вопрос — зачем тестировать код в местах интеграции с Android фреймворком?
Resources — стоит тестировать корректность использования определенных строковых или каких либо других ресурсов приложения, т.к. они являются неотъемлемой частью бизнес — требований.
Parcelable — независимо от того используете ли вы средства автоматической генерации Parcelable или пишете реализацию вручную, стоит тестировать корректность восстановления объектов из их сериализованного представления.
SQLite — тестирование миграции данных, изменения схем, добавление новых таблиц, корректность выполнения запросов.
Intent / Bundle — для некоторых сценариев важно проверять корректность заполнения Intent, флаги, с которыми будет запущена следующая Activity или Service.
- Не UI компоненты системы, такие как Camera, MediaPlayer, MediaRecorder, различные менеджеры и т.д.
Это только часть сценариев, при которых тестирование кода в местах интеграции с Android становится актуальной задачей.
Проблемы тестирования кода, использующего Android
При попытках решить эту задачу в лоб можно столкнуться со следующими проблемами:
RuntimeException
c причиной — method not mocked
при попытке запустить тест кода вызывающего какой — либо метод фреймворка. А если использовать следующую опцию в Gradle -
testOptions {
unitTests.returnDefaultValues = true
}
то, RuntimeException
брошен не будет. Такое поведение может приводить к тяжело детектируемым ошибкам в тестах.
Другой проблемой тестирования являются final
классы и великое множество static
методов фреймворка, что еще сильнее усложняет тестирование кода который его использует.
Пути решения
Для всех вышеперечисленных проблем существуют определенные решения:
Использовать примитивные тестируемые обертки над местами интеграции вашего кода с фреймворком. В ваших тестах вы мокаете обертку и тестируете ее взаимодействие с вашим кодом. Тестирование обертки в виду ее простой реализации опускаете. Хотя на самом деле эту обертку тестировать нужно, а оставаться примитивной она будет недолгое время. В конце концов, вам надоест дублировать реализацию фреймворка Android ради тестирования. Не стоит забывать и про рост количества методов в вашем APK, к которому приведет данный подход.
Instrumented unit tests — самый точный вариант тестирования. Тесты выполняются на реальном устройстве или эмуляторе в настоящем окружении. Но за это придется расплачиваться долгой компиляцией, упаковкой APK, и медленным выполнением тестов.
- PowerMock + Mockito — PowerMock позволит вам мокать
static
методы иfinal
классы. В этом случае вам придется частично повторить поведение некоторых классов Android, что может привести к распуханию кода ответственного за подготовку моков в ваших тестах и затруднит их поддержку в дальнейшем.
Robolectric
Существует еще одно решение проблемы Unit-тестирования Android приложений — Robolectriс. Robolectric — это фреймворк, разработанный компанией PivotalLabs в 2010 году. Он занимает промежуточное положение между «голыми» JUnit тестами и инструментированными тестами, запускаемыми на устройстве, симулируя реальное Android окружение. Фреймворк представляет собой скомпилированный android.jar с обвязкой из утилит для запуска тестов и упрощения тестирования. Он поддерживает загрузку ресурсов, примитивную реализацию выдувания View, предоставляет локальную SQLite (sqlite4java), легко кастомизируем и расширяем.
Используем android.util.Log
Предположим, что мы разрабатываем библиотеку для сторонних разработчиков и хотим убедиться, что наша библиотека печатает в Logcat некоторую важную информацию.
Реализуем следующий интерфейс — Logger
, с одним методом для вывода сообщений уровня «Info».
interface Logger {
fun info(tag: String, message: String, throwable: Throwable? = null)
}
Напишем реализацию AndroidLogger
— которая будет использовать android.util.Log
.
class AndroidLogger: Logger {
override fun info(tag: String, message: String, throwable: Throwable?) {
Log.i(tag, message, throwable)
}
}
Тестируем
android.util.Log
Напишем тест на Junit с помощью Robolectric и убедимся, что метод info
нашей реализации AndroidLogger
на самом деле печатает сообщения в Logcat с уровнем info.
@RunWith(RobolectricTestRunner::class)
@Config(constants = BuildConfig::class, sdk = intArrayOf(23))
class RobolectricAndroidLoggerTest {
private val logger: Logger = AndroidLogger()
@Test fun `info - should log to logcat with info level`() {
val throwable = Throwable()
logger.info("Tag", "Message", throwable)
val logInfo: LogInfo = ShadowLog.getLogs().last()
assertThat(logInfo.type, Is(Log.INFO))
assertThat(logInfo.tag, Is("Tag"))
assertThat(logInfo.msg, Is("Message"))
assertThat(logInfo.throwable, Is(throwable))
}
}
Аннотацией @RunWith
мы указываем, что будем запускать тест с помощью RobolectricTestRunner
. В параметрах к аннотации @Config
мы передаем класс BuildConfig
и указываем версию Android SDK которую будет симулировать Robolectric.
В тесте мы вызываем метод info
у объекта AndroidLogger
. С помощью класса ShadowLog
достаем последнее сообщение записанное в лог и делаем assert по его содержимому.
Внутреннее устройство
Внутреннее устройство Robolectric можно условно разделить на 3 части: Shadow классы, RobolectricTestRunner
и InstrumentingClassLoader
.
Shadow классы
Создатели Robolectric вводят новый тип «тестовых двойников» (test double) — Shadow. Согласно официальному сайту, Shadows — »… not quite Proxies, not quite Fakes, not quite Mocks or Stubs».
Shadow объект существует параллельно реальному объекту и может перехватывать вызовы методов и конструкторов, тем самым изменяя поведение настоящего объекта.
Связь Shadow c Robolectric
Аннотацией @Implements
указывается класс для которого предназначен конкретный Shadow-класс.
@Implements(className = ContextImpl.class)
public class ShadowContextImpl {
...
}
В аннотации @Config
теста можно указать Shadow-классы которые не входят в стандартную поставку Robolectric.
@Config(..., shadows = {CustomShadow.class}, ...)
public class CustomTest {
...
}
Переопределение методов
Переопределенный в Shadow-классе метод помечается аннотацией @Implementation, важно сохранить сигнатуру оригинального метода.
@Implementation
public Object getSystemService(String name) {
...
}
При переопределении native метода кодовое слово native опускается.
private static native long nativeReadLong(long nativePtr);
@Implementation
public static long nativeReadLong(long nativePtr) {
return ...
}
Переопределение конструкторов
Для переопределения конструктора в Shadow-классе реализуется метод __constructor__
с теми же аргументами.
public Canvas(@NonNull Bitmap bitmap) {
...
}
public void __constructor__(Bitmap bitmap) {
this.targetBitmap = bitmap;
}
Вызов настоящего объекта
Для получения ссылки на реальный объект в Shadow-классе достаточно объявить поле с типом «оттеняемого» объекта помеченное аннотацией @RealObject
:
@RealObject
private Context realObject;
Robolectric предоставляет возможность вызвать настоящую реализацию метода, минуя Shadow реализацию, с помощью Shadow.directlyOn
.
Shadow.directlyOn(realObject, "android.app.ContextImpl", "getDatabasesDir");
Собственный Shadow
Написание собственного Shadow-класса не является большой проблемой, даже для сторонней библиотеки не входящий в стандартную поставку с Android.
Напишем класс, получающий токен пользователя с помощью GoogleAuthUtil
.
class GoogleAuthInteractor {
fun getToken(context: Context, account: Account): String {
return GoogleAuthUtil.getToken(context, account, null)
}
}
Реализуем Shadow-класс для GoogleAuthUtil
позволяющий переопределить token
для определенного Account
:
@Implements(GoogleAuthUtil::class)
object ShadowGoogleAuthUtil {
private val tokens = ArrayMap()
@Implementation
@JvmStatic
fun getToken(context: Context, account: Account, scope: String?): String {
return tokens[account].orEmpty()
}
fun setToken(account: Account, token: String?) {
tokens.put(account, token)
}
}
Напишем тест для GoogleAuthInteractor
с помощью Robolectric. В конфигурации к тесту укажем, что хотим использовать ShadowGoogleAuthUtil
и инструментировать классы из пакета com.google.android.gms.auth
.
@RunWith(RobolectricTestRunner::class)
@Config(shadows = arrayOf(ShadowGoogleAuthUtil::class),
instrumentedPackages = arrayOf("com.google.android.gms.auth"))
class GoogleAuthInteractorTest {
private val context = RuntimeEnvironment.application
private val interactor = GoogleAuthInteractor()
@Test fun `provide token - provides token for correct account`() {
val account = Account("name", "type")
ShadowGoogleAuthUtil.setToken(account, "token")
val token = interactor.getToken(context, account)
assertThat(token, Is("token"))
}
}
RobolectricTestRunner
От Shadow классов перейдем к RobolectricTestRunner
— это первая часть Robolectric с которой связываются ваши тесты. Раннер отвечает за динамическую загрузку зависимостей (Shadow-классы и android.jar для указанной версии SDK) во время выполнения тестов.
Robolectric конфигурируется аннотацией @Config
, c помощью которой можно изменять параметры симулируемого окружения для тестового класса и для каждого теста в отдельности. Конфигурация для запуска тестов будет собираться последовательно по всей иерархии тестового класса от родителя к наследнику и, наконец, к самому тестируемому методу. Конфигурация позволяет настроить:
- версию Android
- путь к манифесту и ресурсам
- список текущих квалификаторов
- сторонние Shadow
- дополнительные имена пакетов для инструментирования
InstrumentingClassLoader
Перед запуском тестов RobolectricTestRunner
подменяет системный ClassLoader
на InstrumentingClassLoader
.
InstrumentingClassLoader
обеспечивает связь реальных объектов с Shadow-классами, подмену некоторых классов на классы фейков и проксирование вызовов определенных методов в Shadow-классы напрямую.
Robolectric не инструментирует классы из пакета java.*
, поэтому вызовы методов отсутствующие в обыкновенной JVM, но добавленные в Android SDK, проксируются напрямую в Shadow в месте вызова.
В фреймворке существуют два варианта инструментирования загружаемых классов. Оригинальная реализация генерирует байткод, использующий внутренний интерфейс ClassHandler
и реализующий его класс ShadowWrangler
, по сути оборачивающая каждый вызов метода через Shadow-класс в отдельный Runnable
подобный объект и вызывает его. В апреле 2015 года в проект был добавлен второй вариант модификации байткода, использующий JVM инструкцию invokeDynamic
.
Во время инструментирования Robolectric добавляет к каждому загружаемому классу интерфейс ShadowedObject
с одним единственным методом — $$robo$getData()
, в котором настоящий объект возвращает свой Shadow.
public interface ShadowedObject {
Object $$robo$getData();
}
Для каждого конструктора InstrumentingClassLoader
создает приватный метод $$robo$$__constructor__
с сохранением его сигнатуры и инструкций (кроме вызова super
).
public Size(int width, int height) {
super(width, height);
...
}
private void $$robo$$__constructor__(int width, int height) {
mWidth = width;
mHeight = height;
}
В свою очередь тело оригинального конструктора будет состоять из:
- Вызова
super
(если класс является наследником) - Вызова приватного метода
$$robo$init
, который инициализирует приватное поле__robo_data__
соответствующим Shadow объектом - Вызова переопределенного конструктора (
__constructor__
) на Shadow объекте, если Shadow объект существует и соответствующий конструктор переопределен, в противном случае будет вызвана настоящая реализация ($$robo$$__constructor__
).
Конструктор модифицированный с использованием инструкции invokeDynamic
:
public Size(int width, int height) {
this.$$robo$init();
InvokeDynamicSupport.bootstrap($$robo$$__constructor__(int int), this, width, height);
}
Конструктор модифицированный с использованием ClassHandler:
public Size(int width, int height) {
this.$$robo$init();
ClassHandler.Plan plan = RobolectricInternals.methodInvoked("android/util/Size/__constructor__(II)V", false, Size.class);
if (plan != null) {
try {
plan.run(this, $$robo$getData(), new Object[]{new Integer(width), new Integer(height)});
return;
} catch (Throwable throwable) {
throw RobolectricInternals.cleanStackTrace(throwable);
}
}
try {
this.$$robo$$__constructor__(width, height);
} catch (Throwable throwable) {
throw RobolectricInternals.cleanStackTrace(throwable);
}
}
Для инструментирования методов Robolectric использует аналогичный механизм, настоящий код метода выделяется в приватный метод с приставкой $$robo$$
и вызов метода делегируется Shadow объекту.
Метод модифицированный с использованием инструкции invokeDynamic
:
public int getWidth() {
return (int)InvokeDynamicSupport.bootstrap($$robo$$getWidth(),this);
}
Для native
методов Robolectric опускает соответствующий модификатор и возвращает значение по умолчанию если этот метод не переопределен в Shadow классе.
Производительность
Robolectric далеко не самый производительный фреймворк. Запуск пустого теста на RobolectricTestRunner
занимает около 2х секунд. По сравнению с «чистыми» JUnit тестами 2 секунды это существенная задержка.
Профилирование выполнения тестов на Robolectric показывает, что большую часть времени фреймворк тратит на инструментирование загружаемых классов.
Ниже приведены результаты профилирования Robolectric и связки PowerMock + Mockito для теста android.util.Log
описанного выше.
Robolectric ~2400 мс.:
Метод | мс. |
---|---|
java.lang.ClassLoader.loadClass(String) |
913 |
org.robolectric.internal.bytecode.InstrumentingClassLoader. |
767 |
org.objectweb.asm.tree.ClassNode.accept(ClassVisitor) |
407 |
org.objectweb.asm.tree.MethodNode.accept(ClassVisitor) |
367 |
org.robolectric.internal.bytecode.InstrumentingClassLoader |
298 |
org.objectweb.asm.ClassReader.accept(ClassVisitor, Attribute[], int) |
277 |
org.robolectric.shadows.ShadowResources.getSystem() |
268 |
PowerMock + Mockito ~200 мс.:
Метод | мс. |
---|---|
org.powermock.api.extension.proxyframework.ProxyFrameworkImpl.isProxy(Class) |
304 |
org.powermock.api.mockito.repackaged.cglib.core.KeyFactory$Generator |
131 |
sun.launcher.LauncherHelper.checkAndLoadMain(boolean, int, String) |
103 |
javassist.bytecode.MethodInfo.rebuildStackMap(ClassPool) |
85 |
java.lang.Class.getResource(String) |
84 |
org.mockito.internal.MockitoCore. |
67 |
Опыт использования
В настоящий момент в нашем проекте более 3000 Unit тестов, примерно половина из них используют Robolectric.
Столкнувшись с проблемами производительности фреймворка было принято решение использовать Robolectric только для тестирования ограниченного набора случаев:
- Parcelable
- Форматирование строк в ресурсах
- Не UI компоненты (Camera)
Для всех остальных случаев мы оборачиваем зависимости Android в легко тестируемые обертки или используем unmock-plugin для Gradle.
Видео с моим докладом на эту же тему на конференции MBLTdev 16