Dominar el desarrollo a través de pruebas en Android usando pruebas de IU

Hola a todos. En previsión del lanzamiento de un nuevo conjunto de cursos básicos y avanzados sobre desarrollo de Android, preparamos una traducción de material interesante.




Durante el último año del trabajo del equipo de desarrollo de Android en Buffer, hablamos mucho sobre la limpieza de nuestro proyecto y la mejora de su estabilidad. Uno de los factores fue la introducción () de las pruebas, que, como ya hemos descubierto, nos ayudan a evitar regresiones en nuestro código y nos dan más confianza en las características que ofrecemos. Y ahora, cuando lanzamos nuevos productos en Buffer , queremos asegurarnos de que se aplique el mismo enfoque cuando se trata de ellos, solo para que no estemos en la misma situación que antes.

Antecedentes


Al escribir pruebas unitarias para aplicaciones Buffer, siempre nos hemos adherido a la práctica de desarrollo a través de las pruebas (Test-Driven-Development - en adelante TDD). Hay muchos recursos sobre qué es TDD, sobre sus ventajas y de dónde provienen, pero no nos detendremos en este tema, ya que hay mucha información en Internet. En un nivel alto, personalmente noto algunos de ellos:

  • Tiempo de desarrollo reducido.
  • Código más simple, limpio y más fácil de mantener
  • Código más confiable con más confianza en nuestro trabajo.
  • Mayor cobertura de prueba (esto parece algo obvio)

Pero hasta hace poco, solo seguíamos los principios de TDD solo en forma de pruebas unitarias para nuestras implementaciones que no se basaban en la interfaz de usuario ...



Lo sé, lo sé ... Siempre tuvimos la costumbre de escribir pruebas de interfaz de usuario después de completar lo implementado - Y eso no tiene sentido. Supervisamos TDD para el back-end para que el código que escribimos cumpla con los requisitos que definen las pruebas, pero cuando se trata de pruebas de interfaz de usuario, escribimos pruebas que satisfacen la implementación de una característica específica. Como puede ver, esto es un poco controvertido, y de alguna manera se trata de por qué se usa TDD en primer lugar.

Entonces, aquí quiero considerar por qué esto es así y cómo estamos experimentando con el cambio. Pero, ¿por qué prestamos atención a esto en primer lugar?

Siempre fue difícil trabajar con actividades existentes en nuestra aplicación debido a cómo están escritas. Esta no es una excusa completa, pero sus muchas dependencias, responsabilidades y lazos estrechos los hacen extremadamente difíciles de probar. Para las nuevas actividades que agregamos, por costumbre, yo mismo siempre escribí pruebas de IU después de la implementación; además del hábito, no había otra razón para esto. Sin embargo, al crear nuestro código de plantilla , listo para nuevos proyectos, pensé en un cambio. Y se alegrará de saber que este hábito se ha roto, y ahora estamos trabajando en nosotros mismos, explorando TDD para pruebas de IU

Primeros pasos


Lo que vamos a explorar aquí es un ejemplo bastante simple, para que el concepto sea más fácil de seguir y comprender. Espero que esto sea suficiente para ver algunas de las ventajas de este enfoque.

Vamos a comenzar creando una actividad básica. Necesitamos hacer esto para poder ejecutar nuestra prueba de IU; imagine que esta configuración es la base de nuestra implementación, y no la implementación en sí misma. Así es como se ve nuestra actividad básica:

class LoginActivity: AppCompatActivity(), LoginContract.View {

    @Inject lateinit var loginPresenter: LoginContract.Presenter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_login)
    }

    override fun setPresenter(presenter: LoginContract.Presenter) {
        loginPresenter = presenter
    }

    override fun showServerErrorMessage() {
        TODO("not implemented")
    }

    override fun showGeneralErrorMessage() {
        TODO("not implemented")
    }

    override fun showProgress() {
        TODO("not implemented")
    }

    override fun hideProgress() {
        TODO("not implemented")
    }

    override fun showInvalidEmailMessage() {
        TODO("not implemented")
    }

    override fun hideInvalidEmailMessage() {
        TODO("not implemented")
    }

    override fun showInvalidPasswordMessage() {
        TODO("not implemented")
    }

    override fun hideInvalidPasswordMessage() {
        TODO("not implemented")
    }

}

