Testy jednostkowe w Androidzie cz. 6 – Robolectric

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.

rys. element listy

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ć:

  1. Czy podczas tworzenia widoku zostanie wywołana metoda bind(site) na SiteViewHolder.
  2. Czy zmiana danych w adapterze spowoduje że metoda bindViewHolder wstrzyknie do SiteViewHolder 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.

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 *