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.