JUnit

Testowanie

  |   17 min czytania
(JUnit v4.12)

Integracja

Android Studio ułatwia proces testowania dzięki integracji z bibliotekami wspomagającymi testowanie oraz dostarczeniu dedykowanych narzędzi. Dodając odpowiednie zależności AndroidX do projektu już za pomocą kilku kliknięć można wykorzystać możliwości takich bibliotek jaki: JUnit, Mockito, Espresso, Robolectric czy UI Automator. Lokalne testy jednostkowe uruchamiane na maszynie lokalnej JVM znajdują się w lokalizacji: moduleName/src/test/java/ natomiast instrumentalne testy jednostkowe przeznaczone do uruchamiania na urządzeniu lub emulatorze znajdują się w: moduleName/src/androidTest/java/. W przypadku testów instrumentalnych przeznaczonych dla różnych wariantów budowania aplikacji ścieżka zmienia się na: moduleName/src/androidTestBuildVariantName/java/.

Rysunek struktury folderu

Tworzenie i uruchamianie

Testy jednostkowe można tworzyć dodając klasy do odpowiednich folderów lub za pomocą skrótu Ctrl+Shift+T kierując kursor na klasę lub metodę. Uruchamianie i analiza następuje z poziomu narzędzia GUI lub konsoli w Android Studio, które umożliwia m.in. eksport wyników testów, przeglądanie statystyk oraz pokrycie kodu.

Rysunek pokrycia kodu

Motywacja

Podstawą procesu testowania jest tworzenie i wykonywanie testów jednostkowych, które odpowiednio napisane w łatwy i wiarygodny sposób weryfikują poprawności logiki jednostki testowanej. Wykonywanie testów jednostkowych przy każdym przyroście ułatwia szybkie wyłapanie błędu. Należy jednak pamiętać o izolacji testowanej jednostki od pozostałych zależności. JUnit w Android jest wykorzystywany przede wszystkim do pisania lokalnych testów jednostkowych lub prostych testów instrumentalnych dla których można dostarczyć zależności (własne lub przy pomocy biblioteki np. Mockito). W pozostałych sytuacjach należy wykorzystać bibliotekę dostarczają zależności środowiska uruchomieniowego np. Robolectric. Tworzenie asercji może zostać usprawnione przez wykorzystanie Hamcrest. Alternatywą dla implementacji JUnit dla Android jest wykorzystanie biblioteki Truth we współpracy z asercjami Android.

Dobre praktyki

Tworząc metody testowe należy przede wszystkim pamiętać o wykluczeniu wszelkich zależności w taki sposób, aby na wynik testu testowanej jednostki nie miały wpływu inne zależności. Klasy testowe powinny znajdować się w pakiecie o tej samej nazwie co klasy implementacji, a nazwy klas testowych powinny być podobne do klas testowanych. Metody opisowe powinny być nazywane w sposób opisowy i jednoznaczny w nawiązaniu do celu testu nawet jeśli z tego powodu nazwa metody jest długa. W tym celu można posłużyć się konwencją Given/When/Then, gdzie Given określa warunki początkowe, When opisuje akcje, a Then informuje o oczekiwanym rezultacie. Ponadto należy dążyć do minimalizacji asercji, czasu wykonywania testów oraz zwiększać pokrycie kodu. Testy powinny być krótkie, proste i ściśle dotyczyć jednej jednostki. Jeśli sytuacja tego wymaga należy wykorzystywać metody cyklu życia testów, aby zapewnić odpowiednią inicjalizację i czyszczenie środowiska.

Przykład
Klasa Game zarządza rozgrywką w grze komputerowej w której dwie przeciwne drużyny walczą o zwycięstwo zdobywając bramki. W odniesieniu do implementacji klasy Game zostaną przeprowadzone testy przedstawiające dobre praktyki oraz charakterystykę testów jednostkowych JUnit4 dla Android.

class Game {

    private val redTeam = mutableListOf<String>()
    private val blueTeam = mutableListOf<String>()
    private var redGoals = 0
    private var blueGoals = 0
    private var gameStarted = false

