Maîtriser le développement grâce aux tests sur Android à l'aide de tests d'interface

Bonjour à tous. En prévision du lancement d'un nouvel ensemble de cours de base et avancés sur le développement Android, nous avons préparé une traduction de matériel intéressant.




Au cours de la dernière année de travail de l'équipe de développement Android chez Buffer, nous avons beaucoup parlé de la propreté de notre projet et de l'amélioration de sa stabilité. L'un des facteurs a été l'introduction () de tests qui, comme nous l'avons déjà découvert, nous aident à éviter les régressions dans notre code et nous donnent plus de confiance dans les fonctionnalités que nous proposons. Et maintenant, lorsque nous lançons de nouveaux produits dans Buffer , nous voulons nous assurer que la même approche est appliquée en ce qui les concerne - juste pour que nous ne soyons pas dans la même situation qu'auparavant.

Contexte


Lors de l'écriture de tests unitaires pour les applications Buffer, nous avons toujours respecté les pratiques de développement par le biais de tests (Test-Driven-Development - ci-après TDD). Il existe de nombreuses ressources sur ce qu'est le TDD, sur ses avantages et d'où ils viennent, mais nous ne nous attarderons pas sur ce sujet, car il y a beaucoup d'informations sur Internet. À un niveau élevé, j'en note personnellement certains:

  • Temps de développement réduit
  • Code plus simple, plus propre et plus facile à gérer
  • Code plus fiable avec plus de confiance dans notre travail.
  • Couverture de test plus élevée (cela semble assez évident)

Mais jusqu'à récemment, nous ne suivions les principes de TDD que sous la forme de tests unitaires pour nos implémentations qui n'étaient pas basés sur l'interface utilisateur ...



Je sais, je sais ... Nous avons toujours eu l'habitude d'écrire des tests d'interface une fois que ce qui a été implémenté a été terminé. - et cela n'a pas de sens. Nous avons surveillé TDD pour le backend afin que le code que nous écrivons réponde aux exigences définies par les tests, mais en ce qui concerne les tests d'interface utilisateur, nous écrivons des tests qui satisfont à l'implémentation d'une fonctionnalité spécifique. Comme vous pouvez le voir, c'est un peu controversé, et à certains égards, c'est pourquoi le TDD est utilisé en premier lieu.

Donc, ici, je veux examiner pourquoi il en est ainsi et comment nous expérimentons le changement. Mais pourquoi y prêtons-nous attention en premier lieu?

Il était toujours difficile de travailler avec les activités existantes dans notre application en raison de la façon dont elles sont écrites. Ce n'est pas une excuse complète, mais ses nombreuses dépendances, responsabilités et liens étroits les rendent extrêmement difficiles à tester. Pour les nouvelles activités que nous avons ajoutées, par habitude, j'ai moi-même toujours écrit des tests d'interface utilisateur après la mise en œuvre - à part l'habitude, il n'y avait pas d'autre raison à cela. Cependant, lors de la création de notre code modèle , prêt pour de nouveaux projets, j'ai pensé à un changement. Et vous serez heureux de savoir que cette habitude a été rompue, et maintenant nous travaillons sur nous-mêmes, explorant TDD pour les tests d' interface utilisateur

Premiers pas


Ce que nous allons explorer ici est un exemple assez simple, afin que le concept soit plus facile à suivre et à comprendre - j'espère que cela suffira pour voir certains des avantages de cette approche.

Nous allons commencer par créer une activité de base. Nous devons le faire pour pouvoir exécuter notre test d'interface utilisateur - imaginez que cette configuration est la base de notre implémentation, et non l'implémentation elle-même. Voici à quoi ressemble notre activité de base:

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")
    }

}

Vous remarquerez peut-être que cette activité ne fait rien d'autre que la configuration initiale, qui est requise pour l'activité. Dans la méthode onCreate (), nous établissons simplement un lien vers la mise en page, nous avons également un lien vers notre interface View, qui est implémentée à l'aide de l'activité, mais ils n'ont pas encore d'implémentations.

L'une des choses les plus courantes que nous trouvons dans les tests Espresso est les vues de référence et les chaînes par ID de ressource trouvées dans notre application. À cet égard, nous devons à nouveau fournir un fichier de mise en page à utiliser dans notre activité. Cela est dû au fait que: a) notre activité a besoin d'un fichier de mise en page pour afficher la mise en page pendant les tests, et b) nous avons besoin d'une vue ID pour les liens dans nos tests. Allons de l'avant et faisons une mise en page très simple pour notre activité de connexion:

<?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>

Ici, vous pouvez voir que nous ne nous soucions d'aucun style ou position, souvenez-vous - pendant que nous créons la fondation et non l'implémentation.

Et pour la dernière partie de la configuration, nous allons définir les lignes qui seront utilisées dans cet exercice. Encore une fois, nous devrons les référencer dans les tests - jusqu'à ce que vous les ajoutiez à votre disposition XML ou à votre classe d'activité, définissez-les simplement dans le fichier strings.xml.

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

Vous remarquerez peut-être que dans cette configuration, nous écrivons le moins possible, mais nous fournissons suffisamment de détails sur notre activité et sa configuration pour écrire des tests. Notre activité ne fonctionne pas actuellement, mais elle s'ouvre et a une vue qui peut être référencée. Maintenant que nous avons suffisamment de minimum pour travailler, continuons et ajoutons quelques tests.

