Tests d'Integració (UI) amb Espresso¶
Els tests d'integració o tests instrumentats proven la interfície d'usuari real de l'aplicació. S'executen en un dispositiu o emulador Android, interactuant amb els elements de la pantalla com ho faria un usuari.
1. Què són els tests instrumentats?¶
A diferència dels tests unitaris (que s'executen a la JVM local), els tests instrumentats:
- S'executen en un dispositiu real o emulador.
- Poden interactuar amb la interfície d'usuari (clicar botons, escriure text, etc.).
- Comproven que la pantalla mostra el que esperem.
-
Són més lents que els unitaris, però proven el comportament real de l'app.
-
On van: carpeta
app/src/androidTest/java/ - S'executen a: dispositiu o emulador Android
- Velocitat: lents (segons)
- Què proven: interacció amb la UI, navegació entre pantalles
2. Espresso: conceptes bàsics¶
Espresso és el framework de Google per escriure tests d'UI en Android. Funciona amb tres conceptes:
| Concepte | Què fa | Exemple |
|---|---|---|
| ViewMatchers | Trobar un element a la pantalla | withId(R.id.btnCalcular) |
| ViewActions | Fer una acció sobre l'element | click(), typeText("hola") |
| ViewAssertions | Comprovar l'estat de l'element | matches(isDisplayed()) |
El patró bàsic d'Espresso és:
onView(ViewMatcher) // Trobar l'element
.perform(ViewAction) // Fer una acció (opcional)
.check(ViewAssertion) // Comprovar el resultat
3. Trobar elements (ViewMatchers)¶
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.matcher.ViewMatchers.*
// Per ID del recurs
onView(withId(R.id.etNom))
// Per text visible
onView(withText("Calcular"))
// Per hint (placeholder)
onView(withHint("Escriu el teu nom"))
4. Fer accions (ViewActions)¶
import androidx.test.espresso.action.ViewActions.*
// Escriure text en un EditText
onView(withId(R.id.etNombre1)).perform(typeText("42"))
// Fer clic a un botó
onView(withId(R.id.btnCalcular)).perform(click())
// Esborrar el text d'un camp
onView(withId(R.id.etNombre1)).perform(clearText())
// Tancar el teclat (important després de typeText!)
onView(withId(R.id.etNombre1)).perform(typeText("42"), closeSoftKeyboard())
Tanca el teclat!
Després d'escriure text amb typeText(), el teclat virtual queda obert i pot tapar altres elements. Afegeix sempre closeSoftKeyboard() per evitar problemes:
.perform(typeText("text"), closeSoftKeyboard())
5. Fer comprovacions (ViewAssertions)¶
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
// Comprovar que l'element és visible
onView(withId(R.id.tvResultat)).check(matches(isDisplayed()))
// Comprovar que mostra un text concret
onView(withId(R.id.tvResultat)).check(matches(withText("Resultat: 15")))
// Comprovar que NO és visible
onView(withId(R.id.tvError)).check(matches(not(isDisplayed())))
Import de not()
Per usar not() en les comprovacions, cal importar:
import org.hamcrest.Matchers.not
6. Exemple pràctic: testejar una calculadora¶
Suposem que tenim una Activity amb una calculadora senzilla que suma dos nombres:
El layout (activity_calculadora.xml)¶
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<EditText
android:id="@+id/etNombre1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Nombre 1"
android:inputType="numberDecimal" />
<EditText
android:id="@+id/etNombre2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Nombre 2"
android:inputType="numberDecimal" />
<Button
android:id="@+id/btnSumar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Sumar" />
<TextView
android:id="@+id/tvResultat"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="24sp" />
<TextView
android:id="@+id/tvError"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#FF0000"
android:visibility="gone" />
</LinearLayout>
El ViewModel (CalculadoraViewModel.kt)¶
La lògica de negoci viu al ViewModel (el mateix que hem testat amb tests unitaris):
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class CalculadoraViewModel : ViewModel() {
private val _resultat = MutableLiveData<String>()
val resultat: LiveData<String> = _resultat
private val _error = MutableLiveData<String?>()
val error: LiveData<String?> = _error
fun sumar(text1: String, text2: String) {
if (text1.isBlank() || text2.isBlank()) {
_error.value = "Has d'omplir els dos camps"
_resultat.value = ""
return
}
val n1: Double? = text1.toDoubleOrNull()
val n2: Double? = text2.toDoubleOrNull()
if (n1 == null || n2 == null) {
_error.value = "Introdueix nombres vàlids"
_resultat.value = ""
return
}
_error.value = null
_resultat.value = "Resultat: ${n1 + n2}"
}
}
L'Activity (CalculadoraActivity.kt)¶
L'Activity només s'encarrega de la UI: recull les dades, crida el ViewModel i observa els resultats:
import androidx.activity.viewModels
class CalculadoraActivity : AppCompatActivity() {
private val viewModel: CalculadoraViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_calculadora)
val etNombre1 = findViewById<EditText>(R.id.etNombre1)
val etNombre2 = findViewById<EditText>(R.id.etNombre2)
val btnSumar = findViewById<Button>(R.id.btnSumar)
val tvResultat = findViewById<TextView>(R.id.tvResultat)
val tvError = findViewById<TextView>(R.id.tvError)
btnSumar.setOnClickListener {
val text1 = etNombre1.text.toString()
val text2 = etNombre2.text.toString()
viewModel.sumar(text1, text2)
}
viewModel.resultat.observe(this) { text ->
tvResultat.text = text
}
viewModel.error.observe(this) { missatge ->
if (missatge != null) {
tvError.visibility = View.VISIBLE
tvError.text = missatge
} else {
tvError.visibility = View.GONE
}
}
}
}
Separació de responsabilitats
- ViewModel: conté tota la lògica (validacions, càlculs). Es pot testejar amb tests unitaris ràpids.
- Activity: només connecta la UI amb el ViewModel (observers + click listeners). Es testeja amb tests d'UI (Espresso).
Els tests d'UI¶
Fitxer app/src/androidTest/java/com/example/app/CalculadoraActivityTest.kt:
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.hamcrest.Matchers.not
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CalculadoraActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(CalculadoraActivity::class.java)
@Test
fun sumar_dosNombresValids_mostraResultat() {
// Escriure els nombres
onView(withId(R.id.etNombre1))
.perform(typeText("10"), closeSoftKeyboard())
onView(withId(R.id.etNombre2))
.perform(typeText("5"), closeSoftKeyboard())
// Clicar el botó
onView(withId(R.id.btnSumar)).perform(click())
// Verificar el resultat
onView(withId(R.id.tvResultat))
.check(matches(withText("Resultat: 15.0")))
}
@Test
fun sumar_ambCampsBuits_mostraError() {
// No escrivim res, directament cliquem
onView(withId(R.id.btnSumar)).perform(click())
// Verificar que es mostra l'error
onView(withId(R.id.tvError))
.check(matches(isDisplayed()))
onView(withId(R.id.tvError))
.check(matches(withText("Has d'omplir els dos camps")))
}
@Test
fun sumar_ambTextInvalid_mostraError() {
onView(withId(R.id.etNombre1))
.perform(typeText("abc"), closeSoftKeyboard())
onView(withId(R.id.etNombre2))
.perform(typeText("5"), closeSoftKeyboard())
onView(withId(R.id.btnSumar)).perform(click())
onView(withId(R.id.tvError))
.check(matches(isDisplayed()))
onView(withId(R.id.tvError))
.check(matches(withText("Introdueix nombres vàlids")))
}
@Test
fun sumar_despresError_amagaError() {
// Primer provoquem un error
onView(withId(R.id.btnSumar)).perform(click())
onView(withId(R.id.tvError)).check(matches(isDisplayed()))
// Després fem una suma vàlida
onView(withId(R.id.etNombre1))
.perform(typeText("3"), closeSoftKeyboard())
onView(withId(R.id.etNombre2))
.perform(typeText("7"), closeSoftKeyboard())
onView(withId(R.id.btnSumar)).perform(click())
// L'error ha de desaparèixer
onView(withId(R.id.tvError))
.check(matches(not(isDisplayed())))
onView(withId(R.id.tvResultat))
.check(matches(withText("Resultat: 10.0")))
}
@Test
fun elementsInicials_sónVisibles() {
onView(withId(R.id.etNombre1)).check(matches(isDisplayed()))
onView(withId(R.id.etNombre2)).check(matches(isDisplayed()))
onView(withId(R.id.btnSumar)).check(matches(isDisplayed()))
}
}
7. ActivityScenarioRule¶
L'anotació @get:Rule amb ActivityScenarioRule s'encarrega de:
- Llançar l'Activity abans de cada test.
- Tancar l'Activity després de cada test.
- Gestionar el cicle de vida correctament.
@get:Rule
val activityRule = ActivityScenarioRule(CalculadoraActivity::class.java)
Diferència entre @Rule i @get:Rule
En Kotlin, cal usar @get:Rule (no @Rule) perquè l'anotació s'apliqui al getter de la propietat, que és el que JUnit espera.
8. Executar tests instrumentats des d'Android Studio¶
Requisits previs¶
- Tenir un emulador configurat o un dispositiu connectat.
- L'emulador/dispositiu ha d'estar encès i funcionant.
Des de l'editor¶
- Fes clic a la fletxa verda al costat del nom del test o de la classe.
- Selecciona Run 'NomDelTest'.
Des del terminal¶
./gradlew connectedAndroidTest
Els tests instrumentats són lents
Com que s'executen en un dispositiu real o emulador, tarden molt més que els unitaris. Per això, reserva els tests d'UI per provar la interfície i usa tests unitaris per a la lògica de negoci.
9. Resum d'imports¶
Aquí tens tots els imports que necessitaràs normalment en un test d'Espresso:
// Espresso
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
// JUnit i AndroidJUnit4
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
// Matchers addicionals
import org.hamcrest.Matchers.not