Piramida testów na Androidzie. Część pierwsza – Unit Testy

Programujesz na Androida i zastanawiasz się jak napisać testy do aplikacji? A może w projekcie nie masz żadnych testów i nie wiesz jak żyć? Zapoznaj się z moja filozofią tworzenia testów na Androida.

Piramida testów na Androidzie. Część pierwsza – Unit Testy

Programujesz na Androida i zastanawiasz się jak napisać testy do aplikacji? A może w projekcie nie masz żadnych testów i nie wiesz jak żyć? Zapoznaj się z moja filozofią tworzenia testów na Androida.

trwa sprzedaż kursu unit testów na Androidzie – kupisz go tutaj w cenie 1499zł

Zacznijmy nie od programowania, a od szeroko pojętej budowlanki. Zajmijmy się oknami w domach czy też w mieszkaniach. W oknach takich zazwyczaj spotykamy klamki. Klamka ma otwierać okno. System okienny z klamką możemy sprawdzać na wiele sposobów, w tym:

  • czy klamka pasuje do danych drzwi okiennych?
  • czy klamka po przekręceniu porusza wprawia w ruch elementy okucia?
  • czy klamka po przekręceniu odblokuje okno?

Analogia jest uzasadniona wyłącznie popularnym gifem.

Przełóżmy teraz te wymagania na programistyczne realia. Niech system okienny będzie naszą apką.

  • Czy klamka pasuje do drzwi okiennych? Taką informację powinien nam dostarczyć kompilator.
  • Czy klamka po przekręceniu wprawia w ruch elementy okucia? To sprawdzimy unit testem.
  • Czy klamka po przekręceniu otworzy okno? Tu pomoże nam test integracyjny. Albo inaczej skonstruowany unit test. Zależy co weźmiemy za nasz „unit” w danym teście.
  • A teraz wreszcie – czy jesteśmy w stanie otworzyć okno? Tu jest przypadek na testy end-to-end.

A co jeśli mamy wiele okien i dojdzie do takich przypadków jak na powyższym gifie?

No cóż, będzie średnio. W takiej sytuacji nie możemy sobie pozwolić na złe działanie klamki. Trzeba jeszcze resztę domu wybudować.

Społeczności programistycznej znana jest koncepcja piramidy testów. To takie graficzne przedstawienie pożądanej ilości testów danego typu w projekcie.

Podstawą piramidy są unit testy, a im wyżej, tym te testy są bardziej „zintegrowane”, aż dochodzą do poziomu testowania całej aplikacji end-to-end.

Dużo unit testów daje nam pewien spokój ducha. Pali nam się projekt, ale chociaż mamy pewność, że tamta malutka klasa działa tak jak sobie tego życzymy i że żadne przeddeployowe hotfixy tego nie zepsuły.

Czym jest unit test?

Unit testem nazwiemy taki test, które spełnia następujące warunki:

  • sprawdza wyłącznie jeden element zachowania system
  • jest wyizolowany
  • działa deterministycznie

Wyobraźmy sobie, że nasz kodzik to takie puzzle. Wiele klas czy innych komponentów łączy się ze sobą i daje pełen obraz aplikacji.

Zielony puzzel jest naszym "system under test". To na nim będziemy się skupiać. W idealnym przypadku ten klocek nie łączy się bezpośrednio z innymi, dlatego w unit teście możemy przyjąć taki schemat:



Użyjemy mocków. Takich komponentów, które pasują do naszej klasy, ale nie mają żadnych konkretnych implementacji. Będziemy kontrolować zachowanie mocków i sprawdzać jak zachowa się testowany komponent gdy inne puzzle będą dawać mu inne dane.

Co unit testujemy na Androidzie?

Jakie komponenty apki androidowej będziemy pokrywać unit testami? Cała logikę biznesową. Można powiedzieć inaczej – kod domenowy. Zachowanie naszej aplikacji w podejściu Clean Architecure (link) opisujemy za pomocą Use Case’ów, Interactorów, warstw Repository, Service czy wreszcie warstw Presentera lub ViewModelu.

Uwaga – unit testować będziemy te komponenty, które nie mają żadnych importów androidowych, lub mają je niezbędne minimum. Ostatecznie będziemy też dążyć do tego, by warstwy projektu zawierające logikę aplikacji były jak najbardziej odizolowane od frameworka Android.


Zobaczmy kodzik

Postaramy się zaimplementować test dla pewnego view modelu. Oto VenueDetailsViewModel oraz kilka klas mu towarzyszących:

class VenueDetailsViewModel(
    val venueId: String,
    val venueRepository: Repository<VenueDisplayable>,
    val favoriteVenueController: FavoriteVenueController
) : ViewModel() {

  var isFavorite: Boolean? = null

  var displayable: VenueDisplayable? = null

  var errorCallback: (Throwable) -> Unit = {}

  fun start() {
    venueRepository.getOne(venueId)
        .doOnSuccess {
          displayable = it
        }
        .doOnError {
          errorCallback.invoke(it)
        }
        .subscribe()

    favoriteVenueController.checkIsFavorite(venueId)
        .doOnSuccess {
          isFavorite = it
        }
        .doOnError {
          // ignore
        }
        .subscribe()
  }

  fun favoriteClick() {
  }
}
interface Repository<T> {
  fun getOne(id: String): Single<T>
}

interface FavoriteVenueController {
  fun markAs(venueId: String, favorite: Boolean): Completable
  fun checkIsFavorite(venueId: String): Single<Boolean>
}

data class VenueDisplayable(
    val id: String,
    val name: String,
    val location: String
)

