Wstęp
Narzędzie do mockowania poznane w poprzednim artykule jest świetnym rozwiązaniem, gdy używane biblioteki nie udostępniają interfejsów lub nie posiadają żadnych mechanizmów ułatwiających testowanie. Mogłoby się wydawać, że takim przypadkiem jest Android SDK. Jednakże mockowanie Androidowych klas za pomocą bibliotek takich jak Mockk czy Mockito produkuje bardzo dużo zbędnego, trudnego w utrzymaniu kodu.
Dlatego w 2010 roku powstał Robolectric – niezależny od Google projekt, który miał na celi zmockowanie całego Android SDK w taki sposób aby było możliwe uruchamianie testów na lokalnym urządzeniu a nie na urządzeniu mobilnym. Umożliwiło to testowanie klas typu Activity, Fragment, Services oraz klas posiadających referencję do Android SDK.
Początkowo Robolectric borykał się z wieloma problemami (z resztą jak sama platforma Androida), ale od kilku lat jest rekomendowanym sposobem testowania przez Google i większość problemów natury młodzieńczej zostało rozwiązane. Robolectric ma bardzo skąpą dokumentację dlatego wdrożenie może być nieco kłopotliwe.
Kiedy używać Robolectric?
Robolectrica należy używać tylko tam gdzie kod ma referencję do Android SDK, ponieważ jest bardzo dużym frameworkiem i może spowalniać zwykłe testy. Robolectric jest wszechstronny i może posłużyć do pisania testów jednostkowych, integracyjnych i testów UI.
W testach jednostkowych testujemy pojedyncze klasy, które mają referencje do Android SDK np. Context
, Resources
etc.. Za pomocą samego frameworku JUnit nie uda się uruchomić takiego testu, ponieważ zabraknie referencji do Android SDK, które znajduje się na urządzeniu fizycznym. Zmockowanie klas z Android SDK za pomocą Mockk
jest bardzo pracochłonnym i nie zawsze działającym rozwiązaniem. Do tego zadania Robolectric nadaje się idealnie.
W testach integracyjnych testujemy wiele klas lub komponentów jednocześnie. W tym przypadku będzie to interakcja systemu z naszym kodem. Zazwyczaj będą to klasy korzystające z komponentów takich jak Intent
czy BroadcastReceiver
, testy takie będą sprawdzały integrację pomiędzy naszym kodem a infrastrukturą Androida.
W testach UI skupiamy się głównie na interfejsie użytkownika i na jego działaniu – czyli nawigacji. Jeśli większość przypadków testowych zostanie napisana na poziomie testów jednostkowych oraz integracyjnych, to ilość testów UI będzie na poziomie kilku procent.
Jak widać Robolectric stanowi bardzo ważny obszar w testowaniu aplikacji mobilnych i nie należy go ignorować ani demonizować. Wbrew opinii jego stosowanie jest całkiem przyjemne i łatwe o ile kod, który chce się przetestować jest dobrze napisany.
Jaka aplikacja będzie testowana?
Aby pokazać podstawowe możliwości Robolectrica uznałem, że reprezentatywnym przykładem będzie lista zawierająca linki, gdzie po kliknięciu w jej element otworzy się przeglądarka. Coś w stylu listy ulubionych witryn.

Konfiguracja
Konfiguracja sprowadza się do dodania kilku zależności w pliku gradle:
//BASICS FOR TESTS testImplementation 'junit:junit:4.13.1' testImplementation "org.robolectric:robolectric:4.4" //ANDROID X TESTING CORE testImplementation "androidx.test.ext:junit:1.1.2" testImplementation "androidx.test:core:1.3.0" testImplementation "androidx.arch.core:core-testing:2.1.0" testImplementation 'androidx.test.espresso:espresso-core:3.3.0' testImplementation 'androidx.test:runner:1.3.0' testImplementation 'androidx.test:rules:1.3.0' //MOCKING testImplementation "io.mockk:mockk:1.10.2"
Najważniejszymi zależnościami są org.robolectric:robolectric:4.4
oraz junit:junit:4.13.1
.
android { testOptions { unitTests { includeAndroidResources = true } } }
Uruchamiając testy z konsoli ./gradlew testDebugUnitTest
można natrafić na poniższy błąd:
[Robolectric] WARN: Android SDK 29 requires Java 9 (have Java 8). Tests won't be run on SDK 29 unless explicitly requested.
Aby umożliwić uruchamianie testów, trzeba zmienić domyślne SDK dla testów na 28. Należy utworzyć katalog w app/src/test/resources
plik robolectric.properties
z zawartością:
sdk=28
W pliku tym można umieszczać globalną konfigurację Robolectric dla modułu app. Więcej możesz przeczytać tutaj http://robolectric.org/configuring/.
Jak zacząć testować?
Gdy zaczyna się pisać pierwszy test w aplikacji już istniejącej, zawsze pojawia się pytanie – od czego zacząć?
Ja zazwyczaj zaczynam od najmniejszych elementów, które można przetestować testami jednostkowymi. Jeśli mają zbyt dużo zależności to je refaktoruję. Potem przechodzę do integracyjnych i ostatecznie piszę testy UI. Dzięki takiemu podejściu unikam wielokrotnemu testowaniu tego samego elementu oraz zmniejszam liczbę testów UI i integracyjnych na rzecz testów jednostkowych.
Kierując się powyższą zasadą zacznę od testu dla elementu listy. Element listy wykorzystuje Data Binding dlatego przetestuję czy poprawie działa oraz sprawdzę formatowanie danych.

