Pruebas de instrumentación en Android con Espresso

0
3980
portada tutorial

Índice

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.

Pirámide de test

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.
    android tests

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].

test de instrumentación android

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:

resultado test con fallo en android

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:

Resultado correcto 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:

Resultado ejecución de todos los test

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!

Referencias

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad