Piramida testów na Androidzie

Piramida testów na Androidzie

Tags
Testy
Android
Hidden
Hidden
Published
January 2, 2023
Author
Jarosław Michalik
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.
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.
notion image
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óż, słabo.. W takiej sytuacji nie możemy sobie pozwolić na złe działanie klamek. 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.
notion image
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 nasza kodzik to takie puzzle. Wiele klas czy innych komponentów łączy się ze sobą i daje pełen obraz aplikacji.
notion image
notion image
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
  1. Ma wyświetlić dane Venue na ekranie – czyli przypisać je do pola VenueDisplayable
  1. 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?

Najlepiej będzie śledzić nowości na stronie Szkoły Kotlina. Zapisz się poniżej, a nie przegapisz aktualizacji bezpłatnych materiałów (takich jak ten) i dostaniesz najlepszą ofertę na programy edukacyjne.
 
 
Ten wpis został oryginalnie opublikowany na mojej osobistej stronie, a także udostępniony na OhMyDev
 
 
Wpisy