Погружение в Robolectric

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


Под катом расскажу о внутреннем устройстве фреймворка для unit-тестирования Android-приложений — Robolectric.


2051fc321af846acbefcc97e93482efe.png

Зачем тестировать 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.
getInstrumentedBytes(ClassNode, boolean)
767
org.objectweb.asm.tree.ClassNode.accept(ClassVisitor) 407
org.objectweb.asm.tree.MethodNode.accept(ClassVisitor) 367
org.robolectric.internal.bytecode.InstrumentingClassLoader
$ClassInstrumentor.instrument()
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
.generateClass(ClassVisitor)
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


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

© Habrahabr.ru