Problem
W trakcie pracy przy projekcie natrafiłem na klasę, która jest w miarę nowa i nie ma napisanych jeszcze testów jednostkowych. Jako zagorzały zwolennik testowania wszystkiego co nie jest trywialne i jest możliwe do przetestowania automatycznego postanowiłem uzupełnić brakujące testy.
Na pierwszy rzut oka klasa wyglądała na prostą, kilka ifów jakieś wywołanie metody z Android SDK. Pierwsza myśl to mockito + Robolectric – 5 min, sprawa załatwiona.
W pewnym momencie zrozumiałem, że napisanie testu do tej klasy nie będzie zbyt proste, ponieważ zależy ona od PackageManager
. Klasa, którą chciałem przetestować miała prostą funkcjonalność – otworzyć inną aplikację jeśli jest ona zainstalowana na telefonie, a jeśli nie jest zainstalowana to przekierować do sklepu aby umożliwić jej pobranie. Poniżej znajduje się znajduje się przykładowy kod, który realizuje powyższą funkcjonalność:
class ExternalAppLauncher( private val appPackage: String, private val context: Context ) { fun launchApp() { val intent = context.packageManager.getLaunchIntentForPackage(appPackage) if (intent == null) { launchGooglePlay() } else { context.startActivity(intent) } } private fun launchGooglePlay() { val marketIntent = context.packageManager.getLaunchIntentForPackage("com.android.vending") if (marketIntent != null) { marketIntent.data = Uri.parse("market://details?id=$appPackage") marketIntent.action = Intent.ACTION_VIEW context.startActivity(marketIntent) } else { launchGooglePlayWebsite() } } private fun launchGooglePlayWebsite() { context.startActivity( Intent( Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$appPackage") ) ) } }
Możliwe podejścia
Mockowanie
Pierwsze rozwiązanie jakie przyszło mi do głowy to stworzenie mocka klasy PackageManager
i zweryfikowanie czy metoda getLaunchIntentForPackage
została wywołana. To rozwiązanie jest jednak bardzo kruche, ponieważ istnieje możliwość stworzenia obiektu klasy Intent
bez użycia metody getLaunchIntentForPackage
. Jeśli ktoś kiedyś zrefaktorowałby powyższą klasę, musiałby także zmienić testy – a to świadczyłoby o źle napisanych testach.
Moim zdaniem w takim przypadku lepsze byłoby testowanie manualne niż tworzenie testu z wieloma mockami, które w rzeczywistości testują tylko interakcję zamiast działania.
Robolectric
Po odrzuceniu całkowicie pomysłu z tworzeniem mocków, postanowiłem poszperać w dokumentacji Robolectric. Niestety nic nie zalazłem – Robolectric nie dysponuje obszerną dokumentacja z dobrymi przykładami.
Na szczęście istnieje takie coś jak społeczność, StackOverflow, kod źródłowy i testy frameworka. Co prawda w samej dokumentacji nie znalazłem poprawnego rozwiązania ale punkt zaczepienia – klasa ShadowPackageManager
oraz metoda installPackage
.
Pisanie testów
Pierwszy test
Na początek napisałem test pierwszego przypadku, który był najprostszy i nie wymagał skomplikowanych działań oraz używania żadnych klas Shadow z Robolectrica:
@RunWith(AndroidJUnit4::class) class ExternalAppLauncherTest { private val app = getApplicationContext<Application>() @Test fun `should open browser with google play when app and GooglePlay is not installed`() { //GIVEN val appPackage = "com.n7mobile.wallpaper" val externalAppLauncher = ExternalAppLauncher(appPackage, app) val expectedUri = Uri.parse("https://play.google.com/store/apps/details?id=$appPackage") //WHEN externalAppLauncher.launchApp() //THEN val actualIntent = shadowOf(app).nextStartedActivity assertThat(actualIntent.action, equalTo(Intent.ACTION_VIEW)) assertThat(actualIntent.data, equalTo(expectedUri)) } }
PackageManager
dostarczony przez Robolectric nie zawiera w sobie nic więcej niż aplikację, która jest aktualnie testowana. Można podejrzeć jego zawartość używając deguggera:

Brak jakichkolwiek aplikacji w PackageManager
pozwoliło na prawidłowe przetestowanie scenariusza, w którym nie ma zainstalowanej zewnętrznej aplikacji ani aplikacji Google Play.

Do napisania zostały jeszcze 2 testy:
@Test fun `should open GooglePlay with app when app is not installed`() { } @Test fun `should open app when app is installed`() { }
Drugi test – pierwsza próba
Aby napisać drugi test should open GooglePlay with app when app is not installed
musiałem jakoś dodać do PackageManager
pakiet sklepu Google Play – "com.android.vending"
Dostęp do funkcji manipulujących klasą PackageManager
zapewnia ShadowPackageManager
. Aby uzyskać dostęp do ShadowPackageManager
trzeba użyć funkcji shadowOf
(dostarczonej przez Robolectric) na obiekcie PackageManager
:
private val shadowPackageManager = shadowOf(app.packageManager)
Mając dostęp do ShadowPackageManager
mogłem napisać test używając funkcji installPackage
:
@Test fun `should open GooglePlay with app when app is not installed`() { //GIVEN val appPackage = "com.n7mobile.wallpaper" val googlePlayPackage = "com.android.vending" val expectedUri = Uri.parse("market://details?id=$appPackage") val externalAppLauncher = ExternalAppLauncher(appPackage, app) installApp(googlePlayPackage) //WHEN externalAppLauncher.launchApp() //THEN val actualIntent = shadowOf(app).nextStartedActivity assertThat(actualIntent.action, equalTo(Intent.ACTION_VIEW)) assertThat(actualIntent.component.packageName, equalTo(googlePlayPackage)) assertThat(actualIntent.data, equalTo(expectedUri)) } private fun installApp(googlePlayPackage: String) { val applicationInfo = ApplicationInfoBuilder.newBuilder().setName("Google Play").setPackageName(googlePlayPackage).build() val packageInfo = PackageInfoBuilder.newBuilder().setPackageName(googlePlayPackage).setApplicationInfo(applicationInfo) .build() shadowPackageManager.installPackage(packageInfo) }
Niestety test nie przeszedł mimo, że w PackageManager
znajdował się pakiet com.android.vending
:

Później okazało się, że sam pakiet nie wystarczy i należy dodać jeszcze do PackageManager
informację odnośnie Activity
.
Drugi test – druga próba
Zacząłem szukać w internetach, czy może ktoś już kiedyś miał taki problem. Natrafiłem na kilka artykułów na StackOverflow oraz test klasy ShadowPackageManager
w repozytorium Roblectric (tutaj link).
@Test public void testLaunchIntentForPackage() { Intent intent = packageManager.getLaunchIntentForPackage(TEST_PACKAGE_LABEL); assertThat(intent).isNull(); Intent launchIntent = new Intent(Intent.ACTION_MAIN); launchIntent.setPackage(TEST_PACKAGE_LABEL); launchIntent.addCategory(Intent.CATEGORY_LAUNCHER); ResolveInfo resolveInfo = new ResolveInfo(); resolveInfo.activityInfo = new ActivityInfo(); resolveInfo.activityInfo.packageName = TEST_PACKAGE_LABEL; resolveInfo.activityInfo.name = "LauncherActivity"; shadowOf(packageManager).addResolveInfoForIntent(launchIntent, resolveInfo); intent = packageManager.getLaunchIntentForPackage(TEST_PACKAGE_LABEL); assertThat(intent.getComponent().getClassName()).isEqualTo("LauncherActivity"); }
Znajdował się tam test metody getLaunchIntentForPackage
, której używałem w testowanej klasie. To właśnie ona sprawia trudność przetestowania powyższego kodu. Zastosowałem część kodu znajdujący się w teście metody getLaunchIntentForPackage
w swoim teście:
@Test fun `should open GooglePlay with app when app is not installed`() { //GIVEN val appPackage = "com.n7mobile.wallpaper" val googlePlayPackage = "com.android.vending" val expectedUri = Uri.parse("market://details?id=$appPackage") installApp(googlePlayPackage) val externalAppLauncher = ExternalAppLauncher(appPackage, app) //WHEN externalAppLauncher.launchApp() //THEN val actualIntent = shadowOf(app).nextStartedActivity assertThat(actualIntent.action, equalTo(Intent.ACTION_VIEW)) assertThat(actualIntent.component.packageName, equalTo(googlePlayPackage)) assertThat(actualIntent.data, equalTo(expectedUri)) } private fun installApp(appPackage: String) { val launchIntent = Intent(Intent.ACTION_MAIN) launchIntent.setPackage(appPackage) launchIntent.addCategory(Intent.CATEGORY_LAUNCHER) val resolveInfo = ResolveInfo() resolveInfo.activityInfo = ActivityInfo() resolveInfo.activityInfo.packageName = appPackage resolveInfo.activityInfo.name = "LauncherActivity" shadowPackageManager.addResolveInfoForIntent(launchIntent, resolveInfo) }
Test zakończył się sukcesem:

Niestety to nie był koniec, ponieważ test używał metody addResolveInfoForIntent
która jest oznaczona jako @Deprecated
:

Uznałem, że nie pójdę drogą na skróty bo prędzej czy później trzeba będzie powrócić do tego testu, a jeśli odejdę z pracy to istnieje ryzyko, że ktoś kiedyś zamiast znaleźć rozwiązanie wyłączy po prostu test. Nie mogę do tego dopuścić!
Drugi test – trzecia próba
Przeszedłem do kodu źródłowego metody addResolveInfoForIntent
aby sprawdzić jaki jest opis adnotacji @Deprecated
. Klasy lub metody z tą adnotacją powinny zawsze posiadać informację, czego w zamian należy używać i jak. Na szczęście twórcy Robolectrica nie rozczarowali mnie:

z dokumentacji wynikało, że należy użyć metody addIntentFilterForComponent
oraz addOrUpdateActivity
aby zastąpić działanie metody addResolveInfoForIntent
.
Na tej podstawie stworzyłem implementację funkcji installApp
, która dodaje do PackageManager
aktywność LaunchActivity
z pakietem appPackage
, który jest przekazywany jako parametr. Następnie dodaje do PackageManager
filtry dla wcześniej stworzonego Activity
aby system traktował ją jako aktywność główną dla danego pakietu appPackage
– w tym przypadku dla pakietu com.android.vending
, który symuluje sklep:
@Test fun `should open GooglePlay with app when app is not installed`() { //GIVEN val appPackage = "com.n7mobile.wallpaper" val googlePlayPackage = "com.android.vending" val expectedUri = Uri.parse("market://details?id=$appPackage") val externalAppLauncher = ExternalAppLauncher(appPackage, app) installApp(googlePlayPackage) //WHEN externalAppLauncher.launchApp() //THEN val actualIntent = shadowOf(app).nextStartedActivity assertThat(actualIntent.action, equalTo(Intent.ACTION_VIEW)) assertThat(actualIntent.component.packageName, equalTo(googlePlayPackage)) assertThat(actualIntent.data, equalTo(expectedUri)) } private fun installApp(appPackage: String) { val activityInfo = ActivityInfo() activityInfo.name = "LaunchActivity" activityInfo.packageName = appPackage val appPackage = ComponentName(appPackage, "LaunchActivity") val intentFilter = IntentFilter() intentFilter.addCategory(Intent.CATEGORY_LAUNCHER) intentFilter.addAction(Intent.ACTION_MAIN) shadowPackageManager.addOrUpdateActivity(activityInfo) shadowPackageManager.addIntentFilterForActivity(appPackage, intentFilter) }
Zrefaktorowanie testu w taki sposób aby nie korzystał z funkcji oznaczonych jako @Deprecated
przyniosło zamierzone efekty, test przechodzi bez najmniejszego problemu:

Trzeci test
Ostatni test polegał na sprawdzeniu czy testowana klasa uruchomi zewnętrzną aplikację, jeśli jest ona już zainstalowana na telefonie. Mając już metodę installApp
, stworzoną na potrzeby pierwszego testu, można „zainstalować” w PackageManager
za jej pomocą pakiet aplikacji do uruchomienia aplikacji –com.n7mobile.wallpaper
@Test fun `should open app when app is installed`() { //GIVEN val appPackage = "com.n7mobile.wallpaper" val externalAppLauncher = ExternalAppLauncher(appPackage, app) installApp(appPackage) //WHEN externalAppLauncher.launchApp() //THEN val actualIntent = shadowOf(app).nextStartedActivity assertThat(actualIntent.action, equalTo(Intent.ACTION_MAIN)) assertThat(actualIntent.component.packageName, equalTo(appPackage)) }
Test przechodzi bez problemu:

W PackageManager
można zauważyć, że oprócz pakietu Robolectrica znajduje się także pakiet dodany w teście. W porównaniu do metody installPackage
użytej w pierwszym podejściu, oprócz samego pakietu dodajemy Activity
oraz IntentFilter
. Są one niezbędne w przypadku tworzenia obiektu Intent
przez metodę getLaunchIntentForPackage
.

Podsumowanie
Przedstawiony przypadek jest specyficzny i nie wystąpi on we wszystkich aplikacjach. Rozwiązując ten problem, chciałem pokazać jak wygląda ścieżka szukania rozwiązania, jakie ma możliwości Robolectric i że są dostępne narzędzia, które pozwolą praktycznie wszystko przetestować.
Moim zdaniem jednym z lepszych źródeł informacji o bibliotekach oraz frameworkach (w szczególności tych do testowania) są ich testy. W testach można znaleźć przypadki i przykłady, które odpowiadają w większym lub w mniejszym stopniu realnym problemom. W tym przypadku także testy klasy ShadowPackageManager
oraz kod źródłowy przyczynił się do rozwiązania mojego problemu.
Chciałem również pokazać, że testowanie na platformie Android nie jest jak niektórzy twierdzą niemożliwe albo niesamowicie trudne i kosztowne. Klasy niezależne od Android SDK można przetestować zwykłym JUnit. Framework Robolectric pozwoli przetestować większość klas, które mają referencję do Android SDK. Widoki i zachowanie można przetestować za pomocą Espresso.
Przy obecnie dostępnych narzędziach do testowania kodu androidowego można uzyskać bardzo przyzwoite pokrycie testami stosunkowo małym zaangażowaniem. Nie daj sobie wmówić, że uzyskanie pokrycia większego niż 20% na Androidzie jest niemożliwe. Testowanie to przede wszystkim korzyści, o których pisałem już w artykule Musisz zacząć pisać testy automatyczne!
Link
Repozytorium z przykładem: https://github.com/androidCoder-pl/Snippets