Índice
- 1. Introducción
- 2. Tipos de pruebas
- 3. Espresso
- 4. Firebase Test Lab
- 5. Ejemplo
- 6. Conclusión
- 7. Referencias
1. Introducción
Las pruebas instrumentadas en Android son un tipo de pruebas que comprueba el comportamiento de la interfaz gráfica. Según la pirámide de pruebas, éste se encuentra en la cima.
Además de comprobar el comportamiento de la UI, nos permite validar los flujos de ejecución de nuestra aplicación.
2. Tipos de pruebas
2.1. Tests unitarios
El objetivo de los tests unitarios es probar exclusivamente una funcionalidad en concreto o clase y para ello se utilizan diferentes frameworks de apoyo como Mockito para realizar mocks de todas las dependencias que tiene la clase a probar.
2.2 Tests de integración
Los tests de integración son pruebas de varios componentes de nuestra aplicación. Los componentes podrían ser clases, módulos, comunicación con terceros, etc. El objetivo principal es comprobar si los diferentes componentes se integran correctamente.
2.3 Tests de instrumentación
Son parecidos a los tests de integración pero más reales de cara al usuario final. El objetivo es simular el comportamiento real de la aplicación mediante herramientas como Espresso.
3. Espresso
Espresso es un framework para Android desarrollado por Google que permite crear pruebas de interfaz de usuario. Espresso permite realizar pruebas tanto en dispositivos físicos como virtuales y además en la nube con Firebase Test Lab.
4. Firebase Test Lab
Firebase Test Lab es una infraestructura de pruebas basada en la nube. Usa dispositivos reales de producción en un centro de datos de Google para probar las aplicaciones tanto para plataformas Android como IOS. Android Studio se integra muy bien con Firebase y permite configurar diferentes dispositivos para ejecutar los test de instrumentación en cada uno de ellos.
5. Ejemplo
Creamos un proyecto simple con Android Studio.
5.1. Dependencias
androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.3.31"
androidTestImplementation "androidx.test:core:1.2.1-alpha02"
androidTestImplementation "androidx.test:core-ktx:1.2.1-alpha02"
androidTestImplementation "androidx.test.ext:junit:1.1.2-alpha02"
androidTestImplementation "androidx.test.ext:junit-ktx:1.1.2-alpha02"
androidTestImplementation "androidx.test:runner:1.3.0-alpha02"
androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0-alpha02"
5.2. Directorio de test
Cuando creamos un proyecto en Android existen dos directorios de tests dentro de src:
- androidTest se encuentran los tests de instrumentación.
- test se encuentran los tests unitarios y de integración.
5.3. Crear un test de instrumentación
Para crear un test de instrumentación debemos de crear una clase dentro de la carpeta androidTest/java/[nombre.del.paquete].
Por defecto, cuando se crea el proyecto se generan varios ficheros de tests y uno de ellos es de instrumentación, pero uno de los problemas de generar automáticamente estos ficheros es que, a veces, las dependencias no están actualizadas y algunas clases y métodos tienen la anotación @Deprecated. Por lo tanto, es importante que actualices correctamente las dependencias en tu fichero build.gradle de la aplicación.
En nuestro ejemplo ExampleInstrumentedTest contiene lo siguiente:
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.rules.activityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@get:Rule
var activityScenarioRule = activityScenarioRule()
@Test
fun useAppContext() {
val appContext = ApplicationProvider.getApplicationContext()
assertEquals("com.autentia.demo.instrumentationtest", appContext.packageName)
}
}
Si nos fijamos en la primera parte del código:
@get:Rule
var activityScenarioRule = activityScenarioRule()
Esta declaración permite ejecutar una actividad antes de cada test, en este caso solo tenemos MainActivity. Si seguimos analizando:
@Test
fun useAppContext() {
val appContext = ApplicationProvider.getApplicationContext()
assertEquals("com.autentia.demo.instrumentationtest", appContext.packageName)
}
El objetivo de este test es obtener el contexto de la aplicación y comprobar el nombre del paquete. Podrías preguntarte «¿Y para qué ejecutar la actividad principal en cada test?» Buena observación. En este caso para nada porque no hemos realizado ninguna comprobación de la interfaz gráfica. El siguiente paso es crear un test que interactúe con ella. ¡Vamos a ello!
Partimos de la siguiente interfaz:
Es un sencillo login, y nuestro objetivo es:
Si algunos de los campos están vacíos, cuando se haga clic en el botón «LOGIN», tiene que aparecer el siguiente mensaje de error en cada campo: «Este campo no puede estar vacío».
Para ello aplicaremos TDD, es decir, primero preparamos los tests y después escribimos el código. Volvemos al fichero de test de instrumentación que ha generado automáticamente y creamos un test que compruebe el estado de los campos cuando se haga clic en el botón «LOGIN».
El test quedaría de la siguiente manera:
@Test
fun usernameAndPasswordFieldShowErrorMessageIfAreEmptyWhenPressedLoginButton() {
// When
onView(withId(R.id.button_login)).perform(click())
// Then
onView(withId(R.id.field_username)).check(matches(checkErrorText {
hasErrorText(it)
}))
onView(withId(R.id.field_password)).check(matches(checkErrorText {
hasErrorText(it)
}))
}
Si nos paramos a analizar un poco el código, en el bloque When el método onView() permite obtener un componente visual en Android a través de un Matcher. En este caso realizamos el match a partir del ID. Una vez que se realiza el match este devuelve un ViewInteraction que nos permite realizar acciones con el método perform() o validaciones con check().
En algunos casos interesa crear nuestros propios Matchers cuando el framework de Espresso no lo proporciona. En nuestro ejemplo tenemos el método checkErrorText(condition). Al utilizar una librería de componentes como Material Design, algunos match no funcionan correctamente (en este caso hasErrorText() de Espresso no funciona).
Nuestra implementación del Matcher es la siguiente:
private inline fun checkErrorText(
crossinline condition: (view: T) -> Boolean
): BaseMatcher {
return object : BaseMatcher() {
override fun describeTo(description: Description) {}
override fun matches(item: Any): Boolean {
val textInputLayout = item as T
return condition(textInputLayout)
}
}
}
Con esta implementación podemos llegar a hacer validaciones tan complejas como queramos, y en nuestro ejemplo comprobamos que los campos usuario y contraseña tienen que mostrar error porque están vacíos. La condición es lo que queremos validar y lo recibimos como parámetro de nuestro Matcher.
En nuestro caso la condición sería la siguiente:
private fun hasErrorText(it: TextInputLayout) = it.error?.toString()?.equals(appContext.getString(R.string.empty_field_error)) ?: false
Si ejecutásemos el test en un emulador, su ejecución sería:
Y el resultado del test:
En este punto solo nos falta implementar la lógica necesaria para que pase el test correctamente, es decir, mostrar error en los campos cuando estén vacíos.
Abrimos la clase MainActivity y en el método onCreate() añadimos lo siguiente:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
button_login.setOnClickListener {
if (input_field_username.text.isNullOrEmpty()) {
field_username.error = getString(R.string.empty_field_error)
} else {
field_username.error = ""
}
if (input_field_password.text.isNullOrEmpty()) {
field_password.error = getString(R.string.empty_field_error)
} else {
field_password.error = ""
}
}
}
Si volvemos ejecutar el test en un emulador, su ejecución sería:
Y el resultado del test:
¡Ahora podemos añadir más tests!
@Test
fun usernameFieldShowsErrorMessageIfEmptyValueWhenPressedLoginButton() {
// When
onView(withId(R.id.input_field_password))
.perform(typeText("password"))
.perform(closeSoftKeyboard())
onView(withId(R.id.button_login)).perform(click())
// Then
onView(withId(R.id.field_username)).check(matches(checkErrorText {
hasErrorText(it)
}))
onView(withId(R.id.field_password)).check(matches(checkErrorText {
hasNotErrorText(it)
}))
}
@Test
fun usernameAndPasswordFieldsDoNotShowError() {
// When
onView(withId(R.id.input_field_username))
.perform(typeText("username"))
.perform(closeSoftKeyboard())
onView(withId(R.id.input_field_password))
.perform(typeText("password"))
.perform(closeSoftKeyboard())
onView(withId(R.id.button_login)).perform(click())
// Then
onView(withId(R.id.field_username)).check(matches(checkErrorText {
hasNotErrorText(it)
}))
onView(withId(R.id.field_password)).check(matches(checkErrorText {
hasNotErrorText(it)
}))
}
Si ejecutásemos los tests de nuevo, el resultado es el siguiente:
Conclusión
Como conclusión personal puedo decir que en Android los tests de instrumentación tiene algunas desventajas, una de ellas es el tiempo de ejecución, otra los recursos necesarios para poder ejecutarlos. En cambio, una de las ventajas es la facilidad que proporciona Espresso para probar las interfaces gráficas construidas con actividades o fragmentos.
También me llama mucho la atención la integración con Firebase Test Lab porque permite preparar los tests para un grupo de dispositivos con diferentes configuraciones. Imagina una aplicación que necesita instalarse en 10 dispositivos diferentes con distintas configuraciones… ¿Te animas a crear todos los dispositivos en Android Studio? ¡Aquí lo dejo!