Tests fonctionnels automatisés avec Espresso

Share Button

Tests fonctionnels et automatisation

Une application sans bug

Pour garantir que votre application est exempte d’anomalie, il est nécessaire de mettre en place des tests unitaires. Ceux-ci permettent de valider unitairement les composants d’une application. Ils sont indispensables pour les classes métier, les classes de service et les couches basses de votre projet.

Mais il est également nécessaire de mettre en place des tests fonctionnels. C’est à dire le respect des règles de gestion et d’interaction utilisateur. En somme, cela revient à valider le bon fonctionnement de l’interface utilisateur ainsi que la cohérence de l’assemblage des composants validés unitairement.

La solution Espresso

Espresso est un framework qui permet de simuler des interactions avec l’interface utilisateur pour éxécuter des tests fonctionnels sur votre application Android.

Les exemples de ce tutoriel sont basés sur un projet de test à télécharger. Nous vous invitons à le récupérer dès maintenant pour prendre en main Espresso dans un contexte simple.

Configurer votre projet pour les tests avec Espresso

Depuis Android studio 2.x, l’assistant de création d’un nouveau projet d’application Android prépare votre projet avec le nécessaire pour utiliser Espresso. Si vous avez un projet créé avec Android Studio 2.x (tel que EspressoPlayground), passez directement au chapitre “Configuration de lancement”. Sinon, suivez les étapes ci-dessous pour ajouter Espresso à votre projet.

Configuration Gradle

Ouvrez le build.gradle de votre module d’application (généralement nommé ‘app’) ;
Ajoutez, si nécessaire, les dépendances suivantes (dans la section ‘dependencies’) :

androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
exclude group: 'com.android.support', module: 'support-annotations'
})

Dossiers de code source des tests

Si nécessaire créez le dossier ‘androidTest’ sous l’arborescence de votre dossier ‘src’, puis créez un sous-dossier ‘java’ pour y placer le code de vos tests fonctionnels.
Créez ensuite le package correspondant à votre application. Dans le cas du projet AndroidPlayground, il s’agit de ‘fr.idapps.espressoplayground’.

Configuration de lancement

Dans Android Studio, ouvrez le menu “Run” puis “Edit Configurations” ;
Cliquez sur le bouton “+” et ajoutez une nouvelle configuration de type “Android Tests” ;
Dans le champ “Module”, sélectionnez le module correspondant à votre application Android (généralement ‘app’) ;
Au niveau du champ “Specific instrumentation runner”, copiez le FQN (Fully Qualified Name) de la classe suivante : ‘android.support.test.runner.AndroidJUnitRunner’ ;
Donnez un nom à votre configuration, dans le champ “Name” (en haut). Ici, nous avons choisi “Espresso Tests”.

Remarque : Espresso est uniquement un framework d’interaction avec les vues, et nos tests seront écrits et éxécutés avec JUnit 4. C’est pourquoi on utilise AndroidJUnitRunner.
Vous êtes désormais prêt à créer et exécuter des tests avec Espresso au sein de votre projet. Notez que cette configuration va exécuter tous les tests trouvés dans l’arborescence de votre module d’application.

Vous pouvez désormais créer votre premier test, puis choisir votre configuration dans la barre d’outils d’Android Studio et l’exécuter.
Dans le cas de EspressoPlayground, un test est déjà créé pour vous… Faites un ‘Run’ pour voir !

Enregistrer des tests

Depuis Android Studio 2.2, il est possible d’enregistrer des tests en capturant en direct les interactions utilisateurs effectuées sur un device ou un émulateur. Cette fonctionnalité est offerte par Espresso Test Recorder, qui est en bêta pour le moment, et qui permet de couvrir des cas d’utilisation vraiment basiques.

Démarrer l’enregistrement

Avant d’enregistrer des tests, choisissez votre configuration de lancement correspondant à l’application, et non aux tests. Généralement, celle-ci s’appelle ‘app’.
Pour démarrer l’enregistrement, allez dans le menu “Run” puis choisissez “Record Espresso Test”.
Choisissez ensuite l’appareil sur lequel capturer les actions.
L’application se lance sur l’appareil selectionné.

