Testy jednostkowe w Androidzie cz 1. – JUnit i MockK

Wstęp

Testowanie jednostkowe kodu Androidowego od długiego czasu wzbudza wiele emocji ze względu na mocną zależność od Android SDK, które skutecznie podstawia programistom kłody pod nogi. Główną przyczyną jest budowa Android SDK, które w dużej mierze bazuje wszędzie na klasie Context a na domiar złego większość klas z Android SDK nie jest interfejsem tylko konkretną implementacją z ukrytym konstruktorem lub oznaczoną jako final. Na szczęście istnieją biblioteki, które pozwalają na stworzenie mocków nawet klas finalnych.

Czego się nauczysz

  • konfigurować projekt do testów jednostkowych
  • pisać testy jednostkowe do swojego kodu
  • używać biblioteki do mockowania MockK

Konfiguracja środowiska:

Jeśli utworzyłeś projekt w AndroidStudio to najprawdopodobniej w swoim projekcie masz już katalog z testami app/src/test/java i przykładowym testem, który możesz usunąć.

Jeśli nie masz takiego katalogu to stwórz app/src/test/java i wewnątrz dodaj pakiet odpowiadający projektowi – w tym artykule jest to pl.androidcoder.unittest. Powinno wyglądać to tak:

rys 1

Następnie sprawdź czy w pliku app/build.gradle masz zależność do biblioteki junit:

dependencies {
    ...
    testImplementation 'junit:junit:4.12'
}

Jeśli wszystko zrobiłeś to uruchom wbudowany w Android Studio terminal (Option+F12 lub Alt+F12) i wpisz polecenie:

Dla Windows:

gradlew testDebugUnitTest

Dla Linux i MacOS:

./gradlew testDebugUnitTest

Jeśli poprawne wszystko skonfigurowałeś to powinieneś otrzymać poniższy komunikat:

BUILD SUCCESSFUL in 19s
15 actionable tasks: 13 executed, 2 up-to-date

Przykład 1: Test metody z rodziny utils

Jeśli klasa, którą testujesz nie ma zależności od żadnej implementacji z Android SDK to testy jednostkowe nie będą się różniły od testów jednostkowych w Javie/Kotlinie.

Pierwszym testem jaki napiszesz będzie test funkcji removeDuplicates, która usuwa duplikujące się obiekty w kolekcji:

package pl.androidcoder.unittest.utils

fun <T> removeDuplicates(list : List<T>) : List<T>{
    val noDuplicates = mutableListOf<T>()
    list.forEach {
        if(!noDuplicates.contains(it)){
            noDuplicates.add(it)
        }
    }
    return noDuplicates
}

Utworzenie pliku z testem

Tworzenie pliku z IDE

Żeby stworzyć nowy plik z testem ustaw kursor na nazwę metody, do której chcesz napisać test, naciśnij kombinację cmd+shift+t lub ctrl+shift+t i wybierz Create New Test

rys. P1.1

Upewnij się że w okienku, które się pojawi jako Testing library masz wybrany jUnit4. W Class name usuń Kt żeby klasa nazywała sie CollectionUtilsTest. (jest to spowodowane tym, że funkcja znajduje się bezpośrednio w pliku kotlinowym (a nie w klasie) , które ma rozszerzenie Kt).

rys. P1.2

Po zatwierdzeniu pojawi się jeszcze jedno okno z wyborem lokalizacji nowego pliku. Koniecznie wybierz app/src/test, ponieważ umiejscowienie pliku w złym katalogu spowoduje, że nie zostanie uruchomiony.

rys. P1.3

Ręczne tworzenie pliku

Możesz też utworzyć ręcznie plik CollectionUtilsTest.kt w katalogu app/src/test/java w pakiecie pl.androidcoder.unittest.utils.

Uruchamianie testów

Tak powinien wyglądać nowo utworzony plik dla testów:

package pl.androidcoder.unittest.utils

import org.junit.Assert.*

class CollectionUtilsTest

Żeby gradle wykrył, że klasa CollectionUtilsTest zawiera testy to musi mieć przynajmniej jedną funkcję oznaczoną adnotacją @Test:

package pl.androidcoder.unittest.utils

import org.junit.Assert.*
import org.junit.Test

class CollectionUtilsTest{

    @Test
    fun first(){
        
    }
}

Po każdorazowym dodaniu nowego testu uruchom pusty test. Jeśli test nie przejdzie to znaczy, że problem leży gdzieś w konfiguracji. Uruchamianie pustych testów ochroni Cię przed złą interpretacją nieprzechodzącego testu – sam kilka razy wpadłem w tę pułapkę i straciłem masę czasu szukając błędu tam gdzie go nie było.

Uruchamianie testu z IDE

Test możesz uruchomić z interfejsu AndroidStudio naciskając mały trójkącik po lewej stronie przy nazwie klasy lub metody (ten przy metodzie uruchamia jedynie tę metodę, ten przy klasie uruchamia wszystkie testy z klasy.

rys. P1.4

Polecam uruchamianie testów z IDE, jeśli aplikacja ma bardzo duży zestaw testów i chcesz uruchomić tylko jeden test lub grupę testów ze specyficznego pakietu. Wyniki testów uruchamianych za pomocą IDE są bardziej czytelne od tych z konsoli.

rys. P1.5
BUILD SUCCESSFUL in 14s
15 actionable tasks: 5 executed, 10 up-to-date

Uruchamianie testu z terminala

Jeśli jednak lubisz konsolę lub chcesz szybko uruchamiać wszystkie testy to użyj poniższych poleceń w terminalu:

Dla Windows:

gradlew testDebugUnitTest

Dla Linux i MacOS:

./gradlew testDebugUnitTest

aby zwiększyć czytelność wyników testów dodaj w pliku build.gradle poniższy kod:

android{
  ...
}

tasks.withType(Test) {
    testLogging {
        events "started", "passed", "skipped", "failed"
    }
}

dzięki niemu zostaną wypisane na ekranie wszystkie uruchomione testy oraz ich wynik:

> Task :app:testDebugUnitTest

pl.androidcoder.unittest.utils.CollectionUtilsTest > first STARTED

pl.androidcoder.unittest.utils.CollectionUtilsTest > first PASSED

BUILD SUCCESSFUL in 3s
15 actionable tasks: 15 executed

Testy metody removeDuplicates

Skoro cała konfiguracja już działa, to możesz napisać test. Pisanie testów zacznij od najbardziej zdegenerowanych przypadków jakie istnieją, zostawiajac na sam koniec pozytywny przebieg. Najbardziej zdegenerowanym przypadkiem dla metody removeDuplicates jest przekazanie w argumencie pustej listy.

Test 1: Pusta lista

Jeśli metoda removeDuplicates dostanie pustą listę to powinna zwrócić pustą listę.

class CollectionUtilsTest{

    @Test
    fun `given empty list removeDuplicates should return empty list`(){
        //ARRANGE
        val emptyList = emptyList<String>()
        //ACT
        val actualList = removeDuplicates(emptyList)
        //ASSERT
        assert(actualList.isEmpty()) {"List should be empty but has ${actualList.size} elements"}
    }
}

Struktura testu

Test składa się z 3 głównych bloków ARRANGE ACT ASSERT (lub wywodzący się z metodyki BDD : GIVEN WHEN THEN)

  • ARRANGE (Given)- w tym bloku są ustawiane wszystkie warunki początkowe dla testu, w powyższym przykładzie jest to przygotowanie pustej listy.
  • ACT (When)- wywołanie metody do testowania, zazwyczaj znajduje się tutaj tylko jedna linia. Wynik działania funkcji przechowuję w zmiennej z przedrostkiem actual*, dla rozróżnienia wartości wyjściowej testu od wartości wejściowych.
  • ASSERT (Then)- sprawdzanie wyniku działania testu, w bloku ASSERT powinno być jedno logiczne sprawdzenie tzn. może być wiele linii kodu ale musi sprawdzać jedną rzecz (w powyższym kodzie jest to metoda isEmpty()). Metoda assert jako pierwszy argument przyjmuje wartość Boolean jeśli jest false to test kończy się niepowodzeniem i zostaje wyświetlona wiadomość podana jako drugi argument w formie lambdy {"List should be empty but has ${actualList.size} elements"}. Dodawanie wiadomości do asercji jest bardzo ważne, ponieważ bardzo szybko pozwala wykryć błąd:
rys. P1.6

Warto dodawać komentarze oddzielające te bloki. Komentarze dają też psychologiczne ograniczenie do tworzenia testu niezgodnego z tym schematem.

Aby zwiększyć czytelność wyekstraktuj asercje do osobnych metod jeśli są troszkę bardziej skomplikowane:

class CollectionUtilsTest{

    @Test
    fun `given empty list removeDuplicates should return empty list`(){
        //ARRANGE
        val emptyList = emptyList<String>()
        //ACT
        val actualList = removeDuplicates(emptyList)
        //ASSERT
        assertEmptyList(actualList)
    }

    private fun <T> assertEmptyList(actualList: List<T>) {
        assert(actualList.isEmpty()) { "List should be empty but has ${actualList.size} elements" }
    }
}

metoda assertEmptyList jest bardziej czytelna. Niekiedy asercje mogą mieć kilka linijek, skomplikowane pętle itp. Taka duża ilość kodu w teście potrafi skutecznie zaciemnić znaczenie.

Test 2: Wartości null

Jeśli metoda removeDuplicates dostanie listę z wartościami null to powinna zwrócić pustą listę.

    @Test
    fun `given list with nulls removeDuplicates should return empty list`(){
        //ARRANGE
        val emptyList = listOf<Any?>(null, null, null)
        //ACT
        val actualList = removeDuplicates(emptyList)
        //ASSERT
        assertEmptyList(actualList)
    }

W powyższym przypadku otrzymałem błąd:

rys. P1.7

Dzięki opisowi asercji zobaczysz od razu co jest problemem. Funkcja, która usuwa duplikaty traktuje wartość null jako jeden z elementów listy. Żeby test przeszedł, musisz zmodyfikować funkcję tak aby pomijała wartości null:

fun <T> removeDuplicates(list : List<T>) : List<T>{
    val noDuplicates = mutableListOf<T>()
    list.forEach {
        if(it != null && !noDuplicates.contains(it)){
            noDuplicates.add(it)
        }
    }
    return noDuplicates
}
rys. P1.8

Jak widzisz testy pozwalają wychwycić pewne błędy, których nie przetestowaliśmy manualnie. Czasami jako początkujący programiści nie wiemy jak jakaś funkcja się zachowuje.

Kiedyś miałem mały projekt, w którym w funkcji main testowałem działanie jakiś bibliotek po to aby nie testować na wielkim kodzie w dodatku na urządzeniu. Gdybym wtedy umiał pisać testy jednostkowe mógłbym zaoszczędzić mnóstwo czasu.

Test 3: Wszystkie elementy takie same

Jeśli w liście wszystkie elementy są takie same to zwróć listę z jednym elementem

    @Test
    fun `given list the same elements removeDuplicates should return list with one element`() {
        //ARRANGE
        val list = listOf(TestData("a"), TestData("a"), TestData("a"))
        val expectedList = listOf(TestData("a"))
        //ACT
        val actualList = removeDuplicates(list)
        //ASSERT
        assertEquals(expectedList, actualList)
    }
rys. P1.9

Użyłem funkcji assertEquals, która przyjmuje jako pierwszy parametr wynik oczekiwany a jako drugi wynik aktualny. Funkcje assertEqual,assertNotEquals, assertArrayEquals, assertNull, assertSame znajdziesz w klasie org.junit.Assert jako metody statyczne.

Test 4: Lista bez powtórzeń

Jeśli lista jest bez powtórzeń to zwróć taką samą listę

    @Test
    fun `given list with unique elements removeDuplicates should return the same list`() {
        //ARRANGE
        val expectedList = listOf(TestData("a"), TestData("b"), "first")
        //ACT
        val actualList = removeDuplicates(expectedList)
        //ASSERT
        assertEquals(expectedList, actualList)
    }
rys. P1.10

Test 5: Lista z powtórzeniami

Jeśli w liście powtarzają się elementy to zwróć listę bez powtórzeń

    @Test
    fun `given list with duplications removeDuplicates should return list without duplications`() {
        //ARRANGE
        val list = listOf(TestData("a"), TestData("a"), TestData("a"), "first", "first", "second")
        val expectedList = listOf(TestData("a"), "first", "second")
        //ACT
        val actualList = removeDuplicates(list)
        //ASSERT
        assertEquals(expectedList, actualList)
    }
rys. P1.11

Test 6: Nie modyfikuj listy

Funkcja nie może modyfikować listy przekazanej jako argument

    @Test
    fun `given list with duplications removeDuplicates should not edit given list`() {
        //ARRANGE
        val list = listOf(TestData("a"), TestData("a"), TestData("a"), "first", "first", "second")
        val expectedList = list.toList() //copy list
        //ACT
        removeDuplicates(list)
        //ASSERT
        assertEquals(expectedList, list)
    }
rys. P1.12

Podsumowanie przykładu

Prawie w każdym projekcie istnieje klasa Utils ze statycznymi metodami, które są wykorzystywane do prostych i niezmiennych w czasie czynności takich jak formatowanie, konwersje, operacje na kolekcjach itp. Takie funkcje są najlepszym materiałem na naukę podstaw pisania testów jednostkowych. Jeśli nie masz takich funkcji w projekcie to w ramach ćwiczeń pobierz repozytorium apache-commons-lang, usuń istniejące testy i napisz je od nowa. Przy okazji zajrzyj w istniejące tam testy a nauczysz się różnych mechanizmów stosowanych podczas testowania.

Przykład 2: Interakcja z BatteryManager

W tym przykładzie pokażę w jaki sposób napisać testy w przypadku gdy klasa ma zależności od Android SDK – tutaj od BatteryManager. Kluczem do sukcesu testowania tą metodą jest unikanie tzw. „train wreck” np. fragment.context.getService(Context.BATTERY_SERVICE). Należy użyć wstrzykiwania w konstruktorze obiektu klasy BatteryManager tak jak ma to miejsce w BatteryInfoFactory:

class BatteryInfoFactory(private val batteryManager: BatteryManager) {

    companion object {
        private const val DATA_NOT_AVAILABLE = Integer.MIN_VALUE
    }

    fun getBatteryCurrent(): BatteryCurrent? {
        val actualCurrent = getActualCurrent()
        val averageCurrent = getAverageCurrent()
        val actualCharge = getActualCharge()


        return if (allNull(actualCurrent, averageCurrent, actualCharge))
            null
        else
            BatteryCurrent(actualCurrent, averageCurrent, actualCharge)
    }

    private fun allNull(vararg variables: Any?) =
        variables.all { it == null }


    private fun getActualCharge(): AmpereHour? {
        val actualChargeProperty = batteryManager.getIntProperty(BATTERY_PROPERTY_CHARGE_COUNTER)
        return if (actualChargeProperty != DATA_NOT_AVAILABLE)
            AmpereHour.fromMicroAmpereHour(actualChargeProperty)
        else
            null
    }

    private fun getAverageCurrent(): Ampere? {
        val currentAverageProperty = batteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_AVERAGE)
        return if (currentAverageProperty != DATA_NOT_AVAILABLE)
            Ampere.fromMicroAmpere(currentAverageProperty)
        else
            null
    }

    private fun getActualCurrent(): Ampere? {
        val currentNowProperty = batteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)
        return if (currentNowProperty != DATA_NOT_AVAILABLE)
            Ampere.fromMicroAmpere(currentNowProperty)
        else
            null
    }
}
data class BatteryCurrent(
    val actualCurrent: Ampere?,
    val averageCurrent: Ampere?,
    val actualCharge: AmpereHour?
)
data class Ampere(val value: Double) {
    companion object {
        fun fromMicroAmpere(microAmperes: Int): Ampere {
            return Ampere(microAmperes / 1000000.0)
        }
    }
}
data class AmpereHour(val value: Double) {
    companion object {
        fun fromMicroAmpereHour(microAmperesHour: Int): AmpereHour {
            return AmpereHour(microAmperesHour / 1000000.0)
        }
    }
}