Ajout de tests


Donc, nous avons trois situations que nous devons implémenter, nous allons donc écrire des tests pour eux.

  • Lorsqu'un utilisateur entre une adresse e-mail non valide dans le champ de saisie e-mail, nous devons afficher un message d'erreur. Nous allons donc écrire un test qui vérifie si ce message d'erreur est affiché.
  • Lorsque l'utilisateur recommence à entrer des données dans le champ de saisie de l'e-mail, le message d'erreur ci-dessus devrait disparaître - nous allons donc écrire un test pour cela.
  • Enfin, lorsque l'API renvoie un message d'erreur, il doit s'afficher dans une boîte de dialogue d'avertissement - nous allons donc également ajouter un test pour cela.

@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, maintenant nous avons des tests écrits - continuons et exécutons-les.



Et il n'est pas surprenant qu'ils aient échoué - c'est parce que nous n'avons pas encore d'implémentation, donc cela devrait être prévu. En tout cas, nous devrions être heureux de voir du rouge pour les tests dès maintenant!

Alors maintenant, nous devons ajouter des implémentations pour notre activité jusqu'à ce que les tests réussissent. Alors que nous écrivons des tests ciblés qui testent un seul concept (ou du moins cela devrait l'être!), Nous pouvons ajouter des implémentations une par une et également regarder nos tests devenir verts l'un après l'autre. Examinons donc

l'un des tests ayant échoué, commençons par le test invalidPasswordErrorDisplayed () . Nous savons quelques choses:

  • , , , , :

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)
}

Maintenant que nous avons ajouté la logique de cette situation, continuons et réexécutons nos tests!



Super, il semble que le chèque invalidPassworrdErrorDisplays()ait réussi. Mais nous n'avons pas encore fini, nous avons encore deux tests qui n'ont pas été passés pour les parties de notre fonction de connexion que nous devons implémenter.

Ensuite, nous considérerons le test serverErrorMessageDisplays(). C'est assez simple, nous savons que lorsque l'API renvoie une réponse d'erreur (et non une erreur générale de notre bibliothèque réseau), l'application doit afficher un message d'erreur à l'utilisateur dans une boîte de dialogue d'avertissement. Pour ce faire, nous avons juste besoin de créer une instance de la boîte de dialogue en utilisant notre message d'erreur de serveur dans le texte de la boîte de dialogue:

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

Continuons et exécutons à nouveau nos tests:



Hourra! Nous allons de l'avant, il ne nous reste plus qu'un test, c'est un test invalidEmailErrorHidesWhenUserTypes(). Encore une fois, c'est un cas simple, mais regardons-le:

  • Lorsque l'utilisateur clique sur le bouton de connexion et que l'adresse e-mail est manquante ou que l'adresse e-mail est incorrecte, nous affichons à l'utilisateur un message d'erreur. Nous l'avons déjà implémenté, je l'ai simplement exclu pour plus de simplicité
  • Cependant, lorsque l'utilisateur recommence à saisir des données dans le champ, le message d'erreur doit être supprimé de l'affichage. Pour ce faire, nous devons écouter lorsque le contenu textuel du champ de saisie change:

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)
        }
    })
}

Maintenant, cela devrait être suffisant pour garantir que notre message d'erreur sera masqué lors de la modification du contenu du champ de saisie. Mais nous avons des tests pour confirmer nos changements:



super! Nos exigences de mise en œuvre sont remplies lorsque nous passons nos tests - c'est formidable de voir le feu vert

Conclusion


Il est important de noter que l'exemple auquel nous avons appliqué TDD est extrêmement primitif. Imaginez que nous développons un écran complexe, tel qu'un flux de contenu, dans lequel vous pouvez effectuer plusieurs actions avec des éléments de flux (par exemple, comme dans l'application Buffer pour Android) - dans ces cas, nous utiliserons de nombreuses fonctions différentes qui devraient être implémentées dans l'activité donnée / fragment. Ce sont des situations où TDD sera révélé encore plus dans les tests d'interface utilisateur, car ce qui peut conduire à écrire du code trop complexe pour ces fonctions peut être réduit à des implémentations qui satisfont les tests donnés que nous avons écrits.

Pour résumer, je partagerai quelques points tirés de mon expérience:

  • , , TDD . , . , Espresso (/, ), . activity, , , . .
  • , , , , , , , . , , , , , ( , !). - . , , , - .
  • , Ui- , , . , , , , , , .
  • , . , — , , , - .
  • Cela semble plus naturel. Étant donné que TDD est déjà utilisé pour les tests unitaires, vous vous sentez un peu en arrière lorsque vous écrivez des tests unitaires, suivis des implémentations, puis des tests d'interface utilisateur. Vous vous sentirez plus naturel en prenant des mesures complètes avec TDD, pas à mi-chemin.

Utilisez-vous déjà TDD lors de l'écriture de tests d'interface utilisateur et faites-vous quelque chose de similaire ou de complètement différent? Ou voulez-vous en savoir un peu plus et poser quelques questions? N'hésitez pas à commenter ci-dessous ou à nous tweeter à @bufferdevs

C'est tout. Nous vous attendons aux cours:


All Articles