Enregistrer les actions

Effectuez une série d’interactions avec l’application. Vous remarquerez que les actions sont consignées dans la liste affichée à l’écran. Dans notre exemple, nous avons effectué l’opération “2 + 8 =” sur notre calculatrice, soit une série de 4 actions.

Poser des assertions

Vous pouvez ensuite comparer les états des vues à votre attente. Pour cela, il faut ajouter une (ou plusieurs) assertion avec le bouton “Add Assertion”.
Cliquez ensuite sur la vue dont vous voulez tester l’état, au niveau de l’aperçu.
Différentes options sont disponibles pour tester l’état de vos vues. Dans notre exemple, nous vérifions que le texte du champ résultat est bien égal au résultat de notre calcul.
Cliquez ensuite sur “Save Assertion”.
Vous pouvez ajouter d’autres actions et d’autres assertions. Lorsque vous avez terminé la capture de votre scénario de test, cliquez sur “Complete Recording”.
Espresso Test Recorder vous demande ensuite le nom de la classe de test, saisissez en un, puis validez.

Exécuter notre test

Pour exécuter le test que vous venez d’enregistrer, il vous suffit de revenir sur la configuration de lancement “Espresso Tests” et de cliquer sur “Run”. Essayez, pour voir !

Limitations

Espresso Test Recorder est encore un peu jeune. En jouant un peu avec, nous avons rapidement trouvé ses limites. Par exemple, les événements de mouvement (“touch motion events”) ne sont pas capturés, ce qui empêche le déplacement dans une RecyclerView. Le code produit est également assez redondant (effet “boilerplate”). Si vous souhaitez éxécuter des scénarios de test un peu complexes, nous vous conseillons de les implémenter vous-même, en Java.

Ecriture de tests : les fondamentaux

Afin de mieux comprendre la suite, nous allons commencer par un tour d’horizon des différents concepts que l’on peut trouver dans une classe de test Espresso.

Les règles (Rules)

Rappelons que nos tests seront écrits et éxécutés avec JUnit 4. Les règles JUnit permettent d’ajouter des comportements transverses à tous les cas de test présents dans une même classe de test. Pour utiliser une règle, il suffit de l’instancier en tant que donnée membre de notre classe, en précédant sa déclaration de @Rule.
La règle ActivityTestRule est indispensable pour les tests fonctionnels automatisés. Celle-ci assure que la bonne activité est démarrée avant l’exécution du cas du test. Lorsqu’on exécute plusieurs cas de tests, l’activité est redémarrée entre chaque. C’est très utile pour garantir l’absence d’effet de bord et assurer l’atomicité de nos tests !

Dans l’exemple suivant, nous précisons que tous les tests définis dans CalculatorActivityTest seront exécutés sur l’activité CalculatorActivity :

@RunWith(AndroidJUnit4.class)
public class CalculatorActivityTest {

@Rule
public ActivityTestRule mActivityRule = 
       new ActivityTestRule<>(CalculatorActivity.class);
...
}

Les annotations @Before, @Test, @After

Pour les utilisateurs non familiers de JUnit : l’annotation @Test permet de spécifier que la méthode annotée est un cas de test JUnit. Il faut donc la placer au dessus de chaque méthode de cas de test, sans quoi cette dernière ne sera pas exécutée. Les annotations @Before et @After sont exécutées respectivement avant et après chaque cas de test. L’annotation @Before est très utile pour initialiser des composants ou pour positionner l’application dans un état désiré. L’annotation @After est souvent utilisée pour libérer des ressources utilisées dans tous les cas de test.

La classe ViewInteraction, les ViewMatchers et les ViewActions

Les briques de base que nous utilisons avec Espresso sont les suivantes :

  • Espresso : le point d’entrée pour accéder aux éléments de notre UI
  • ViewInteraction : la classe permettant l’interaction avec les vues
  • Matcher : un “matcher” de Hamcrest. Il permet de vérifier certaines propriétés de nos vues, pour savoir si elles sont éligibles à certaines actions
  • ViewAction : une interface permettant d’effectuer une action sur une vue
  • ViewMatchers : une classe utilitaire fournissant des Matcher
  • ViewActions : une classe utilitaire fournissant des actions courantes sur les vues