Test do BatteryInfoFactory

Stwórz nowy pusty test i uruchom, żeby zobaczyć czy wszystko działa:

class BatteryInfoFactoryTest {
    @Test
    fun empty() {
        
    }
}
rys. P2.1

Przy tworzeniu instancji BatteryInfoFactory napotykasz na problem, ponieważ konstruktor wymaga BatteryManager a obiekt tej klasy można pobrać jedynie z klasy Context, do której nie ma dostępu z poziomów testów. BatteryManager nie jest niestety interfejsem więc nie można stworzyć własnej implementacji do testów. Konstruktory są ukryte więc nie można nawet przeciążyć klasy.

Czy już nic nie da się zrobić? Trzeba stworzyć interfejs i delegować wszystkie funkcje?

Oczywiście, że jest rozwiązanie, które ratuje przed nadmiernymi implementacjami i odpuszczeniem testów. Trzeba użyć biblioteki, która potrafi robić mocki klas – czyli dostarcza instancję klasy, która nie ma żadnej funkcjonalności – przesłania jej całą funkcjonalność pozwalając symulować jej stan lub zachowanie.

Bibliotek tego typu jest kilka: Mockito (chyba najpopularniejsze), PowerMock, JMockit, EasyMock i MockK. (być może jakąś pominąłem)

