Snippet: Nietrywialny test prostego kodu czyli jak przetestować kod zależny od PackageManager.

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:

rys. 1

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.

rys. 2

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 :

rys. 3

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:

rys. 4

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

rys. 5

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:

rys. 6

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:

rys. 7

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:

rys. 8

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.

rys. 9

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

Jeśli uważasz treść za wartościową to podziel się nią z innymi. Dziękuję.

Mateusz Chrustny

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *