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/
.
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.
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
KlasaGame
zarządza rozgrywką w grze komputerowej w której dwie przeciwne drużyny walczą o zwycięstwo zdobywając bramki. W odniesieniu do implementacji klasyGame
zostaną przeprowadzone testy przedstawiające dobre praktyki oraz charakterystykę testów jednostkowychJUnit4
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 klasieGame
co może wprowadzać potrzebę przetestowania klasyGame
z poziomu aplikacji. W tym celu należy stworzyć testy instrumentalne (w pakieckieandroidTest
) wykonywane przezAndroidJUnit4
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))
}
}