Занурення в Robolectric

У світі Android-розробки все частіше використовують unit-тестування. Перевірка коректності роботи окремих модулів програми допомагає виявити та усунути помилки в коді вже на ранніх етапах. Разом з автоматизацією складання, компонентними та інтеграційними тестами, unit-тести дозволяють робити якісний продукт незалежно від розміру вашої команди розробників.

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

Спочатку постараємося відповісти на запитання — навіщо тестувати код у місцях інтеграції з Android фреймворком?

Resources — варто тестувати коректність використання певних рядкових чи інших ресурсів докладання, т.к. вони є невід'ємною частиною бізнес-вимог.

Parcelable — незалежно від того, чи використовуєте ви засоби автоматичної генерації Parcelable або пишете реалізацію вручну, варто тестувати коректність відновлення об'єктів з їхнього серіалізованого уявлення.

SQLite - тестування міграції даних, зміни схем, додавання нових таблиць, коректність виконання запитів.

Intent/Bundle — для деяких сценаріїв важливо перевіряти коректність заповнення Intent, прапори, з якими буде запущено наступну Activity або Service.

  • Чи не UI компоненти системи, такі як Camera, MediaPlayer, MediaRecorder, різні менеджери і т.д.
  • Це лише частина сценаріїв, за яких тестування коду в місцях інтеграції з Android стає актуальним завданням.

    Проблеми тестування коду, що використовує Android

    При спробах вирішити це завдання в лоб можна зіткнутися з такими проблемами:

    RuntimeException cпричиною - method not mocked при спробі запустити тест коду викликає який-небудь метод фреймворку. А якщо використовувати наступну опцію в Gradle -

    то, 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".

    Напишемо реалізацію AndroidLogger - яка буде використовувати android.util.Log.

    Тестуємо android.util.Log

    Напишемо тест на Junit за допомогою Robolectric і переконаємося, що метод info нашої реалізації AndroidLogger насправді друкує повідомлення у Logcat із рівнем info.

    Анотацією @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, no quite Fakes, no quite Mocks or Stubs".

    Shadow об'єкт існує паралельно до реального об'єкта і може перехоплювати виклики методів і конструкторів, тим самим змінюючи поведінку цього об'єкта.

    Зв'язок Shadow c Robolectric

    Анотацією @Implements вказується клас якого призначений конкретний Shadow-клас.

    В анотації @Config тесту можна вказати Shadow-класи, які не входять до стандартної поставки Robolectric.

    Перевизначення методів

    Перевизначений у Shadow-класі метод позначається інструкцією @Implementation, важливо зберегти сигнатуру оригінального методу.

    При перевизначенні нативного методу кодове слово опускається.

    Перевизначення конструкторів

    Для перевизначення конструктора в Shadow-класі реалізується метод __constructor__ з тими самими аргументами.

    Виклик цього об'єкту

    Для отримання посилання на реальний об'єкт у Shadow-класі достатньо оголосити поле з типом об'єкта, що "відтіняється", позначене анотацією @RealObject :

    Robolectric надає можливість викликати реальну реалізацію методу, минаючи Shadow реалізацію, за допомогою Shadow.directlyOn .

    Власний Shadow

    Написання власного Shadow-класу не є великою проблемою, навіть для сторонньої бібліотеки, що не входить у стандартне постачання з Android.

    Напишемо клас, який отримує токен користувача за допомогою GoogleAuthUtil.

    Реалізуємо Shadow-клас для GoogleAuthUtil, що дозволяє перевизначити token для певного Account :

    Напишемо тест для GoogleAuthInteractor за допомогою Robolectric. У конфігурації до тесту вкажемо, що хочемо використовувати ShadowGoogleAuthUtil та інструментувати класи з пакету com.google.android.gms.auth.

    RobolectricTestRunner

    Від Shadow класів перейдемо до Robolectric TestRunner – це перша частина Robolectric з якою зв'язуються ваші тести. Ранер відповідає за динамічне завантаження залежностей (Shadow-класи іandroid.jar для зазначеної версії SDK) під час виконання тестів.

    Robolectric конфігурується анотацією @Config , за допомогою якої можна змінювати параметри оточення, що симулюється, для тестового класу і для кожного тесту окремо. Конфігурація для запуску тестів буде збиратися послідовно по всій ієрархії тестового класу від батька до спадкоємця і, нарешті, до методу, що тестується. Конфігурація дозволяє налаштувати:

    • версію Android
    • шлях до маніфесту та ресурсів
    • список поточних кваліфікаторів
    • сторонні Shadow
    • додаткові імена пакетів для інструментування

    InstrumentingClassLoader

    Перед запуском тестів RobolectricTestRunner підміняє системний ClassLoader на InstrumentingClassLoader.

    InstrumentingClassLoader забезпечує зв'язок реальних об'єктів з Shadow-класами, заміну деяких класів на класи фейків та проксування викликів певних методів у Shadow-класи безпосередньо.

    Robolectric не інструментує класи з пакета java.* , тому виклики методів відсутні у звичайній JVM, але додані в Android SDK, проксуються безпосередньо в Shadow у місці виклику.

    Під час інструментування Robolectric додає до кожного класу, що завантажується, інтерфейс ShadowedObject з одним єдиним методом — $$robo$getData() , в якому справжній об'єкт повертає свій Shadow.

    Для кожного конструктора InstrumentingClassLoader створює приватний метод $$robo$$__constructor__ із збереженням його сигнатури та інструкцій (крім викликуsuper).

    У свою чергу тіло оригінального конструктора складатиметься з:

    • Виклику super (якщо клас є спадкоємцем)
    • Виклику приватного методу $$robo$init , який ініціалізує приватне поле __robo_data__ відповідним об'єктом Shadow
    • Виклику перевизначеного конструктора ( __constructor__ ) на Shadow об'єкті, якщо Shadow об'єкт існує і відповідний конструктор перевизначений, інакше буде викликана реальна реалізація ( $$robo$$__constructor__ ).

    Конструктор модифікований з використанням інструкції invokeDynamic:

    Конструктор модифікований із використанням ClassHandler:

    Для інструментування методів Robolectric використовує аналогічний механізм, цей код методу виділяється в приватний метод із приставкою $$robo$$ та виклик методу делегується Shadow об'єкту.

    Метод модифікований з використанням інструкції invokeDynamic:

    Для нативних методів Robolectric опускає відповідний модифікатор і повертає значення за замовчуванням, якщо цей метод не перевизначений у Shadow класі.

    Продуктивність

    Robolectric далеко не найпродуктивніший фреймворк. Запуск порожнього тесту на RobolectricTestRunner займає близько 2 секунд. Порівняно з “чистими” JUnit тестами 2 секунди, це суттєва затримка.

    Профілювання виконання тестів на Robolectric показує, що більшу частину часу фреймворк витрачає на інструментування класів, що завантажуються. Нижче наведено результати профілювання Robolectric та зв'язки PowerMock + Mockito для тесту android.util.Log описаного вище.

    Метод мс.
    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

    Метод мс.
    org.powermock.api.extension.proxyframework.ProxyFrameworkImpl.isProxy(Клас)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(рядок)84
    org.mockito.internal.MockitoCore. ()67

    Опыт використання

    У нинішній момент у нашому проекті понад 3000 Unit тестів, приблизно половина з них використовує Robolectric.

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

    • Парцеляційний
    • Форматування рядка в ресурсах
    • Не компоненти інтерфейсу користувача (Камера)

    Для всіх інших випадків ми обираємо залежність від Android у легко тестованих обертках або використовуємо unmock-plugin для Gradle.

    Відео з моєю доповіддю на цю ж тему на конференції MBLTdev 16