Testy jednostkowe w Androidzie cz. 2 – jak działają testy JUnit.

Wstęp

W poprzednim artykule pokazałem jak szybko skonfigurować środowisko i napisać test. Jestem zwolennikiem nauki od ogółu do szczegółu, dlatego w tym artykule wytłumaczę Ci mechanikę działania testów jednostkowych z wykorzystaniem JUnit.

JUnit jak i wiele innych frameworków do testowania bazuje na xUnit, które zostało opracowane przez Kenta Becka w 1998r.. (Czyli ponad 20 lat temu!) W książce „Test Driven Development: By Example” (polski tytuł: „TDD Sztuka tworzenia dobrego kodu”) Kent Beck na 200 stronach książki przedstawił technikę TDD oraz proces budowy xUnit. Bardzo polecam książkę, jeśli chcesz zagłębić się w podstawowe mechanizmy stojące za testowaniem kodu lub chcesz nauczyć się TDD.

Czego się nauczysz z tego artykułu?

  • Zrozumienia jak działają testy jednostkowe
  • Kiedy używać adnotacji @BeforeClass, @Before, @Test, @After, @AfterClass.

Czym jest test w JUnit?

Testem w JUnit jest funkcja, która sprawdza poprawność działania programu. Jeśli podczas jej działania wystąpi AssertionError lub jakiś inny wyjątek, który nie jest przewidziany test zostaje przerwany. Jeśli funkcja nie zostanie przerwana to test jest traktowany jako zakończony sukcesem.

Klasy z testami muszą być uruchamiane przez specjalne klasy uruchomieniowe typu Runner. Dzięki nim nie trzeba samodzielnie zarządzać kolejnością wywoływania konkretnych metod, pilnowaniem ich niezależności, generowaniem wyników i raportów.

W testach wykorzystuje się adnotacje @BeforeClass, @Before, @Test, @After, @AfterClass, które pomagają runnerowi wywołać metody w odpowiedniej kolejności i rozróżnić zwykłe metody od testów.

Adnotacja @Test

Aby JUnit wykrył, że metoda jest testem a nie zwykłą metodą to musi być oznaczona adnotacją @Test.

class Independent{
    @Test
    fun test1(){

    }
}

Najistotniejszą właściwością JUnit jest to, że każda metoda oznaczona jako @Test jest wykonywana na osobnej instancji klasy – co powoduje, że wszystkie metody wykonują się niezależnie od siebie – nie współdzielą ze sobą zmiennych klasy.

Pokażę to na przykładzie:

class Independent{

    private var variable = 0

    @Test
    fun test1(){
        println("test1 variable : $variable")
        println("test1 object hash: ${hashCode()}")
        variable = 1
    }

    @Test
    fun test2(){
        println("test2 variable : $variable")
        println("test2 object hash: ${hashCode()}")
        variable = 2
    }
}

Jeśli testy wykonywałby się na jednej instancji klasy Independent, to test1 zmieniłby wartość zmiennej variable na 1, więc test2 miałby dostęp do zmienionej już wartości zmiennej variable na 1. Oba testy wyświetliłby taki sam hashCode, ponieważ operowałyby na tym samym obiekcie, więc logi testów byłyby takie:

test1 variable : 0
test1 object hash: 2121744517
test2 variable : 1
test2 object hash: 2121744517

Na szczęście tak nie jest – JUnit wywołuje każdy test na osobnej instancji. Metoda test1 oraz test2 są wywoływane na osobnych obiektach dlatego każdy test operuje na świeżej wartości variable = 0 oraz hashCode w każdym teście jest inny. Logi testów uruchomionych w JUnit są takie:

test1 variable : 0
test1 object hash: 2121744517
test2 variable : 0
test2 object hash: 476402209

Pobierz przykład z repozytorium i pobaw się nim. Zobacz jak działa, dodaj więcej testów, zmiennych i logów.

Adnotacje @Before i @After

Metoda oznaczona adnotacją @Before służy do wstępnego skonfigurowania wszystkich testów w klasie (stworzenie obiektów, ustawienie wartości itp).

Metoda oznaczona adnotacją @After służy do wyczyszczenia zasobów po wykonaniu testu.

Poniższy przykład obrazuje zasadę działania tych dwóch adnotacji:

class Independent{

    private var variable = 0


    @Before
    fun setUp() {
        println("----@Before----")
        variable = -1
    }

    @After
    fun tearDown() {
        println("@After variable : $variable")
    }

    @Test
    fun test1(){
        println("test1 variable : $variable")
        println("test1 object hash: ${hashCode()}")
        variable = 1
    }

    @Test
    fun test2(){
        println("test2 variable : $variable")
        println("test2 object hash: ${hashCode()}")
        variable = 2
    }
}

Logi z wykonania testów:

----@Before----
test1 variable : -1
test1 object hash: 183264084
@After variable : 1
----@Before----
test2 variable : -1
test2 object hash: 358699161
@After variable : 2

