Погружение в 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<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 года в проект был добавлен второй вариант модификации байткода, использующий 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 для Gradle.


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


    Метки:
    • +15
    • 6,2k
    • 3
    e-Legion Ltd. 108,91
    Лидер мобильной разработки в России
    Поделиться публикацией
    Комментарии 3
    • 0

      Не было опыта с новым Mokito final\static мокать?
      What's new in Mockito 2 · unmockable)/mockito Wiki

      • 0

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

      • 0
        > В мире Android-разработки всё чаще используют unit-тестирование
        > фреймворка для unit-тестирования Android-приложений — Robolectric

        То, что описано в статье, юнит-тестированием не является.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое