• 20 heures
  • Facile

Ce cours est visible gratuitement en ligne.

Ce cours est en vidéo.

Vous pouvez obtenir un certificat de réussite à l'issue de ce cours.

J'ai tout compris !

Mis à jour le 04/05/2018

Tips & Tricks pour améliorer la qualité de votre code

Connectez-vous ou inscrivez-vous gratuitement pour bénéficier de toutes les fonctionnalités de ce cours !

Dans ce dernier chapitre, nous allons améliorer la qualité de notre code en implémentant une série de tests afin de valider le bon fonctionnement de notre application Wonder. Pour cela, nous allons créer des "Tests d'Interfaces Utilisateurs" (ou UI Tests en anglais... ;) ) en utilisant la librairie Espresso fournie par Google.

Cette dernière vous permettra de réaliser facilement des tests instrumentalisés sur votre interface graphique, en effectuant des vérifications sur la partie "vue" uniquement.

Installation

Par défaut sur toutes les nouvelles applications créées via Android studio, les dépendances pour utiliser Espresso sont déjà présentes. Si jamais ce n'est pas le cas pour vous, ajoutez les dépendances suivantes à votre fichier build.gradle :

Extrait de build.gradle :

android {
    ...
    defaultConfig {
        ...
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    ...
}

dependencies {
    // ESPRESSO
    androidTestCompile 'com.android.support.test.espresso:espresso-core:3.0.1'
    androidTestCompile 'com.android.support.test:runner:1.0.1'
    
    ...
}

Fonctionnement d'Espresso

Espresso a été pensé dans le but de vous encourager à penser vos tests de la même manière qu'un utilisateur utilise votre application : ce dernier localise un élément de votre UI (bouton, liste, etc...) et tente d'interagir avec.

Voici un exemple de test avec Espresso : 

onView(withId(R.id.my_button)) // withId(R.id.my_button) is a ViewMatcher
    .perform(click()) // click() is a ViewAction
    .check(matches(isDisplayed())); // matches(isDisplayed()) is a ViewAssertion

La structure d'un test Espresso sera quasiment toujours la même :

  • onView()  : C'est le point d'entrée de votre test. Il vous permettra par la suite de définir sur quelle vue nous souhaitons réaliser le test.

  • withId()  : C'est le ViewMatchers permettant d'identifier précisément la vue sur laquelle nous souhaitons réaliser le test.

  • perform(click())  : La méthode  perform()  nous permettra d'exécuter des actions de type ViewActions , comme la méthode click(). Il en existe bien sûr beaucoup d'autres comme vous pouvez le constater dans la classe ViewActions ! :)

  • check(matches(isDisplayed()))  : La méthode  check()  permet d'effectuer un test de type ViewAssertion , comme la méthode matches() par exemple. Cette dernière exécute  une méthode de type ViewMatchers effectuant ici la vérification isDisplayed().

Comme vous le voyez, tout est très bien découpé avec Espresso... :p Cette organisation nous permet de pouvoir réaliser beaucoup de choses, comme nous allons le voir immédiatement.

Tests sur la Bottom Navigation Bar

Ainsi pour notre application Wonder, nous aimerions vérifier qu'au démarrage de l'activité MainActivity,  le premier bouton de la Bottom Navigation Bar soit bien sélectionné, et que celle-ci contienne bien les  textes "Android", "Logo" et "Paysage".

D'accord ! Bon, je viens de regarder dans la liste des Matchers disponibles sur Android, mais rien ne me permet d'effectuer cela... :euh: 

Eh oui ! Espresso ne contient pas encore toutes les méthodes pour tester l'ensemble des vues disponibles sur Android (surtout les aussi récentes comme la Bottom Navigation Bar !)... ;)

Ainsi, pour pallier à ce problème, nous allons créer nous-même un ViewMatchers nous permettant d'effectuer par la suite des tests UI sur la Bottom Navigation Bar. Pour cela, créez le package  matchers/  dans le package  androidTest/java/com.openclassroms.wonder . Puis, créez-y la classe suivante.

