- PVSM.RU - https://www.pvsm.ru -

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

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

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

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

Зачем тестировать 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<Account, String>()

    @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 года в проект был добавлен второй вариант модификации байткода [1], использующий 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.<init>() 67

Опыт использования

В настоящий момент в нашем проекте более 3000 Unit тестов, примерно половина из них используют Robolectric.

Столкнувшись с проблемами производительности фреймворка было принято решение использовать Robolectric только для тестирования ограниченного набора случаев:

  • Parcelable
  • Форматирование строк в ресурсах
  • Не UI компоненты (Camera)

Для всех остальных случаев мы оборачиваем зависимости Android в легко тестируемые обертки или используем unmock-plugin [2] для Gradle.

Видео с моим докладом на эту же тему на конференции MBLTdev 16

Автор: e-Legion Ltd.

Источник [3]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/android/239255

Ссылки в тексте:

[1] был добавлен второй вариант модификации байткода: https://github.com/robolectric/robolectric/commit/1021315c11e619e0735bea1349e04023178c4067

[2] unmock-plugin: https://github.com/bjoernQ/unmock-plugin

[3] Источник: https://habrahabr.ru/post/320898/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best