Les Matchers

Revenons un peu sur le principe des “matchers”, car c’est un concept fondamental pour aborder Espresso. Un Matcher agit un peu comme un filtre. C’est une interface qui permet de savoir si une vue répond à certains critères.

On s’intéresse ici principalement à sa méthode ‘matches’ :

boolean matches(Object item);

Suivant l’implémentation concrète de la méthode, on pourra filtrer les vues de notre application de façon différente. Par exemple, la méthode ViewMatchers#withId fournit un matcher de vues qui n’accepte (renvoie true) que les vues possédant un identifiant donné.

Durant l’exécution des tests, Espresso va passer en revue toutes la hiérarchie des vues de l’activité testée à l’aide des matchers de Hamcrest. Il permet alors d’interagir avec celles qui sont acceptées par le matcher. Avec ce type de construction, on pourra donc retrouver les vues qui nous intéressent. Par exemple :

ViewMatchers.withId(R.id.mACButton) // Matche avec toutes les vues ayant l’id ‘mACButton’.
ViewMatchers.withText(R.string.filter); // Matche avec toutes les vues contenant ledit texte
ViewMatchers.isAssignableFrom(TextView.class); // Matche avec toutes les TextView
...

Les ViewActions

Les ViewActions sont des actions élémentaires qui peuvent être effectuées sur les vues. ViewAction est une interface très simple, qui définit trois méthodes :

Matcher getConstraints();

Retourne un matcher de vues qui permet de savoir sur quelles vues l’action est applicable.

String getDescription();

Retourne une description succinte qui sera affichée dans les logs lors de l’exécution de l’action.

void perform(UiController uiController, View view);

Implémente le comportement de l’action sur la vue passée en paramètre.

Nous verrons comment implémenter ces actions dans la section Créer une action personnalisée.

Les imports de méthodes statiques d’Espresso

Espresso fournit un certain nombre de méthodes pour interagir avec nos vues. Ces méthodes sont des méthodes statiques. Ainsi, pour une écriture plus compacte des scénarios de test, nous pouvons les importer afin de les utiliser directement, sans les préfixer par le nom de la classe qui les définit.

Par exemple, au lieu d’écrire :

Espresso.onView(ViewMatchers.withId(R.id.mPlusButton))
.perform(ViewActions.click());

Nous écrirons :

onView(withId(R.id.mPlusButton)).perform(click());

Pour cela, il nous suffit d’importer les méthodes statiques que nous utilisons :

import static android.support.test.espresso.Espresso.onView;
import static android.support.test.espresso.action.ViewActions.click;
import static android.support.test.espresso.matcher.ViewMatchers.withId;

Notre premier test

Ouvrons le projet EspressoPlayground. Celui-ci comporte une calculatrice que vous pouvez tester manuellement en exécutant le projet. Il s’agit de la ‘CalculatorActivity’. Notre implémentation n’est peut être pas exempte de défauts, et nous allons la tester de façon automatisée.
Commençons par un test très simple. Nous allons vérifier que le bouton de remise à zéro fonctionne correctement. Pour cela, nous allons :

  1. Appuyer sur un chiffre
  2. Appuyer sur le bouton de remise à zéro
  3. Lire le résultat dans le champ de texte dédié, et vérifier qu’il correspond à zéro
  4. Repérer une vue et effectuer une action
@Test
public void checkResetLeadsToZeroResult_sameActivity() {
onView(withId(R.id.mDigit7Button)).perform(click());
onView(withId(R.id.mACButton)).perform(click());
onView(withId(R.id.mResultTextView)).check(matches(withText("0.0")));
}

Un test plus complexe

Nous allons vérifier qu’une opération d’addition basique sur notre calculatrice produit le résultat attendu.
Cela implique de :

  1. Saisir un nombre
  2. Appuyer sur le bouton d’addition (“+”)
  3. Saisir un second nombre
  4. Lire le résultat, et le comparer à la somme des deux nombres

Créer une méthode de saisie de nombres