Przez ostatnie lata używałem Mockito jednak nie wspiera oficjalnie składni kotlina – istnieje dodatkowa nieoficjalna biblioteka dodająca składnię kotlinową https://github.com/nhaarman/mockito-kotlin.

W poniższym przykładzie zrezygnują z Mockito i użyję biblioteki MockK, którą pokazał mi znajomy i przypadła mi go gustu w szczególności, że ma pełne wsparcie dla kotlina.

Dodatnie MockK do projektu

Wystarczy, że dodasz tylko jedną zależność. Aby sprawdzić najnowszą wersję wejdź na repozytorium Github projektu MockK https://github.com/mockk/mockk.

dependencies {
    testImplementation "io.mockk:mockk:1.9.3.kotlin12"
}

Stworzenie mocka BatteryManager

Aby stworzyć mocka użyj metody mockk dostarczonej przez bibliotekę:

class BatteryCurrentTest 
    private val batteryManager = mockk<BatteryManager>()
}

Gdy już masz obiekt klasy BatteryManager to możesz stworzyć także implementację BatteryInfoFactory:

class BatteryCurrentTest {
    private val batteryManager = mockk<BatteryManager>()
    private val factory = BatteryInfoFactory(batteryManager)
}

to już jest wszystko, żeby zabrać się za pierwszy test.