Classe matchers/BottomNavigationItemViewMatcher.java :

public final class BottomNavigationItemViewMatcher {

    private BottomNavigationItemViewMatcher(){}

    public static Matcher<View> withIsChecked(final boolean isChecked) {
        return new BoundedMatcher<View, BottomNavigationItemView>(BottomNavigationItemView.class) {

            private boolean triedMatching;

            @Override
            public void describeTo(Description description) {
                if (triedMatching) {
                    description.appendText("with isChecked: " + String.valueOf(isChecked));
                    description.appendText("But was: " + String.valueOf(!isChecked));
                }
            }

            @Override
            protected boolean matchesSafely(BottomNavigationItemView item) {
                triedMatching = true;
                return item.getItemData().isChecked() == isChecked;
            }
        };
    }

    public static Matcher<View> withTitle(final String titleTested) {
        return new BoundedMatcher<View, BottomNavigationItemView>(BottomNavigationItemView.class) {

            private boolean triedMatching;
            private String title;

            @Override
            public void describeTo(Description description) {
                if (triedMatching) {
                    description.appendText("with title: " + titleTested);
                    description.appendText("But was: " + String.valueOf(title));
                }
            }

            @Override
            protected boolean matchesSafely(BottomNavigationItemView item) {
                this.triedMatching = true;
                this.title = item.getItemData().getTitle().toString();
                return title.equals(titleTested);
            }
        };
    }
}

Explications : Nous avons créé ici une classe non instanciable définissant deux méthodes publiques statiques,  withTitle  et  withIsChecked . Celles-ci auront pour rôle :

  • withTitle : Vérifier si le titre fourni en paramètre de la méthode est bien celui affiché dans la vue BottomNavigationItemView spécifiée.

  • withIsChecked  : Vérifier la sélection (sélectionnée ou non) de la vue BottomNavigationItemView spécifiée.

Je vous laisse les étudier de plus près afin de comprendre la manière dont vous pouvez créer des ViewMatchers personnalisés. Plus rien ne peut maintenant vous arrêter... ;)

Implémentons dès à présent tout cela à travers des tests d'UI. Je vous laisse créer la classe MainActivityTests dans le package dédié à vos tests instrumentalisés.

Classe MainActivityTests.java : 

@RunWith(AndroidJUnit4.class)
public class MainActivityTests {

    // FOR DATA
    private Context context;

    @Rule
    public final ActivityTestRule<MainActivity> mActivityRule = new ActivityTestRule<>(MainActivity.class);

    @Before
    public void setup() {
        this.context = InstrumentationRegistry.getTargetContext();
    }

    @Test
    public void checkBottomNavigationButtonSelection(){
        onView(ViewMatchers.withId(R.id.action_android)).check(matches(withIsChecked(true)));
        onView(ViewMatchers.withId(R.id.action_landscape)).check(matches(withIsChecked(false)));
        onView(ViewMatchers.withId(R.id.action_logo)).check(matches(withIsChecked(false)));
    }

    @Test
    public void checkBottomNavigationButtonTitle(){
        onView(ViewMatchers.withId(R.id.action_android)).check(matches(withTitle(context.getString(R.string.bottom_navigation_menu_android))));
        onView(ViewMatchers.withId(R.id.action_landscape)).check(matches(withTitle(context.getString(R.string.bottom_navigation_menu_landscape))));
        onView(ViewMatchers.withId(R.id.action_logo)).check(matches(withTitle(context.getString(R.string.bottom_navigation_menu_logos))));
    }
}

Explications : Nous avons créé ici une classe responsable des tests s'effectuant sur notre écran principal  MainActivity . Dans un premier temps, nous avons créé une règle d'exécution grâce à l'annotation  @Rule. Dans notre cas, la règle sera de lancer l'activité MainActivity avant l'exécution de chaque test. 

Nous avons également créé une méthode  setup()  s'exécutant avant chaque test (grâce à l'annotation  @Before ) afin de récupérer le contexte que nous utiliserons un peu plus tard... ;) Et enfin, nous effectuons nos deux tests permettant de vérifier si les boutons de la Bottom Navigation Bar sont bien dans l'état de sélection qu'ils devraient être et si leur titre est également bien correct.

Exécutez maintenant ces tests instrumentalisés. Tout devrait fonctionner sans problème ! :)

Résultat des tests
Résultat des tests

Tests sur des ressources asynchrones

Maintenant, il serait tout de même intéressant de vérifier que notre écran principal récupère correctement la liste de projet "design" depuis l'API de Behance et l'affiche dans sa RecyclerView.

Eh bien il nous suffit de faire un simple test vérifiant que notre RecyclerView n'est pas vide non ? :p

Théoriquement oui ! :) Mais en pratique, pas vraiment... D'ailleurs, si vous essayez de le faire, votre test échouera systématiquement et cela est bien normal. Pourquoi ? Tout simplement car l'exécution d'un test d'UI par Espresso s'effectue immédiatement après que l'activité soit lancée. Du coup, la requête réseau n'a pas le temps de renvoyer ses résultats et donc remplir la RecyclerView... :(

En fait, c'est une affaire de mili-secondes... o_O

Afin de régler ce problème, on serait tenter d'ajouter des  Thread.sleep()  avant de réaliser notre test sur la RecyclerView, le temps que les données soient récupérées. Cependant, ce n'est pas vraiment une bonne approche, car vos tests deviendront à terme très longs à exécuter et pourront parfois échouer si la requête réseau met plus de temps à retourner ses résultats que prévu.

Pour régler ce problème, les équipes d'Android ont implémenté les "Idling Resources" sur Espresso, permettant de mettre en pause l'exécution d'un test le temps qu'une requête asynchrone ait terminé de s'exécuter. 

La seule limitation actuelle c'est que ce procédé implique une modification du code métier pour mieux gérer les tests. Certains trouveront cela affreux, d'autres acceptables. En tout cas, cette approche est pour le moment fortement recommandée par Google... ;)

Dans un premier temps, nous allons installer la librairie suivante afin d'ajouter le support des "Idling Resources".

Extrait de build.gradle :

dependencies {
    ...

    //IDLE CONCURRENT
    implementation 'com.android.support.test.espresso.idling:idling-concurrent:3.0.1'
    androidTestImplementation 'com.android.support.test.espresso.idling:idling-concurrent:3.0.1'
}

Puis, modifions le code métier afin d'indiquer clairement à Espresso à quel moment nous exécutons une requête asynchrone.

Extrait de BaseFragment.java :

public abstract class BaseFragment extends Fragment {

    // FOR TESTING
    @VisibleForTesting protected CountingIdlingResource espressoTestIdlingResource;

    ...

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        View view = inflater.inflate(this.getLayoutId(), container, false);
        ...
        this.configureEspressoIdlingResource();
        ...
    }

    ...

    @VisibleForTesting
    public CountingIdlingResource getEspressoIdlingResource() { return espressoTestIdlingResource; }

    @VisibleForTesting
    private void configureEspressoIdlingResource(){
        this.espressoTestIdlingResource = new CountingIdlingResource("Network_Call");
    }

    protected void incrementIdleResource(){
        if (BuildConfig.DEBUG) this.espressoTestIdlingResource.increment();
    }

    protected void decrementIdleResource(){
        if (BuildConfig.DEBUG) this.espressoTestIdlingResource.decrement();
    }
}

Explications : Nous déclarons dans notre  BaseFragment  l'ensemble des méthodes que nous utiliserons dans nos fragments enfants comme  MainFragment.

Nous déclarons un objet de type  CountingIdlingResource  nous permettant par la suite de déterminer si une tâche asynchrone est en train de s'exécuter.