Element listy zawiera 4 elementy: nazwę, url, listę tagów oraz kolorowy znacznik po prawej stronie. Dane potrzebne do wyświetlenia są przechowywane w prostej klasie Site
. Za formatowanie danych odpowiedzialna jest klasa SiteViewConverter
.
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> <data> <variable name="site" type="pl.androidcoder.robolectric.Site" /> <import type="pl.androidcoder.robolectric.SiteViewConverter"/> </data> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content"> <TextView android:id="@+id/name" style="@style/TextAppearance.AppCompat.Title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="8dp" android:layout_marginTop="8dp" android:textStyle="bold" android:text="@{site.name}" app:layout_constraintBottom_toTopOf="@id/url" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:text="Androidcoder developer blog" /> <TextView android:id="@+id/url" style="@style/TextAppearance.AppCompat.Body2" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="8dp" android:layout_marginTop="4dp" android:layout_marginEnd="8dp" android:text="@{site.url}" app:layout_constraintBottom_toTopOf="@id/hashtags" app:layout_constraintEnd_toStartOf="@id/color" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/name" tools:text="http://androidcoder.pl" /> <TextView android:id="@+id/hashtags" style="@style/TextAppearance.AppCompat.Small" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="8dp" android:layout_marginEnd="8dp" android:text="@{SiteViewConverter.convert(site.tags)}" android:layout_marginBottom="8dp" android:layout_marginTop="4dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@id/color" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/url" tools:text="#Programmers #Kotlin #Android #Best" /> <View android:id="@+id/color" android:layout_width="12dp" android:layout_height="0dp" android:text="@{SiteViewConverter.convert(site.labelColor)}" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" tools:background="@color/colorAccent" /> </androidx.constraintlayout.widget.ConstraintLayout> </layout>
data class Site( val name: String, val url: String, val tags: List<String>, val hexLabelColor : String? )
TEST: Unit test SiteViewConverter
SiteViewConverter
posiada dwie metody. Pierwsza z nich konwertuje listę tagów na prosty ciąg znaków. Druga konwertuje kolor zapisany w postaci heksadecymalnej na wartość Int, która jest przyjmowana przez Androidowy widok.
class SiteViewConverter { companion object { @JvmStatic fun convertTags(tags: List<String>): String { return if (tags.isEmpty()) "" else tags.filter { it.isNotBlank() } .joinToString(separator = " ") { "#$it" } } @JvmStatic fun convertHexColor(hexLabelColor: String): Int { if(hexLabelColor.isBlank()) return 0 return try { Color.parseColor(hexLabelColor) } catch (e: IllegalArgumentException) { 0 } } } }
Testy do powyższego kodu można napisać wykorzystując klasyczne testy JUnit.
class SiteViewConverterTest { @Test fun testConvertTags() { assertConvertTags(emptyList(), "") assertConvertTags(listOf("aa"), "#aa") assertConvertTags(listOf("aa", " "), "#aa") assertConvertTags(listOf("aa", ""), "#aa") assertConvertTags(listOf("aa", "bb"), "#aa #bb") } private fun assertConvertTags(tags: List<String>, listOfTags: String) { assertThat(SiteViewConverter.convertTags(tags), equalTo(listOfTags)) } @Test fun testConvertHexColor() { assertConvertHexColor("", 0) assertConvertHexColor("WRONG DATA", 0) assertConvertHexColor("#FFFFFFFF", -1) assertConvertHexColor("#AAAAAA", -5592406) assertConvertHexColor("#00AAAAAA", 11184810) } private fun assertConvertHexColor(hexColor: String, intColor: Int) { assertThat(SiteViewConverter.convert(hexColor), equalTo(intColor)) } }
Niestety test testConvertHexColor
nie zakończy się sukcesem, ponieważ klasa Color
należy do Android SDK. Do testowania klas które mają referencję do Android SDK, trzeba użyć Robolectric.
Najlepiej jest wydzielić test testConvertHexColor
do osobnej klasy (tak aby klasyczne testy nie znajdowały się w tej samej klasie co testy wykorzystujące Robolectric) i dodać do niej adnotacje @RunWith(AndroidJUnit4::class)
– która użyje odpowiedniego „Runnera” do uruchomienia testów – w tym przypadku „RobolectricTestRunner” (więcej informacji na http://robolectric.org/androidx_test/).
@RunWith(AndroidJUnit4::class) class SiteViewConverterAndroidTest { @Test fun testConvertHexColor() { assertConvertHexColor("", 0) assertConvertHexColor("WRONG DATA", 0) assertConvertHexColor("#FFFFFFFF", -1) assertConvertHexColor("#AAAAAA", -5592406) assertConvertHexColor("#00AAAAAA", 11184810) } private fun assertConvertHexColor(hexColor: String, intColor: Int) { assertThat(SiteViewConverter.convertHexColor(hexColor), equalTo(intColor)) } }
Czy można byłoby zmockować Color.parseColor
? Tak, ale generowałoby to zbędny, skomplikowany kod a test testowałby tylko interakcję z mockiem a nie zachowanie. Zmiana implementacji parsowania koloru niestety pociągałaby ze sobą zmianę także testu.
Takie zastosowanie Robolectic daje najwięcej korzyści, ponieważ bardzo niskim kosztem (dodanie adnotacji RunWith
i kilku zależności), bez posiadania dużej wiedzy na temat frameworka, można napisać testy jednostkowe do istotnych części systemu.
TEST: Widok elementu listy
W tym teście test wejdę trochę na wyższy poziom abstrakcji, ponieważ będę testował integrację 3 obiektów SiteItemBinding
, View
oraz SiteViewConverter
. Cała ta konstrukcja składa się na jeden element listy.
Do utworzenia widoku jest potrzebny Context
activity. Można go utworzyć za pomocą metody buildActivity
:
@RunWith(AndroidJUnit4::class) class SiteItemViewTest { private val context = Robolectric.buildActivity(Activity::class.java).get() }
Mając już Context
można stworzyć instancję SiteItemBinding
i przetestować stworzony widok wraz z bindingiem i klasami powiązanymi.
@RunWith(AndroidJUnit4::class) class SiteItemViewTest { private val context = Robolectric.buildActivity(Activity::class.java).get() private lateinit var layoutInflater: LayoutInflater private lateinit var binding: SiteItemBinding @Before fun setUp() { layoutInflater = LayoutInflater.from(context) binding = SiteItemBinding.inflate(layoutInflater) } @Test fun testSiteItemBinding() { val site = Site( name = "Best android blog", url = "http://androidcoder.pl", tags = listOf("test", "test2"), labelColor = "#FFFFFF" // after convert to int == -1 ) binding.site = site binding.executePendingBindings() assertThat(binding.name.text.toString(), equalTo(site.name)) assertThat(binding.url.text.toString(), equalTo(site.url)) assertThat(binding.hashtags.text.toString(), equalTo("#test #test2")) assertThat((binding.color.background as ColorDrawable).color, equalTo(-1)) } }
[L1] Dodanie adnotacji aby test został uruchomiony z pomocą frameworka Robolectric
[L3] Utworzenie instancji Activity
niezbędnego do utworzenia widoku
[L9-L10] Stworzenie instancji SiteItemBinding, która ma referencję do widoku oraz do klasy SiteViewConverter
.
[L15] Utworzenie danych testowych.
[L21-L22] Wstrzyknięcie danych testowych i wymuszenie odświerzenia widoku
[L24-L27] Sprawdzenie czy po wstrzyknięciu danych [L21] dane te zostały poprawnie wstrzyknięte do widoku.
Ponieważ SiteViewConverter
posiada własne testy jednostkowe, których nie ma sensu duplikować to w tym teście można tylko sprawdzić czy metody convertHexColor
i convertTags
zostały wywołane.
TEST: SiteViewHolder
class SiteViewHolder( private val binding: SiteItemBinding ) : RecyclerView.ViewHolder(binding.root) { fun bind(site: Site) { binding.site = site binding.executePendingBindings() } }
Może zastanawiacie się czy warto testować SiteViewHolder
? Czy SiteViewHolder
zawiera taki kod, który trzeba przetestować? Czy może jednak testy SiteAdapter
pokryją wszystkie przypadki testowe w SiteViewHolder
? Jako profesjonaliści musicie sami odpowiedzieć na to pytanie bazując na własnym doświadczeniu.
Ja zdecydowałem się na oba rozwiązania tak, abyście zobaczyli jakie są zalety i wady łączenia testów oraz rozdzielania ich. Na początek oddzielny test dla SiteViewHolder
@RunWith(AndroidJUnit4::class) class SiteViewHolderTest { private val context = Robolectric.buildActivity(Activity::class.java).get() private lateinit var layoutInflater: LayoutInflater private lateinit var binding: SiteItemBinding private val site = Site( name = "Best android blog", url = "http://androidcoder.pl", tags = listOf("test", "test2"), labelColor = "#FFFFFF" ) @Before fun setUp() { layoutInflater = LayoutInflater.from(context) binding = spyk(SiteItemBinding.inflate(layoutInflater)) } @Test fun testHolderBinding() { val holder = SiteViewHolder(binding) holder.bind(site) assertThat(holder.itemView, equalTo(binding.root)) verify { binding.site = site } } }
[L1] Dodanie adnotacji aby test został uruchomiony z pomocą frameworka Robolectric
[L3] Utworzenie instancji Activity
niezbędnego do utworzenia widoku
[L7] Utworzenie danych testowych
[L16-L17] Stworzenie instancji SiteItemBinding
, która ma także referencję do widoku. Dodatkowo użyłem spyk
z Mockk aby sprawdzić czy na SiteItemBinding
został ustawiony site
[L25]
[L22] Stworzenie SiteViewHolder
z wcześniej utworzonym SiteItemBinding
[L23] Ustawienie danych testowych na SiteViewHolder
[L24] Sprawdzenie czy SiteViewHolder
zawiera ten sam widok co SiteItemBinding
[L25] Sprawdzenie czy SiteViewHolder
dobrze ustawia zmienną binding.site
.
TEST: SiteAdapter
Czas na adapter. Jako, że SiteViewHolder
, widok i binding site_item.xml
oraz SiteViewConverter
są przetestowane to w teście SiteAdapter
trzeba przetestować:
- Czy podczas tworzenia widoku zostanie wywołana metoda
bind(site)
naSiteViewHolder
. - Czy zmiana danych w adapterze spowoduje że metoda
bindViewHolder
wstrzyknie doSiteViewHolder
nowe dane
class SiteAdapter : RecyclerView.Adapter<SiteViewHolder>() { private var items = emptyList<Site>() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SiteViewHolder = SiteViewHolder(SiteItemBinding.inflate(LayoutInflater.from(parent.context))) override fun getItemCount(): Int = items.size override fun onBindViewHolder(holder: SiteViewHolder, position: Int) { holder.bind(items[position]) } fun setData(newItems : List<Site>){ items = newItems notifyDataSetChanged() } }
@RunWith(AndroidJUnit4::class) class SiteAdapterTest { private val activity = Robolectric.buildActivity(Activity::class.java) private val context = activity.get() private val adapter = spyk(SiteAdapter()) { every { onCreateViewHolder(any(), any()) } answers { spyk(callOriginal()) } } private val site = Site( name = "Best android blog", url = "http://androidcoder.pl", tags = listOf("test", "test2"), labelColor = "#FFFFFF" ) private val newSite = Site( name = "Best new android blog", url = "http://androidcoder.pl/home", tags = listOf("test", "test2", "test3"), labelColor = "#FFFFFF" ) private lateinit var recyclerView: RecyclerView private lateinit var holder: SiteViewHolder @Before fun setUp() { recyclerView = RecyclerView(context) recyclerView.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) recyclerView.adapter = adapter adapter.setData(listOf(site)) recyclerView.measure(0, 0) holder = recyclerView.findViewHolderForLayoutPosition(0) as SiteViewHolder } @Test fun testBindViewHolder() { verify { holder.bind(site) } } @Test fun testDataChanged() { adapter.setData(listOf(newSite)) adapter.bindViewHolder(holder, 0) verify { holder.bind(newSite) } } }
[L1] Dodanie adnotacji aby test został uruchomiony z pomocą frameworka Robolectric
[L3] Utworzenie instancji Activity
niezbędnego do utworzenia widoku
[L4-L7] Utworzenie instancji SiteAdapter
, który będzie testowany. Została tutaj zastosowana biblioteka mockk
. [L6] opakowuje utworzony ViewHolder
i pozwala śledzić interakcje z nim.
[L9 – L21] Utworzenie danych testowych
[L28-L31] Ustawienie początkowych danych testowych dla adaptera
[L32] Wymuszenie utworzenia widoków przez RecyclerView
[L33] Wyjęcie z RecyclerView
pierwszego ViewHolder
[L36-L39] Sprawdzenie czy przy tworzeniu widoku została wywołana metoda z bind
na SiteViewHolder
. Jest to test metody onBindViewHolder
z SiteAdapter
[41-47] Sprawdzenie czy jeśli dane w adapterze się zmienią to czy zostaną ustawione nowe dane na SiteViewHolder
. Jest to test metody setData
z SiteAdapter
W przypadku gdyby SiteAdapter
był bardziej skomplikowany i posiadał różne typy widoku, to testy metody onCreateViewHolder
musiałby sprawdzać czy dla danego viewType
typ zwracanego ViewHolder
jest poprawny.
TEST: SiteAdapter, SiteViewHolder, SiteItemBinding i SiteItemConverter za jednym razem
Napisanie 4 małych klas z testami może być przez niektórych uznane za bezsensowne i można stwierdzić, że przy tak prostej klasie jest to nadużycie. Teoretycznie można te wszystkie testy zawrzeć w jednej klasie testów Robolectric:
@RunWith(AndroidJUnit4::class) class SiteAdapterCondensedTest { private val context = Robolectric.buildActivity(Activity::class.java).get() private val adapter = SiteAdapter() private val site = Site( name = "Best android blog", url = "http://androidcoder.pl", tags = listOf("test", "test2"), labelColor = "#FFFFFF" ) private val newSite = Site( name = "Best new android blog", url = "http://androidcoder.pl/home", tags = listOf("test", "test2", "test3"), labelColor = "#FFFFFF" ) lateinit var recyclerView: RecyclerView lateinit var holder: SiteViewHolder @Before fun setUp() { recyclerView = RecyclerView(context) recyclerView.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) recyclerView.adapter = adapter adapter.setData(listOf(site)) recyclerView.measure(0, 0) holder = recyclerView.findViewHolderForLayoutPosition(0) as SiteViewHolder } @Test fun testBindViewHolder() { assertThat(holder.itemView.name.text.toString(), equalTo(site.name)) assertThat(holder.itemView.url.text.toString(), equalTo(site.url)) assertThat(holder.itemView.hashtags.text.toString(), equalTo("#test #test2")) assertThat((holder.itemView.color.background as ColorDrawable).color, equalTo(-1)) } @Test fun testDataChanged() { adapter.setData(listOf(newSite)) adapter.bindViewHolder(holder, 0) assertThat(holder.itemView.name.text.toString(), equalTo(newSite.name)) assertThat(holder.itemView.url.text.toString(), equalTo(newSite.url)) assertThat(holder.itemView.hashtags.text.toString(), equalTo("#test #test2 #test3")) assertThat((holder.itemView.color.background as ColorDrawable).color, equalTo(-1)) } @Test fun testColorConverter() { assertConvertHexColor("", 0) assertConvertHexColor("WRONG DATA", 0) assertConvertHexColor("#FFFFFFFF", -1) assertConvertHexColor("#AAAAAA", -5592406) assertConvertHexColor("#00AAAAAA", 11184810) } @Test fun testConvertTags() { assertConvertTags(emptyList(), "") assertConvertTags(listOf("aa"), "#aa") assertConvertTags(listOf("aa", " "), "#aa") assertConvertTags(listOf("aa", ""), "#aa") assertConvertTags(listOf("aa", "bb"), "#aa #bb") } private fun assertConvertTags(tags: List<String>, listOfTags: String) { adapter.setData(listOf(newSite.copy(tags = tags))) adapter.bindViewHolder(holder, 0) assertThat(holder.itemView.hashtags.text.toString(), equalTo(listOfTags)) } private fun assertConvertHexColor(hexColor: String, intColor: Int) { adapter.setData(listOf(newSite.copy(labelColor = hexColor))) adapter.bindViewHolder(holder, 0) assertThat((holder.itemView.color.background as ColorDrawable).color, equalTo(intColor)) } }
Połączenie testów SiteViewHolderTest
, SiteAdapterTest
oraz SiteItemViewTest
spowodowało zmniejszenie ilości kodu i uproszczenie testów. Można jednak zauważyć, że jednocześnie metody assertConvertTags
oraz assertConvertHexColor
skomplikowały się w porównaniu z metodami z SiteViewConverterTest
.
Pisanie testów dla wielu połączonych klas zamiast rozdzielania ich może w jednym miejscu uprościć testy a w drugim wręcz skomplikować.
Jeśli macie pewność na 100%, że będzie to prosty widok to można połączyć takie testy ale jeśli klasy zaczną się rozbudowywać, należy je bezwzględnie zrefaktorować, oddzielić i napisać jak w pierwotnym podejściu.
TEST: Activity
Activity
jest komponentem odpowiedzialnym za wyświetlanie okna aplikacji i jest elementem Android SDK – nie powinna się tam znajdować żadna logika biznesowa. Testy Activity
powinny się sprowadzać tylko do testowania interakcji pomiędzy widokiem a użytkownikiem.
Pozwoliłem sobie na uproszczenie Activity i umieszczenie w nim kodu pobierania danych z repozytorium w wątku głównym. Zrobiłem to aby uprościć do minimum kod, który pokazuję jako wstęp do testowania Robolectric.
open class MainActivity : AppCompatActivity() { lateinit var repository: SiteRepository override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) inject() val adapter = SiteAdapter() list.adapter = adapter repository.getSites( success = { adapter.setData(it) } ) } open fun inject() { repository = RealSiteRepository() } }
Jedną z trudniejszych rzeczy podczas testowania Activity z jaką trzeba się zmierzyć, jest wstrzykiwanie testowych zależności. W teście MainActivity
muszę użyć klasy MockSiteRepository
zamiast RealSiteRepository
.
class MockSiteRepository : SiteRepository { var sites: List<Site>? = null set(value) { field = value value?.let { sites -> pendingRequest.forEach { it.invoke(sites) } } } private val pendingRequest = mutableListOf<((List<Site>) -> Unit)>() override fun getSites(success: (List<Site>) -> Unit) { sites.let { if (it == null) pendingRequest += success else success.invoke(it) } } }
Jest kilka rozwiązań danego problemu, ja najbardziej lubię sposób, który znalazłem w książce „Working Effectively with Legacy Code”. Polega on na tym, że trzeba stworzyć nową klasę np. TestedMainActivity
, która dziedziczy po MainActivity
i nadpisuje metodę odpowiedzialną za wstrzykiwanie zależności. W tym przypadku jest to metoda inject()
.
class TestedMainActivity : MainActivity() { override fun inject() {} }
Gdy do testów użyjemy klasy TestedMainActivity
zamiast MainActivity
to nie będzie już użyta klasa RealSiteRepository
. Bezpośrednio w testach wstrzykniemy MockSiteRepository
aby móc wstrzykiwać dane testowe.
@RunWith(AndroidJUnit4::class) class MainActivityTest { private val scenario = Robolectric.buildActivity(TestedActivity::class.java) private val mockRepository = MockSiteRepository() @Before fun setUp() { scenario.get().repository = mockRepository } private fun setSites(sites: List<Site>) { mockRepository.sites = sites } }
[L1] Dodanie adnotacji aby test został uruchomiony z pomocą frameworka Robolectric
[L3] Stworzenie activity do testów, w tym przypadku TestedMainActivity
dziedziczącego MainActivity
[L4] Stworzenie instancji mocka repozytorium
[L7-L10] Jako, że w TestedMainActivity
nie zostało wstrzyknięte żadne repozytorium to wstrzykujemy tutaj MockSiteRepository
[L12-L14] Metoda pomocnicza do ustawiania nowych danych w repozytorium
Do sprawdzenia w tym teście mamy 3 rzeczy:
- Czy jeśli repozytorium jest puste to czy nic się nie wyświetli
- Czy jeśli repozytorium zawiera jakieś dane to wyświetlą się elementy listy
- Dodatkowo można sprawdzić czy w liście jest używany odpowiedni adapter – a dzięki temu odpowiednie widoki.
Test 1: Nie wyświetlaj elementów listy jeśli repozytorium jest puste
Test jest bardzo prosty, zmieścił się w 3 linijkach – głównie za sprawą biblioteki espresso.
@Test fun show_none_items_when_sites_are_empty() { setSites(emptyList()) scenario.setup() onView(withId(R.id.list)).check(matches(hasChildCount(0))) }
[L3] Ustawiamy za pomocą metody setSites
pustą listę w repozytorium
[L4] Uruchamiamy activity za pomocą metody scenario.setup()
[L5] Wykorzystujemy bibliotekę espresso i wbudowane interakcje. Interakcja onView
znajduje widok za pomocą podanego matchera. W tym przypadku, matcherem jest withId
, który pozwala znaleźć widok po konkretnym id. Polecenie onView(withId(R.id.list))
zwraca nie widok, ale obiekt , który pozwala wykonać różne interakcje z widokiem. Ja użyłem metody check
oraz matches(hasChildCount(0))
– to sprawdza czy widok nie posiada w sobie innych widoków.
Ostatnia linia na pierwszy rzut oka może wydawać się niezbyt prosta, jest to spowodowane tym, że twórcy espresso chcieli aby była ona jak najbardziej uniwersalna i umożliwiała programistom rozszerzanie jej w ramach własnego projektu i poza nim.
Test 2: Wyświetl element listy jeśli w repozytorium znajdują się dane
@Test fun show_data_when_sites_are_not_empty() { //GIVEN val site = Site("Test name", "http://test.com", listOf("tag1", "tag2"), "#FF0000") setSites(listOf(site)) //WHEN scenario.setup() //THEN onView(withId(R.id.list)).check(matches(hasChildCount(1))) onView( allOf( withChild(withText(site.name)), withChild(withText(site.url)), withChild(withText("#tag1 #tag2")), isDescendantOfA(withId(R.id.list)) ) ).check(matches(isDisplayed())) }
[L9] Sprawdzam czy widok listy posiada 1 element
[L10] Matcher allOf
agreguje wiele matcherów i szuka takiego widoku, który je spełnia. Szukam widoku, który ma w sobie widoki, które zawierają teksty site.name
, site.url
, "#tag1 #tag2"
i w dodatku widok ten znajduje się w widoku R.id.list
. Sprawdzam za pomocą check(matches(isDisplayed()))
czy znaleziony widok jest wyświetlony na ekranie.
Test 3: Czy jest używany dobry adapter
@Test fun test_adapter_usage() { //GIVEN setSites(listOf()) //WHEN scenario.setup() //THEN onView(withId(R.id.list)).check { view, _ -> if (view is RecyclerView) { assert(view.adapter is SiteAdapter) } } }
[L8] W tym teście zastosowałem własny matcher w formie lambdy do sprawdzenia widoku, ponieważ nie istniał żaden wbudowany do sprawdzenia adaptera. Jak widzicie, można bez problemu dostać się do referencji widoku i wykonać na nim jakiekolwiek chcemy działania.
Podsumowanie
Test dla Activity nie jest może największy ale jest najbardziej skomplikowany – nie można i nie powinno się w nim testować zbyt dużej ilości szczegółów.
Testy UI znajdują się na samym wierzchołku piramidy testów co oznacza ze testów UI w stosunku do reszty testów w systemie powinno być najmniej, ponieważ
- Testy są bardzo powolne w stosunku do testów jednostkowych
- Są trudniejsze w napisaniu i generują wysokie koszty utrzymania (każda zmiana UI pociąga za sobą zmianę testów – lub napisanie ich od nowa)
- Trudno się je debuguje i ciężko znaleźć przyczynę błędu
- Dodając animację możemy zepsuć masę testów
Dlatego tak ważne jest aby jak najwięcej przypadków testowych pokryć w testach integracyjnych oraz jednostkowych, które są szybkie i tanie. W droższych testach powinny być testowane rzeczy krytyczne, problematyczne i takie które programiści łatwo psują np. przycisk do rejestracji, który może stać się niewidoczny dla użytkowników po aktualizacji widoku – mimo, że jest to tylko przycisk, jest to rzeczy bardzo krytyczna, ponieważ uniemożliwiamy rejestrację nowych użytkowników.