    fun start() {
        if(!gameStarted && teamsSizesEquals()) {
            gameStarted = true
            redGoals = 0
            blueGoals = 0
            //do and allow for some actions
        }
    }

    fun stop() {
        if(gameStarted) {
            gameStarted = false
            //do some other actions
        }
    }

    fun clearTeams() {
        redTeam.clear()
        blueTeam.clear()
    }

    fun addPlayer(team: Team, player: String): Boolean {
        if(allowToAddPlayer(player)) {
            when(team) {
                Team.RED -> { redTeam.add(player) }
                Team.BLUE -> { blueTeam.add(player) }
            }
            return true
        }
        else return false
    }

    fun removePlayer(player: String): Boolean {
        if(allowToRemovePlayer(player)) {
            if(redTeam.contains(player)) redTeam.remove(player)
            else blueTeam.remove(player)
            return true
        }
        else return false
    }

    fun goal(team: Team) {
        when(team) {
            Team.RED -> { redGoals++ }
            Team.BLUE -> { blueGoals++ }
        }
    }

    fun getScore(): String {
        return "RED $redGoals:$blueGoals BLUE"
    }

    fun getRedTeam(): List<String> {
        return redTeam
    }

    fun getBlueTeam(): List<String> {
        return blueTeam
    }

    fun hasStarted(): Boolean {
        return gameStarted
    }

    private fun allowToAddPlayer(player: String): Boolean {
        return (!gameStarted && !redTeam.contains(player) && !blueTeam.contains(player))
    }

    private fun allowToRemovePlayer(player: String): Boolean {
        return (!gameStarted && (redTeam.contains(player) || blueTeam.contains(player)))
    }

    private fun teamsSizesEquals(): Boolean {
        return redTeam.size != 0 && blueTeam.size != 0 && redTeam.size.equals(blueTeam.size)
    }

    enum class Team {
        RED, BLUE;
    }
}

Klasa testowa

Testy w JUnit (metody) zawierają się w klasie testowej, które z kolei mogą być częścią zestawu klas testowych. Aby klasa była klasą testową w JUnit4 musi zawierać deklarację przynajmniej jednej metody testowej oznaczonej adnotacją @Test. Podstawowym elementem testów są asercje sprawdzające wartość logiczną, równość wartości czy referencji.

class SmallTest {

    @Test
    fun startGameWithDifferentTeamsSize() {
        val game = Game()
        game.addPlayer(Game.Team.RED, "Johnnie")
        game.addPlayer(Game.Team.BLUE, "Jack")
        game.addPlayer(Game.Team.BLUE, "Jim")
        game.start()
        assertFalse(game.hasStarted())
    }
}

Cykl życia

Klasy testowe poza metodami testowymi mogą składać się także z metod inicjalizacyjnych i końcowych. Metoda oznaczona adnotacją @Before wykonywana jest przed każdym testem i służy przygotowaniu środowiska testowego, natomiast z adnotacją @After po każdym teście co wykorzystywane jest do czyszczenia środowiska. W analogiczny sposób działają metody oznaczone jako @BeforeClass oraz @AfterClass, które wykonują się kolejno przed i po uruchomieniu wszystkich testów. Jeśli metoda testowa ma zostać wyłączona z testów należy użyć adnotacji @Ignore. Adnotacja @Test może zostać wzbogacona o maksymalny czas wykonania (timeout) lub oczekiwany typ wyjątku.

class FullTest {
    
    //mock some dependencies
    val game = Game()
    
    @Before
    fun init() {
        //clear game before every test
        game.clearTeams()
    }

    @After
    fun uninit() {
        //clear game before every test
        game.stop()
    }
    //use @BeforeClass, @AfterClass in the same way

    @Ignore //ignore this test temporary
    fun startGameWithDifferentTeamsSize() {
        game.addPlayer(Game.Team.RED, "Johnnie")
        game.addPlayer(Game.Team.BLUE, "Jack")
        game.addPlayer(Game.Team.BLUE, "Jim")
        game.start()
        assertFalse(game.hasStarted())
    }

    @Test(timeout = 1000) //after 1000ms when test couldn't finished than just failed
    fun startAndStopGame() {
        game.addPlayer(Game.Team.RED, "Johnnie")
        game.addPlayer(Game.Team.RED, "William")
        game.addPlayer(Game.Team.BLUE, "Jack")
        game.addPlayer(Game.Team.BLUE, "Jim")
        game.start()
        assertTrue(game.hasStarted())
        game.stop()
        assertFalse(game.hasStarted())
    }

    @Test
    fun modifyTeamsAndStartGame() {
        game.addPlayer(Game.Team.RED, "Johnnie")
        game.addPlayer(Game.Team.RED, "William")
        game.addPlayer(Game.Team.BLUE, "Jack")
        game.start()
        assertFalse(game.hasStarted())
        game.removePlayer("William")
        game.start()
        assertTrue(game.hasStarted())
    }
}

Parametry

Klasa testowa może posiadać także jedną metodę generującą zestawy danych dla metod testowych. Klasa ta musi być oznaczona adnotacją @RunWith(Parameterized.class) natomiast metoda statyczna zwracająca kolekcje danych oznaczone jako @Parameters. Właściwości mogą przyjmować wstrzykniętą wartość za pomocą konstruktora lub być zdefiniowane w ciele klasy i oznaczone jako @Parameter(number).

@RunWith(Parameterized::class)
class ParameterizedTest(val redGoals: Int, val blueGoals: Int, val scores: String) {

    companion object {
        val game = Game()

        @BeforeClass @JvmStatic
        fun initTeams() {
            game.addPlayer(Game.Team.RED, "Johnnie")
            game.addPlayer(Game.Team.RED, "William")
            game.addPlayer(Game.Team.BLUE, "Jack")
            game.addPlayer(Game.Team.BLUE, "Jim")
        }

        @AfterClass @JvmStatic
        fun uninitTeams() {
            game.clearTeams()
        }

        //create test data
        @Parameters @JvmStatic
        fun createData(): Collection<Array<Any>> {
            return listOf(
                arrayOf(0, 2, "RED 0:2 BLUE"),
                arrayOf(10, 5, "RED 10:5 BLUE"),
                arrayOf(3, 3, "RED 3:3 BLUE"))
        }
    }

    //use auto parameterized test method
    @Test
    fun scoreAndCheckResultWithParameterized() {
        game.start()
        repeat(redGoals) { game.goal(Game.Team.RED) }
        repeat(blueGoals) { game.goal(Game.Team.BLUE) }
        assertEquals(scores, game.getScore())
        game.stop()
    }

    //instead of manual parameterized
    @Test
    fun scoreAndCheckResultNormal() {
        //first test
        game.start()
        repeat(2) { game.goal(Game.Team.BLUE) }
        assertEquals("RED 0:2 BLUE", game.getScore())
        game.stop()

        //second test
        game.start()
        repeat(10) { game.goal(Game.Team.RED) }
        repeat(5) { game.goal(Game.Team.BLUE) }
        assertEquals("RED 10:5 BLUE", game.getScore())
        game.stop()

        //third test
        game.start()
        repeat(3) { game.goal(Game.Team.RED) }
        repeat(3) { game.goal(Game.Team.BLUE) }
        assertEquals("RED 3:3 BLUE", game.getScore())
        game.stop()
    }
}

Zasady

JUnit umożliwia dodawanie zachowania do każdego testu za pomocą adnotacji @Rule oraz tworzenie nowych zasad. Aby stworzyć własną zasadę należy w klasie zasady implementować interfejs TestRule.

class RuleTest {

    @Rule @JvmField
    val rule = PrintTestRule() //this rule print message before and after every test

    val game = Game()

    @Test
    fun addPlayersWithSameNameToTheSameTeam() {
        game.addPlayer(Game.Team.RED, "Johnnie")
        game.addPlayer(Game.Team.RED, "William")
        game.addPlayer(Game.Team.RED, "Jack")
        game.addPlayer(Game.Team.RED, "William")
        assertEquals(3, game.getRedTeam().size)
    }