Mimo, że zmienna variable jest domyślnie ustawiona na 0 to metoda @Before przed każdym testem ustawia jej wartość na -1.

Każdy test, zaczyna się od wywołania metody oznaczonej @Before, następnie wykonuje się metoda oznaczona @Test a na końcu metoda oznaczona jako @After.

@Before -> @Test -> @After

Więcej o @After

Metody z adnotacją @After nie przydają się tak często jak metoda @Before. Są jednak miejsca, w których musimy jej użyć np. zamknięcie strumieni, usunięcie tymczasowych plików, zamknięcie połączenia z bazą danych etc.. Głównym jej celem jest usunięcie zasobów, które powodowałyby wycieki pamięci lub błędne działanie testów.

Na przykład aby przetestować klasę, która zapisuje licznik do pliku, test musi stworzyć pliki tymczasowe i sprawdzić czy klasa poprawnie operuje na nich.

class CounterFileCache(private val cacheDir: String) {
    companion object {
        private const val COUNTER_FILENAME = "counter_cache.txt"
    }

    fun saveCounter(counter: Int) {
        val bytes = ByteBuffer.allocate(4).putInt(counter).array()
        getFile()
                .writeBytes(bytes)
    }

    fun getCounter(): Int {
        val bytes = getFile().readBytes()
        return if (bytes.isNotEmpty())
            ByteBuffer.wrap(bytes).int
        else
            0
    }

    private fun getFile(): File {
        val file = File("$cacheDir$COUNTER_FILENAME")
        val directory = file.parentFile

        if (!directory.exists() && !directory.mkdirs()) {
            throw IllegalStateException("Couldn't create dir: $directory")
        }
        if (!file.exists() && !file.createNewFile()) {
            throw IllegalStateException("Couldn't create file: $file")
        }
        return file
    }
}
class TestCounterFileCache {

    companion object {
        private const val COUNTER_FILENAME = "counter_cache.txt"
    }

    private val cacheDir = "out/test/cache/"

    private val counterFileCache = CounterFileCache(cacheDir)

    @Test
    fun `counter should saved into file`() {
        //WHEN
        val expectedCounter = 12
        counterFileCache.saveCounter(expectedCounter)
        //THEN
        assertCounterFromFile(expectedCounter)
    }

    @Test
    fun `when counter saved then get counter should return the same number`() {
        //GIVEN
        val expectedCounter = 12
        //WHEN
        counterFileCache.saveCounter(expectedCounter)
        //THEN
        assertEquals(expectedCounter, counterFileCache.getCounter())
    }

    @Test
    fun `when counter not exist then return 0`() {
        assertEquals(0, counterFileCache.getCounter())
    }

    private fun assertCounterFromFile(expectedCounter: Int) {
        val file = File("$cacheDir$COUNTER_FILENAME")
        assert(file.exists())
        val counterFileReader = CounterFileCache(cacheDir)
        assertEquals(expectedCounter, counterFileReader.getCounter())
    }
}

Testy niestety nie przechodzą – nie z tego względu, że klasa ma błąd, tylko z powodu błędu popełnionego w testach.

W testach wartość counter jest zapisywana do pliku zaś w teście when counter not exist then return 0 jest ona odczytywana z pliku. Zakładamy niezależność każdego z testów i oczekujemy, że wykonując test plik nie będzie istniał. Jednak poprzednie testy stworzyły plik i nie usunęły go – wszystkie testy korzystają z tego samego miejsca zapisu pliku. Aby posprzątać po testach świetnie będzie pasowała metoda oznaczona @After, w której zostanie usunięty cały katalog z plikami stworzonymi w testach.

    @After
    fun tearDown() {
        File(cacheDir).deleteRecursively()
    }

Dzięki każdorazowemu usuwaniu całego katalogu, po wykonaniu każdego z testów, testy przechodzą za każdym razem.

Jest tutaj pewny haczyk, jeśli testy uruchamiałyby się równolegle to byłaby możliwa sytuacja w, której 2 testy jednocześnie korzystają z tego samego pliku. Dlatego dla bezpieczeństwa warto jest dodać do zmiennej cacheDir hash obiektu testowego lub znacznik czasowy:

private val cacheDir = "out/test/cache/${System.nanoTime()}/"
private val cacheDir = "out/test/cache/${hashCode()}/"

Wtedy mamy pewność, że inne testy nie będą korzystały z tych samych plików w jednym czasie.

Adnotacje @BeforeClass i @AfterClass

Tych metod jeszcze nie spotkałem w projektach, w których pracowałem. Czasami jest tak, że ze względów wydajnościowych warto aby wszystkie testy współdzieliły jakiś obiekt np. połączenie do bazy danych lub wczytanie danych testowych z dużego pliku. Jeśli wczytywanie trwa np. 1 sekundę to na 20 testach oszczędności sięgną aż 19 sekund.