Puede observar que esta actividad no hace más que la configuración inicial, que es necesaria para la actividad. En el método onCreate (), simplemente establecemos un enlace al diseño, también tenemos un enlace a nuestra interfaz de Vista, que se implementa utilizando la actividad, pero aún no tienen implementaciones.

Una de las cosas más comunes que encontramos en las pruebas de Espresso son las vistas de referencia y las cadenas por ID de recursos que se encuentran en nuestra aplicación. En este sentido, nuevamente necesitamos proporcionar un archivo de diseño para usar en nuestra actividad. Esto se debe al hecho de que: a) nuestra actividad necesita un archivo de diseño para mostrar el diseño durante las pruebas, yb) necesitamos una vista de ID para los enlaces en nuestras pruebas. Avancemos y hagamos un diseño muy simple para nuestra actividad de inicio de sesión:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <EditText
        android:id="@+id/input_email"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <EditText
        android:id="@+id/input_password"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <Button
        android:id="@+id/button_login"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</LinearLayout>

Aquí puede ver que no estábamos preocupados por ningún estilo o posición, recuerde, mientras creamos la base y no la implementación.

Y para la última parte de la configuración, vamos a definir las líneas que se utilizarán en este ejercicio. Nuevamente, necesitaremos hacer referencia a ellos en las pruebas; hasta que los agregue a su diseño XML o clase de actividad, solo defínalos en el archivo strings.xml.

<string name="error_message_invalid_email”>…</string>
<string name="error_message_invalid_password”>…</string>

Puede notar que en esta configuración escribimos lo menos posible, pero proporcionamos suficientes detalles sobre nuestra actividad y su diseño para escribir pruebas para ella. Nuestra actividad no funciona actualmente, pero se abre y tiene una vista a la que se puede hacer referencia. Ahora que tenemos suficiente mínimo para trabajar, continuemos y agreguemos algunas pruebas.

Agregar pruebas


Entonces, tenemos tres situaciones que debemos implementar, por lo que vamos a escribir algunas pruebas para ellos.

  • Cuando un usuario ingresa una dirección de correo electrónico no válida en el campo de entrada de correo electrónico, debemos mostrar un mensaje de error. Entonces, vamos a escribir una prueba que verifique si se muestra este mensaje de error.
  • Cuando el usuario comienza a ingresar datos en el campo de entrada de correo electrónico nuevamente, el mensaje de error anterior debería desaparecer, por lo tanto, vamos a escribir una prueba para esto.
  • Finalmente, cuando la API devuelve un mensaje de error, debe mostrarse en un cuadro de diálogo de advertencia, por lo que también agregaremos una prueba para esto.

@Test
fun invalidEmailErrorHidesWhenUserTypes() {
    activity.launchActivity(null)

    onView(withId(R.id.button_login))
            .perform(click())

    onView(withId(R.id.input_email))
            .perform(typeText("j"))

    onView(withText(R.string.error_message_invalid_email))
            .check(doesNotExist())
}

@Test
fun invalidPasswordErrorDisplayed() {
    activity.launchActivity(null)

    onView(withId(R.id.button_login))
            .perform(click())

    onView(withText(R.string.error_message_invalid_password))
            .check(matches(isDisplayed()))
}

@Test
fun serverErrorMessageDisplays() {
    val response = ConnectResponseFactory.makeConnectResponseForError()
    stubConnectRepositorySignIn(Single.just(response))
    activity.launchActivity(null)

    onView(withId(R.id.input_email))
            .perform(typeText("joe@example.com"))

    onView(withId(R.id.input_password))
            .perform(typeText(DataFactory.randomUuid()))

    onView(withId(R.id.button_login))
            .perform(click())

    onView(withText(response.message))
            .check(matches(isDisplayed()))
}

Ok, ahora tenemos pruebas escritas, continuemos y las ejecutemos.



Y no es sorprendente que hayan fallado, esto se debe a que todavía no tenemos una implementación, por lo que esto es de esperarse.

Así que ahora necesitamos agregar implementaciones para nuestra actividad hasta que pasen las pruebas. A medida que escribimos pruebas enfocadas que prueban solo un concepto (¡o al menos debería serlo!), Podemos agregar implementaciones una por una y también ver cómo nuestras pruebas se vuelven verdes una tras otra.

Entonces, veamos una de las pruebas fallidas, comencemos con la prueba invalidPasswordErrorDisplayed () . Sabemos algunas cosas:

  • , , , , :