    @Test
    fun addPlayersWithSameNameToTheAnotherTeam() {
        game.addPlayer(Game.Team.RED, "Johnnie")
        game.addPlayer(Game.Team.RED, "William")
        game.addPlayer(Game.Team.BLUE, "Jack")
        game.addPlayer(Game.Team.BLUE, "William")
        assertEquals(1, game.getBlueTeam().size)
    }
}

class PrintTestRule : TestRule {

    private lateinit var base: Statement
    private lateinit var description: Description

    override fun apply(base: Statement, description: Description): Statement {
        this.base = base
        this.description = description
        return PrintTestStatement(base)
    }

    class PrintTestStatement(private val base: Statement) : Statement() {
        override fun evaluate() {
            println("Log before test action")
            base.evaluate()
            println("Log after testaction")
        }
    }
}

AndroidX Test zawiera zestaw gotowych zasad dla JUnit, które zwiększają elastyczność, redukują powtarzający się kod oraz wspomagają testowanie komponentów Android. Wykorzystywane są przede wszystkim testach UI przy użyciu Espresso. ActivityTestRule dostarcza do klasy testowej żądanej Aktywności (Activity), która jest dostępna w całym cyklu życia klasy testowej, ServiceTestRule dostarcza Usługę (Service) natomiast IntentsTestRule dostarcza Intencję (Intent).

Przykład
Aktywność GameActivity umożliwia użytkownikowi prowadzenie rozgrywki opierając się na klasie Game co może wprowadzać potrzebę przetestowania klasy Game z poziomu aplikacji. W tym celu należy stworzyć testy instrumentalne (w pakieckie androidTest) wykonywane przez AndroidJUnit4 i wykorzystać ActivityTestRule (lub bibliotekę Robolectric).

@RunWith(AndroidJUnit4::class)
class InstrumentedTest {

    @Rule @JvmField
    val activityRule = ActivityTestRule(GameActivity::class.java)

    //use only context
    @Test
    fun checkStringResourceFromContext() {
        val appContext = InstrumentationRegistry.getTargetContext()
        assertEquals("Game", appContext.getString(R.string.app_name))
    }

    //use activity from rule
    @Test
    fun startAndStopGameFromActivity() {
        activityRule.activity.initDefaultGame()
        activityRule.activity.startGame()
        assertTrue(activityRule.activity.isGameRunning())
        activityRule.activity.stopGame()
        assertFalse(activityRule.activity.isGameRunning())
    }
}

class GameActivity : AppCompatActivity() {

    private val game = Game()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_game)
        //init view
    }

    //imit real actions flow in one function
    fun startGame() {
        game.start()
    }

    fun stopGame() {
        game.stop()
    }

    fun initDefaultGame() {
        game.addPlayer(Game.Team.RED, "Johnnie")
        game.addPlayer(Game.Team.RED, "William")
        game.addPlayer(Game.Team.BLUE, "Jack")
        game.addPlayer(Game.Team.BLUE, "Jim")
    }

    fun isGameRunning(): Boolean {
        return game.hasStarted()
    }
}

Filtry

AndroidJUnitRunner umożliwia stosowanie adnotacji dla testów instrumentalnych w celach restrykcyjnych. Adnotacja @RequiresDevice mówi, że test powinien zostać przeprowadzony tylko na urządzeniu fizycznym, @SdkSupress określa minimalne API natomiast @SmallTest, @MediumTest i @LargeTest informują o wielkości testu co przekłada się na czas jego wykonania.

@RunWith(AndroidJUnit4::class)
class FilterInstrumentedTest {

    @MediumTest @SdkSuppress(minSdkVersion = 21) @RequiresDevice
    @Test //runs only on devices with min 21 API
    fun checkAppTitle() { 
        val appContext = InstrumentationRegistry.getTargetContext()
        assertEquals("Game", appContext.getString(R.string.app_name))
    }

    @SmallTest @SdkSuppress(maxSdkVersion = 20)
    @Test //runs on devices and emulator with max 20 API
    fun checkOldAppTitle() {
        val appContext = InstrumentationRegistry.getTargetContext()
        assertEquals("Game Old", appContext.getString(R.string.app_name))
    }
}