Test 1 : Żadna wartość nie jest dostępna

Zaczynam od najbardziej zdegenerowanego przypadku testowego:

Jeśli żadna wartość nie jest dostępna to getBatteryCurrent zwróci null.

    @Test
    fun `when all data is unavailable then return null`() {
        //ARRANGE
            ????
        //ACT
        val batteryCurrent = factory.getBatteryCurrent()
        //ASSERT 
        assertNull(batteryCurrent)
    }

W listingu P2.9 w 4 linii pozostawiłem znaki zapytania. Biblioteki takie jak MockK dają możliwość zakrywania prawdziwej implementacji w taki sposób, że możemy zasymulować co ma zwracać dana metoda. Jeśli wrócisz do listingu 2.1 to zobaczysz, że wszystkie dane pobierane są za pomocą jednej metody batteryManager.getIntProperty() i z różnymi argumentami: BATTERY_PROPERTY_CHARGE_COUNTER, BATTERY_PROPERTY_CURRENT_AVERAGE i BATTERY_PROPERTY_CURRENT_NOW.

Jeśli w teście na obiekcie batteryManager wykonamy poniższą operację:

every{ 
    batteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)
} returns 123

to za każdym razem gdy zostanie wywołana metoda getIntProperty z argumentem BATTERY_PROPERTY_CURRENT_NOW zostanie zwrócona liczba 123.

dzięki temu możemy uzupełnić pierwszy test o zmockowane funkcje w BatteryManager

class BatteryCurrentTest {

    companion object {
        private const val DATA_NOT_AVAILABLE = Integer.MIN_VALUE
    }

    private val batteryManager = mockk<BatteryManager>()
    private val factory = BatteryInfoFactory(batteryManager)

    @Test
    fun `when all data is unavailable then return null`() {
        //ARRANGE
        batteryManager.apply {
            every { getIntProperty(BATTERY_PROPERTY_CURRENT_NOW) } returns DATA_NOT_AVAILABLE
            every { getIntProperty(BATTERY_PROPERTY_CURRENT_AVERAGE) } returns DATA_NOT_AVAILABLE
            every { getIntProperty(BATTERY_PROPERTY_CHARGE_COUNTER) } returns DATA_NOT_AVAILABLE
        }
        //ACT
        val batteryCurrent = factory.getBatteryCurrent()
        //ASSERT
        assertNull(batteryCurrent)
    }
}
rys. P2.2

Linie 14-16 Listingu P2.11 zmieniają zachowanie funkcji getPropery() klasy BatteryManager w taki sposób, że jeśli w jako parametr podasz BATTERY_PROPERTY_CURRENT_NOW, BATTERY_PROPERTY_CURRENT_AVERAGE lub BATTERY_PROPERTY_CHARGE_COUNTER to zwróci ona wartość DATA_NOT_AVAILABLE

Jeśli metoda getIntProperty zwróci wartość Integer.MIN_VALUE to oznacza, że dana właściwość nie jest dostępna – sprawdź dokumentację BatteryManager i klasę BatteryInfoFactory (listing P2.1).

Wydzieliłem stałą Integer.MIN_VALUE do innej stałej DATA_NOT_AVAILABLE aby nadać jej znaczenie i poprawić czytelność kodu.

Test 2-4: Jedna z wartości nie jest dostępna

Jeśli BATTERY_PROPERTY_CURRENT_NOW nie jest dostępne to getBatteryCurrent zwróci BatteryCurrent bez actualCurrent

    @Test
    fun `should return null actualCurrent when is not available`() {
        //ARRANGE
        batteryManager.apply {
            every { getIntProperty(BATTERY_PROPERTY_CURRENT_NOW) } returns DATA_NOT_AVAILABLE
            every { getIntProperty(BATTERY_PROPERTY_CURRENT_AVERAGE) } returns 222222222
            every { getIntProperty(BATTERY_PROPERTY_CHARGE_COUNTER) } returns 333333333
        }
        //ACT
        val batteryCurrent = factory.getBatteryCurrent()
        //ASSERT
        batteryCurrent?.apply {
            assertNull(actualCurrent)
            assertEquals(Ampere(222.222222), averageCurrent)
            assertEquals(AmpereHour(333.333333), actualCharge)
        }
    }
rys. P2.3