private fun setupLoginButtonClickListener() {
    button_login.setOnClickListener {
        loginPresenter.performSignIn(input_email.text.toString(),
                input_password.text.toString()) }
}

  • , . TextInputLayout, , :

override fun showInvalidPasswordMessage() {
    layout_input_password.error = getString(R.string.error_message_invalid_password)
}

Ahora que hemos agregado la lógica para esta situación, ¡continuemos y ejecutemos nuestras pruebas nuevamente!



Genial, parece que el cheque invalidPassworrdErrorDisplays()fue exitoso. Pero aún no hemos terminado, todavía tenemos dos pruebas que no se han aprobado para aquellas partes de nuestra función de inicio de sesión que debemos implementar.

A continuación, consideraremos la prueba serverErrorMessageDisplays(). Esto es bastante simple, sabemos que cuando la API devuelve una respuesta de error (y no un error general de nuestra biblioteca de red), la aplicación debe mostrar un mensaje de error al usuario en un cuadro de diálogo de advertencia. Para hacer esto, solo necesitamos crear una instancia del diálogo usando nuestro mensaje de error del servidor en el texto del diálogo:

override fun showServerErrorMessage(message: String) {
    DialogFactory.createSimpleInfoDialog(this, R.string.error_message_login_title, message, 
            R.string.error_message_login_ok).show()
}

Continuemos y ejecutemos nuestras pruebas nuevamente:



¡Hurra! Estamos avanzando, ahora solo nos queda una prueba, esta es una prueba invalidEmailErrorHidesWhenUserTypes(). Nuevamente, este es un caso simple, pero veámoslo:

  • Cuando el usuario hace clic en el botón de inicio de sesión y falta la dirección de correo electrónico o la dirección de correo electrónico es incorrecta, le mostramos un mensaje de error. Ya lo hemos implementado, lo descarté por simplicidad
  • Sin embargo, cuando el usuario comienza a ingresar datos nuevamente en el campo, el mensaje de error debe eliminarse de la pantalla. Para hacer esto, necesitamos escuchar cuando el contenido del texto del campo de entrada cambia:

private fun setupOnEmailTextChangedListener() {
    input_email.addTextChangedListener(object : TextWatcher {

        override fun afterTextChanged(s: Editable) {}

        override fun beforeTextChanged(s: CharSequence, start: Int,
                                       count: Int, after: Int) {
        }

        override fun onTextChanged(s: CharSequence, start: Int,
                                   before: Int, count: Int) {
            loginPresenter.handleEmailTextChanged(s)
        }
    })
}

Ahora esto debería ser suficiente para garantizar que nuestro mensaje de error estará oculto al cambiar el contenido del campo de entrada. Pero tenemos pruebas para confirmar nuestros cambios:



¡Genial! Nuestros requisitos de implementación se cumplen cuando pasamos nuestras pruebas; es genial ver la luz verde

Conclusión


Es importante tener en cuenta que el ejemplo al que aplicamos TDD es extremadamente primitivo. Imagine que estamos desarrollando una pantalla compleja, como una fuente de contenido, en la que puede realizar varias acciones con elementos de fuente (por ejemplo, como en la aplicación Buffer para Android); en estos casos, utilizaremos muchas funciones diferentes que deberían implementarse en la actividad dada. / fragmento. Estas son situaciones en las que TDD se revelará aún más en las pruebas de IU, porque lo que puede conducir a escribir código demasiado complejo para estas funciones puede reducirse a implementaciones que satisfagan las pruebas dadas que escribimos.

Para resumir, compartiré algunos puntos aprendidos de mi experiencia:

  • , , TDD . , . , Espresso (/, ), . activity, , , . .
  • , , , , , , , . , , , , , ( , !). - . , , , - .
  • , Ui- , , . , , , , , , .
  • , . , — , , , - .
  • Parece más natural Debido al hecho de que TDD ya se usa para pruebas unitarias, se siente un poco hacia atrás cuando escribe pruebas unitarias, seguidas de implementaciones, seguidas de pruebas de IU. Te sentirás más natural dando un paso completo con TDD, no a la mitad.

¿Ya está utilizando TDD al escribir pruebas de interfaz de usuario y está haciendo algo similar o completamente diferente? ¿O quieres saber un poco más y hacer algunas preguntas? Siéntase libre de comentar a continuación o twittear en @bufferdevs

Eso es todo. Te esperamos en los cursos:


All Articles