Занурення в 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 заради тестування. Не варто забувати і про зростання кількості методів у вашому АПК, до якого призведе даний підхід.
  • Instrumented unit tests — найбільш точний варіант тестування. Тести виконуються на реальному пристрої або емуляторі в цьому оточенні. Але за це доведеться розплачуватися довгої компіляцією, упаковкою АПК, і повільним виконанням тестів.
  • 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

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.