Jeśli BATTERY_PROPERTY_CURRENT_AVERAGE nie jest dostępne to getBatteryCurrent zwróci BatteryCurrent bez averageCurrent

    @Test
    fun `should return null averageCurrent when is not available`() {
        //ARRANGE
        batteryManager.apply {
            every { getIntProperty(BATTERY_PROPERTY_CURRENT_NOW) } returns 111111111
            every { getIntProperty(BATTERY_PROPERTY_CURRENT_AVERAGE) } returns DATA_NOT_AVAILABLE
            every { getIntProperty(BATTERY_PROPERTY_CHARGE_COUNTER) } returns 333333333
        }
        //ACT
        val batteryCurrent = factory.getBatteryCurrent()
        //ASSERT
        batteryCurrent?.apply {
            assertEquals(Ampere(111.111111), actualCurrent)
            assertNull(averageCurrent)
            assertEquals(AmpereHour(333.333333), actualCharge)
        }
    }
rys. P2.4

Jeśli BATTERY_PROPERTY_CHARGE_COUNTER nie jest dostępne to getBatteryCurrent zwróciBatteryCurrent bez actualCharge.

@Test
fun `should return null actualCharge when is not available`() {
    //ARRANGE
    batteryManager.apply {
        every { getIntProperty(BATTERY_PROPERTY_CURRENT_NOW) } returns 111111111
        every { getIntProperty(BATTERY_PROPERTY_CURRENT_AVERAGE) } returns 222222222
        every { getIntProperty(BATTERY_PROPERTY_CHARGE_COUNTER) } returns DATA_NOT_AVAILABLE
    }
    //ACT
    val batteryCurrent = factory.getBatteryCurrent()
    //ASSERT
    batteryCurrent?.apply {
        assertEquals(Ampere(111.111111), actualCurrent)
        assertEquals(Ampere(222.222222), averageCurrent)
        assertNull(actualCharge)
    }
}
rys. P2.5

Refaktoryzacja testów

Po dodaniu kilku testów, możesz zauważyć że niektóre bloki kodu powtarzają się. Jeśli wyekstraktujesz je do osobnych metod lub obiektów to poprawisz czytelność testów i przyspieszysz pisanie kolejnych.

W powyższym przykładzie w każdym teście powtarza się blok ARRANGE:

        //ARRANGE
        batteryManager.apply {
            every { getIntProperty(BATTERY_PROPERTY_CURRENT_NOW) } returns 111111111
            every { getIntProperty(BATTERY_PROPERTY_CURRENT_AVERAGE) } returns 222222222
            every { getIntProperty(BATTERY_PROPERTY_CHARGE_COUNTER) } returns DATA_NOT_AVAILABLE
        }

Wyekstraktuj go do osobnej metody z parametrami:

    private fun mockBatteryProperties(
        currentNowValueMicro: Int,
        currentAverageValueMicro: Int,
        chargeCounterValueMicro: Int
    ) {
        batteryManager.apply {
            every { getIntProperty(BATTERY_PROPERTY_CURRENT_NOW) } returns currentNowValueMicro
            every { getIntProperty(BATTERY_PROPERTY_CURRENT_AVERAGE) } returns currentAverageValueMicro
            every { getIntProperty(BATTERY_PROPERTY_CHARGE_COUNTER) } returns chargeCounterValueMicro
        }
    }

Wyekstraktuj także blok assert, ponieważ w każdym teście jest podobny:

    private fun assertBatteryCurrent(
        batteryCurrent: BatteryCurrent?,
        expectedActualCurrent: Double?,
        expectedAverageCurrent: Double?,
        expectedActualCharge: Double?
    ) {
        assertNotNull(batteryCurrent)
        batteryCurrent?.apply {
            if (expectedActualCurrent != null)
                assertEquals(Ampere(expectedActualCurrent), actualCurrent)
            else
                assertNull(actualCurrent)

            if (expectedAverageCurrent != null)
                assertEquals(Ampere(expectedAverageCurrent), averageCurrent)
            else
                assertNull(averageCurrent)

            if (expectedActualCharge != null)
                assertEquals(AmpereHour(expectedActualCharge), actualCharge)
            else
                assertNull(actualCharge)
        }
    }