Le code de nos tests n’échappe pas aux bonnes pratiques de développement. Factoriser notre code est un bon moyen de développer des tests fiables et d’accélérer le développement des scénarios de test. Nous allons donc créer une méthode effectuant la saisie d’un chiffre, et une méthode effectuant la saisie d’un nombre.

private void typeNumber(double number) {
String numberToString = String.format(Locale.US, "%f", number);
for (int i = 0; i < numberToString.length(); i++) {
char digit = numberToString.charAt(i);
typeDigit(String.valueOf(digit));
}
}

private void typeDigit(String digit) {
int viewId = View.NO_ID;
switch (digit) {
case "0":
viewId = R.id.mDigit0Button;
break;
case "1":
viewId = R.id.mDigit1Button;
break;
...
case ".":
viewId = R.id.mDecimalButton;
break;
}
if(viewId != View.NO_ID) {
onView(withId(viewId)).perform(click());
}
}

Avec ces nouvelles primitives, nous apportons un pouvoir d’expression supérieur à notre code de test : des actions proches de ce qu’un humain pourrait décrire dans un scénario de test fonctionnel. Gardez cette approche en tête, elle deviendra indispensable si vous vous tournez vers le BDD avec Cucumber (voir ce projet).

Créer une action personnalisée

Afin de comparer le résultat de l’addition à notre attendu, il serait intéressant de lire le contenu de la TextView qui affiche le résultat de l’opération. Pour cela, nous allons exécuter une action personnalisée.

Notre action personnalisée doit implémenter l’interface ViewAction. Une ViewAction est applicable sous certaines conditions, exprimées par des contraintes (cf. la méthode getConstraints qui retourne un Matcher). La ViewAction effectue son travail (ici : lire le texte) dans la méthode perform.

Voici le code de notre méthode getText :

public static String getText(final Matcher matcher) {
final String[] stringHolder = { null };
/* Exécute notre action personnalisée sur la vue pointée par le matcher fourni. */
onView(matcher).perform(
/* ViewAction personnalisée : récupère le texte de la TextView. */
new ViewAction() {
@Override
public Matcher getConstraints() {
return isAssignableFrom(TextView.class);
}

@Override
public String getDescription() {
return "getting text from a TextView";
}

@Override
public void perform(UiController uiController, View view) {
/* Le cast est safe, car la contrainte garantit que nous avons un TextView. */
TextView tv = (TextView) view;
stringHolder[0] = tv.getText().toString();
}
});
return stringHolder[0];
}

Interactions UI avancées (carnet d’adresses)

Tester une RecyclerView (Partie 1)

Notre application d’exemple contient une RecyclerView effectuant l’affichage de la liste des contacts. Au cours de nos tests, nous souhaitons contrôler, entre-autres, que tous les contacts sont affichés dans la RecyclerView. Rappelons que sur Android, seuls les items visibles sont instanciés par l’Adapter. La difficulté est donc de pouvoir accéder à un élément de la liste en dehors de l’écran. Une astuce simple est de simuler des évènements de scroll sur la liste.

Afin de manipuler la RecyclerView plus simplement, nous pouvons utiliser la librairie ‘espresso-contrib’. Cette dernière fournit la classe utilitaire RecyclerViewActions qui permet de facilement manipuler la RecyclerView. Pour cela, il nous faut ajouter au build.gradle la dépendance suivante :

androidTestCompile('com.android.support.test.espresso:espresso-contrib:2.2') {
exclude group: 'com.android.support', module: 'appcompat'
exclude group: 'com.android.support', module: 'support-v4'
exclude group: 'com.android.support', module: 'support-annotations'
exclude module: 'recyclerview-v7'
}

Voici la méthode qui permet de scroller jusqu’à une position spécifiée dans la RecyclerView. Nous avons rajouté une attente active (appel de méthode ‘sleep’) de manière à ce que la dernière vue soit bien visible après le scroll. En effet, le scroll met un certain temps à se réaliser. Vous pouvez d’ailleurs prévoir un temps assez large, ici 500 millisecondes :