Musisz bardzo rozważnie stosować tę adnotację, ponieważ możesz przez przypadek uzależnić od siebie testy. Dlatego najpierw stwórz test, który nie wykorzystuje współdzielonych obiektów, a potem jeśli wydajność jest niezadowalająca użyj @BeforeClass i @AfterClass.

W poniższym przykładzie możesz zobaczyć przykład, w którym warto zastosować @BeforeClass i @AfterClass

class LongRunningTest {

    private var testData: List<TestData>? = null
    @Before
    fun setUpClass() {
        testData = TestDataProvider().getData()
    }

    @After
    fun tearDownClass() {
        println("Clear data")
        testData = null
    }

    @Test
    fun test1() {
        println("test1")
        assertNotNull(testData)
    }

    @Test
    fun test2() {
        println("test2")
        assertNotNull(testData)
    }

    @Test
    fun test3() {
        println("test3")
        assertNotNull(testData)
    }

    @Test
    fun test4() {
        println("test4")
        assertNotNull(testData)
    }
}

class TestDataProvider {
    fun getData(): List<TestData> {
        println("Start loading test data...")
        Thread.sleep(4000)
        println("Test data loaded")
        return listOf(
                TestData(100, 123),
                TestData(100, 123)

        )
    }
}

data class TestData(val input: Int, val output: Int)

Wyobraź sobie, że TestDataProvider pobiera dane z bazy danych albo z jakiegoś zewnętrznego serwera i trwa to 4s. Powyższe 4 testy wykonują się ponad 16 sekund, ponieważ każdy test pobiera dane od nowa. Gdyby było takich testów 20 to ponad wykonywałyby się ponad minutę.

Jeśli użyjesz adnotacji @BeforeClass to wczytane dane z TestDataProvider zostaną tylko raz. Adnotacja @BeforeClass musi być użyta na statycznej metodzie.

class FixedLongRunningTest {

    companion object {
        private var testData: List<TestData>? = null
        @JvmStatic
        @BeforeClass
        fun setUpClass() {
            testData = TestDataProvider().getData()
        }

        @JvmStatic
        @AfterClass
        fun tearDownClass() {
            println("Clear data")
            testData = null
        }
    }

    @Test
    fun test1() {
        println("test1")
        assertNotNull(testData)
    }

    @Test
    fun test2() {
        println("test2")
        assertNotNull(testData)
    }

    @Test
    fun test3() {
        println("test3")
        assertNotNull(testData)
    }

    @Test
    fun test4() {
        println("test4")
        assertNotNull(testData)
    }
}

Kotlin nie ma statycznych metod, więc musisz stworzyć companion object z metodą setUpClass oznaczoną dwoma adnotacjami @JvmStatic i @BeforeClass. @JvmStatic informuje, że metoda znajdująca się w companion object będzie traktowana przez JVM jako metoda statyczna – nie zapomnij o tym, ponieważ testy nie zadziałają.

Dodanie adnotacji @BeforeClass przyniosło oczekiwane efekty – metoda wczytywania danych wykonuje się tylko raz. Wynik czasów może być jednak zaskakujący.

Mimo opóźnienia 4s przy wczytywaniu danych, testy wykonuje się tylko w 1ms. Jest to spowodowane tym, że JUnit nie liczy czasu wykonywana metody @BeforeClass i @AfterClass. Dodałem swój znacznik czasowy w kodzie i otrzymałem wynik 4029ms.

Repozytorium

Kod źródłowy powyższych przykładów znajdziesz tutaj: https://github.com/androidCoder-pl/android-basicUnitTest-part2

Podsumowanie

Podsumowując najważniejsze rzeczy:

  • Testy są wykonywane na osobnych instancjach klasy – nie współdzielą zmiennych klasy
  • Aby test był wykryty przez runnera musi posiadać adnotację @Test
  • Metoda oznaczona @Before jest wykonywana przed każdym testem i służy do inicjalizacji zmiennych i danych wejściowych wspólnych dla każdego testu.
  • Metoda oznaczona @After jest wykonywana po każdym teście i służy do zamknięcia strumieni, wyczyszczenia plików i zwolnienia pamięci.
  • Metoda oznaczona @BeforeClass jest wykonywana tylko raz przed wszystkimi testami znajdującymi się w jednej klasie i pozwala inicjalizować współdzielone przez testy zasoby.
  • Metoda oznaczona @AfterClass jest wykonywana tylko raz po wszystkich testach znajdującymi się w jednej klasie i służy do zamknięcia strumieni, wyczyszczenia plików i zwolnienia pamięci.

Nie są to wszystkie możliwości jakie daje JUnit, na tę chwilę jest to wystarczająca wiedza, dzięki której będziesz mógł zacząć pisać poprawne testy. Ponieważ wszystkie testy od jednostkowych do espresso bazuje na tym frameworku to wiedza na temat jego działania jest niezbędna.

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 *