Dzięki tym zabiegom kod zyskał na czytelności i jego objętość znacznie się zmniejszyła. Każdy test ma tylko 3 linie kodu:

   @Test
    fun `when all data is unavailable then return null`() {
        //ARRANGE
        mockBatteryProperties(DATA_NOT_AVAILABLE, DATA_NOT_AVAILABLE, DATA_NOT_AVAILABLE)
        //ACT
        val batteryCurrent = factory.getBatteryCurrent()
        //ASSERT
        assertNull(batteryCurrent)
    }


    @Test
    fun `should return null actualCurrent when is not available`() {
        //ARRANGE
        mockBatteryProperties(DATA_NOT_AVAILABLE, 222222222, 333333333)
        //ACT
        val batteryCurrent = factory.getBatteryCurrent()
        //ASSERT
        assertBatteryCurrent(batteryCurrent, null, 222.222222, 333.333333)
    }

    @Test
    fun `should return null averageCurrent when is not available`() {
        //ARRANGE
        mockBatteryProperties(111111111, DATA_NOT_AVAILABLE, 333333333)
        //ACT
        val batteryCurrent = factory.getBatteryCurrent()
        //ASSERT
        assertBatteryCurrent(batteryCurrent, 111.111111, null, 333.333333)

    }

    @Test
    fun `should return null actualCharge when is not available`() {
        //ARRANGE
        mockBatteryProperties(111111111, 222222222, DATA_NOT_AVAILABLE)
        //ACT
        val batteryCurrent = factory.getBatteryCurrent()
        //ASSERT
        assertBatteryCurrent(batteryCurrent, 111.111111, 222.222222, null)
    }

Test 5: Wszystkie wartości są dostępne

Jeśli wszystkie dane są dostępne to getBatteryCurrent zwróciBatteryCurrent ze wszystkimi danymi

    @Test
    fun `factory should deliver battery current in amperes`() {
        //ARRANGE
        mockBatteryProperties(111111111, 222222222, 333333333)
        //ACT
        val batteryCurrent = factory.getBatteryCurrent()
        //ASSERT
        assertBatteryCurrent(batteryCurrent, 111.111111, 222.222222, 333.333333)
    }
rys. P2.6

Podsumowanie przykładu

Biblioteki do mockowania niesamowicie upraszczają testowanie zewnętrznego kodu, który nie posiada interfejsów. Trzeba jednak rozsądnie ich używać, ponieważ możesz dojść do sytuacji, w której klasy z logiką biznesową będą zawierały referencje do klas z Android SDK i tym samym naruszały zasadę DIP (Dependency Inversion Principle). Stosuj biblioteki do mockowania tam gdzie rzeczywiście nie da się zrobić własnego mocka ze względu na brak interfejsów w API.

Repozytorium

Kod źródłowy wraz z testami znajdziesz na repozytorium git na GitHub: https://github.com/androidCoder-pl/android-basicUnitTest-part1

Bibliografia

[1] http://blog.cleancoder.com/
[2] https://cleancoders.com/videos?series=clean-code&subseries=advanced-tdd
[3] Test Driven Devlopment: By Example
[4] https://developer.android.com/training/testing/fundamentals
[5] https://developer.android.com/training/testing/unit-testing

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

Mateusz Chrustny

2 thoughts on “Testy jednostkowe w Androidzie cz 1. – JUnit i MockK

  1. Dziękuję Ci za komentarz 😉 Jakbyś miał jakieś pytania dotyczące testowania to śmiało możesz pisać. Niedługo pojawią się następne artykuły zgłębiające temat pisania testów jednostkowych. Postaram się nie zawieść. Jeśli chciałbyś o czymś konkretnym przeczytać na blogu, to chętnie posłucham opinii.

Skomentuj Mateusz Chrustny Anuluj pisanie odpowiedzi

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