private void scrollAtPosition(int position){
//Scroll sur la liste à la position désirée
onView(withId(R.id.mRecyclerView)).perform(RecyclerViewActions.scrollToPosition(position));
//Attente de 500ms
onView(isRoot()).perform(sleep(500));
}

Afin de connaître le nombre d’items censés être présents dans la RecyclerView, nous avons besoin de récupérer le contenu de l’Adapter, pour cela il nous faut une ViewAction qui manipule la RecyclerView :

public class RecyclerViewAction {

public static ArrayList getAdapterItems(final Matcher matcher) {
final ArrayList[] contacts = new ArrayList[]{new ArrayList<>()};

onView(matcher).perform(new ViewAction() {

@Override
public Matcher getConstraints() {
return isAssignableFrom(RecyclerView.class);
}

@Override
public String getDescription() {
return "getting all elements from the adapter";
}

@Override
public void perform(UiController uiController, View view) {
RecyclerView rv = (RecyclerView) view;
ContactsRecyclerAdapter adapter = (ContactsRecyclerAdapter)rv.getAdapter();
contacts[0] = new ArrayList<>(adapter.getContactList());
}
});
return contacts[0];
}
}

Enfin, voici notre méthode qui teste tous les éléments d’une liste. Ici, on effectue un test sur le libellé correspondant au prénom pour chaque item:

@Test
public void test_Show_AllContacts() {
ArrayList contactList = RecyclerViewAction.getAdapterItems(withId(R.id.mRecyclerView));
for (int i =0; i<contactList.size(); i++) {
//Need to scroll because view item from list is not live again!!!
scrollAtPosition(i);
//Chech if item has good name
onView(withRecyclerView(R.id.mRecyclerView).atPositionOnView(i, R.id.mNameTextView)).check(matches((withText(contactList.get(i).getName()))));
}
}

Tester une RecyclerView (Partie 2)

Maintenant, nous allons tester le filtre par ordre alphabétique. Pour ce faire, il est nécessaire de connaître la taille de la liste. De la même manière que précédemment, nous créons une ViewAction qui manipule le RecyclerView et qui retournera à son tour la taille de l’adapter:

public class RecyclerViewAction {

public static int getAdapterItemCount(final Matcher matcher) {

final int[] size = new int[1];

onView(matcher).perform(new ViewAction() {

@Override
public Matcher getConstraints() {
return isAssignableFrom(RecyclerView.class);
}

@Override
public String getDescription() {
return "getting the total number of items in the data set held by the adapter";
}

@Override
public void perform(UiController uiController, View view) {
RecyclerView rv = (RecyclerView) view;
ContactsRecyclerAdapter adapter = (ContactsRecyclerAdapter)rv.getAdapter();
size[0] = adapter.getItemCount();
}
});
return size[0];
}

}

Et voici notre test du tri par ordre alphabétique.
Dans un premier lieu on simule le clic sur le bouton de tri pour avoir la liste triée.
Ensuite on scrolle la liste pour accéder à l’item qui est comparé de proche en proche.

@Test
public void test_Sort_By_AlphabeticalOrder() {

//Click on "Sort Alphabetical Order" Button
onView(withId(R.id.mSortAlphapeticalOrderButton)).perform(click());

//Get the size of the contact address book
int sizeContactList = RecyclerViewAction.getAdapterItemCount(withId(R.id.mRecyclerView));
String tmp = "";
for(int i=0; i<sizeContactList; i++){ scrollAtPosition(i); String contactName = getText(withRecyclerView(R.id.mRecyclerView).atPositionOnView(i, R.id.mNameTextView)); if(tmp.compareTo(contactName) > 0){
Assert.fail("List is not sorted correctly!");
}
tmp = contactName;
}
}

En cas de problème

Aucun test trouvé (“No tests were found”)

Le TestRunner vous informe qu’aucun test n’a été trouvé, et pourtant vous en avez développé quelques-uns.

Vérifiez votre configuration de lancement (Run Configuration).
Ce type de problème peut également se produire lorsqu’une exception est propagée jusqu’au TestRunner. Cela peut, par exemple, venir d’une règle personnalisée qui pose problème. Parcourez votre logcat à la recherche d’une exception pour en trouver la cause.

Bibliographie

Share Button