Co ten nasz ViewModel ma zrobić?

  1. Ma pobrać Venue o konkretnym ID z Repository
  2. Ma wyświetlić dane Venue na ekranie – czyli przypisać je do pola VenueDisplayable
  3. Jeśli coś złego się stanie, ma o tym dać znać – czyli wywołać funkcję errorCallback

Tworzę sobie klasę testową i dodaję funkcję tworzącą testową instancję naszego view modelu:

class VenueDetailsViewModelTest {

  private fun createViewModel(
      venueId: String = "fake_id",
      venueRepository: Repository<VenueDisplayable> = mock(),
      favoriteVenueController: FavoriteVenueController = mock()
  ) = VenueDetailsViewModel(
      venueId = venueId,
      venueRepository = venueRepository,
      favoriteVenueController = favoriteVenueController
  )
}

Uwaga. Bardzo ważna rzecz. Do unit testu nie podajemy konkretnych implementacji Repository, Controlera, czy innych rzeczy. Podajemy test doubles, najczęściej nazywane w skrócie mockami. Do mockowania używam Mockito, a w projektach z Kotlinem coraz częściej MockK

W tym wypadku w funkcji createViewModel() przekazuję defaultowe argumenty – puste mocki stworzone za pomocą Mockito z Kotlin Extensions.

Zaimplementujmy w końcu teścik.

W naszym pierwszym przypadku testowym sprawdzimy czy VenueDisplayable pobrane z venueRepository zostanie faktycznie przypisane do pola displayable, a w drugim sprawdzimy czy w przypadku błędu zostanie przekazany błąd do funkcji errorHandler.

import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.verify
import io.reactivex.Single
import org.junit.Test
import strikt.api.expectThat
import strikt.assertions.isEqualTo

@Test
  fun `given repository emits value when start view model then display that value`() {
    val viewModel = createViewModel(
        venueRepository = mock {
          on { getOne(FAKE_ID) } doReturn Single.just(fakeVenue)
        }
    )

    viewModel.start()

    expectThat(viewModel.displayable).isEqualTo(fakeVenue)
  }

  @Test
  fun `given repository emits error when start view model then show error`() {
    val mockErrorCallback: (Throwable) -> Unit = mock()
    val throwable = Throwable("some error")

    val viewModel = createViewModel(
        venueRepository = mock {
          on { getOne(FAKE_ID) } doReturn Single.error(throwable)
        }
    ).apply {
      errorCallback = mockErrorCallback
    }

    viewModel.start()

    verify(mockErrorCallback).invoke(throwable)
  }

Czy mógłbym te dwa przypadki zawrzeć w jednym teście? Prawdopodobnie tak. Ale nie o to tu chodzi. Chcemy mieć pewność, że wyświetlanie errorów działa niezależnie od tego, czy wyświetlanie danych ma się dobrze. W teście warto mieć jedną asercję, choćby ze względu na to jak działa większość frameworków do testów. Najczęściej po pierwszej nieudanej asercji przerywane jest działanie reszty testu. O tym jak mieć kilka asercji w jednym teście bez zbędnego bólu głowy napisałem tutaj

Co jest jeszcze ogromnie istotne w unit teście? Izolacja.

Zauważmy, że tego testu nie obchodzi czy dane Repository<> jest bazą danych, czy też jakimś źródłem sieciowym. Tego testu nie obchodzą szczegóły implementacji. Równie dobrze Repository może nie działać. Po prostu ViewModel dobrze się dogaduje z interfejsem Repository, a to jest ważne w unit testach. Z drugiej strony mamy pewien errorCallback. Będzie on zaimplementowany w warstwie widoku jako Toast lub Snackbar. Ale czy jest to istotne z perspektywy unit testu? Nie jest istotne ani trochę.

Krótkie FAQ na sam koniec:

Jak osiągnąć izolacje testu?

Wstrzykiwanie zależności for the win. Kieruj się filozofią dependency inversion, a będziesz w domu.


Jak wybrać unit testu jednostkowego?

Wybierz najbardziej podstawowe zachowanie systemu jaki przyjdzie Ci do głowy. Może to być jedna publiczną metoda, może to być bardzo konkretna ścieżka programu.


Jaki framework wybrać do testów jednostkowych na Androidzie?

Junit5, Kotest, MockK. To jest dobra baza. Uważam, że Junit4 nie ma już co używać w nowych projektach. Jeśli czujesz się już dobrze w unit testach i chcesz spróbować innego stylu – sprawdź Kotest i jego Speci. Do mockowania – jeśli używasz już Mockito, to dobrym pomysłem będzie zaciągnięcie dodatkowych rozszerzeń kotlinowych dla tej biblioteki. A jeśli mocno wykorzystujesz korutyny, dobrym pomysłem będzie odpalenie MockK.

Gdzie nauczyć się dobrych praktyk w testowaniu na Androida w Kotlinie?

Możesz wciąż korzystać z wielu bezpłatnych źródeł (takich jak ten wpis), a dodatkowo dołączyć do kursu unit testów. W tym praktycznym programie przekażę Ci konkretną wiedzę, którą przełożysz na konkretne skille.

Użyj kodu piramida-testow by dostać dodatkowe 6,9% zniżki na cały kurs.

Android ❤️ Unit Testy - EDU | michalik.tech
Praktyczny kurs unit testów dla Android Dev – integracja Junit5 i frameworków do specyfikcji – asercje bez tajemnic – mockowanie – ręcznie i z użyciem frameworków – testy w Clean Architecture – testowanie z Kotlin Coroutines oraz RxJava – testowanie z LiveData – metody testowania MVP, MVVM, MVI, MV(…
wpis pojawił się oryginalnie na portalu OhMyDev
Jarosław Michalik

Jarosław Michalik

Programista | Bloger | Muzyk Pomagam się ogarnąć w internetowych technologiach