Cet objet ressemblant fortement à un compteur dans son utilisation, nous avons créé deux méthodes permettant d'incrémenter et décrémenter celui-ci :

  • 0 = aucune tâche asynchrone n'est lancée

  • 1+ = une ou plusieurs tâches asynchrones s'exécutent

Implémentons maintenant ces deux méthodes dans notre classe MainFragment.

Extrait de MainFragment.java :

public class MainFragment extends BaseFragment {
    
    ...
    
    // -------------------
    // DATA
    // -------------------

    private void getProjects(){
        this.incrementIdleResource();
        ...
    }

    private void refreshProjects(String request){
        this.incrementIdleResource();
        ...
    }

    // -------------------
    // UI
    // -------------------

    private void updateDesign(ApiResponse projectResponse){

        this.decrementIdleResource();
        ...
    }

    ...
}

Explications : L'implémentation est ici très simple ! :D Quand nos requêtes réseaux s'exécutent, nous incrémentons notre compteur. Dès que celles-ci se terminent, nous décrémentons notre compteur. Cette technique permettra à Espresso de savoir à quel moment l'UI est disponible pour être testée.

Extrait de MainActivity.java :

public class MainActivity extends AppCompatActivity {

    ...

    // -------------------
    // TEST
    // -------------------

    @VisibleForTesting
    public CountingIdlingResource getEspressoIdlingResourceForMainFragment() {
        return this.mainFragment.getEspressoIdlingResource();
    }
}

Explications : Nous créons ici une méthode permettant de récupérer une "Idling Resource" qui nous servira uniquement pour les tests, d'où l'annotation  @VisibleForTesting  ... ;)

Et c'est tout pour cette partie ! Passons maintenant à notre test.

Extrait de MainActivityTests.java :

public class MainActivityTests {
    ...
    
    private IdlingResource mIdlingResource;

    ...
    
    @Test
    public void checkIfRecyclerViewIsNotEmpty() throws Exception {
        this.waitForNetworkCall();
        onView(ViewMatchers.withId(R.id.fragment_main_recycler_view)).perform(RecyclerViewActions.actionOnItemAtPosition(0, click()));
    }

    // ---

    private void waitForNetworkCall(){
        this.mIdlingResource = mActivityRule.getActivity().getEspressoIdlingResourceForMainFragment();
        IdlingRegistry.getInstance().register(mIdlingResource);
    }
}

Explications : Nous avons créé un test appelé  checkIfRecyclerViewIsNotEmpty  afin de vérifier que notre RecyclerView est bien vide.

Comme vous pouvez le voir, avant d'exécuter notre test Espresso, nous lançons la méthode  waitForNetworkCall  dans le but d'enregistrer notre "Idling Resource" à Espresso.

Une fois cela fait, nous n'avons plus qu'a vérifier si notre RecyclerView n'est pas vide, en tentant de cliquer sur son premier élément.

Et voilà ! Exécutez maintenant ce dernier test et appréciez le résultat ! :)

Résultat obtenu
Résultat obtenu

Conclusion

Et voilà, ce cours se termine ! J'espère que vous l'aurez apprécié et surtout bien compris... :) Vous devriez maintenant être capables de créer de superbes interfaces utilisateur ! Bravo !

Nous avons vu comment créer et appliquer des styles/thèmes visuels, tout en implémentant notre première vue personnalisée. Nous nous sommes initiés au Material Design et avons appliqué quelques-uns de ses composants dans notre mini-application, améliorant ainsi fortement l'expérience utilisateur. Et enfin, nous avons traduit notre application en plusieurs langues, appris à créer de belles animations et transitions ainsi qu'à tester tout cela grâce à des tests d'UI en utilisant Espresso.

Et comme toujours, pensez à pratiquerpratiquer et encore pratiquer, afin que coder devienne aussi naturel que respirer...

A très bientôt dans un prochain cours !

Exemple de certificat de réussite
Exemple de certificat de réussite