Initiation aux tests Flutter

L’écriture de tests n’est pas un luxe dans le monde du développement et encore moins dans celui de l’écosystème mobile. Dès lors qu’on commence à mettre un pied dedans, on se rend assez vite compte qu’ils sont essentiels, ou devrais-je dire indispensables.
Les tests permettent de faciliter la maintenabilité du code et de minimiser la présence de bugs. Aussi, lorsqu’un projet est complexe, ils peuvent servir de documentation dans le sens où ils nous permettent de comprendre plus facilement certaines parties du code avec des cas d’usages précis.
C’est pourquoi je vous propose de vous plonger dans les fondamentaux des tests Flutter.

Initialisation

La première étape à réaliser lorsque l’on souhaite tester un projet Flutter est d’ajouter la dépendance aux packages test et integration_test. Ce sont ces packages qui vont nous donner accès à des APIs permettant l’écriture de tests standardisés.

Pour ce faire, on peut ajouter les dépendances dans le fichier pubspec.yaml :

dev_dependencies:
  integration_test:
    sdk: flutter
  flutter_test:
    sdk: flutter

Une fois les dépendances ajoutées, on peut dès à présent s’attaquer au vif du sujet et créer nos premiers tests.

Quelques types de tests possibles

Avant toute chose, il est important de savoir ce que l’on veut tester. Il existe plusieurs types de tests qui ont chacun leur propre rôle à jouer dans la maintenabilité d’une application.

Les tests unitaires

Les tests unitaires ont pour rôle de vérifier la bonne exécution de parties spécifiques d’une application en faisant varier certaines conditions. On peut ainsi tester de manière isolée un ensemble de fonctions, de classes ou encore des méthodes qui correspondent à une fonctionnalité de notre application.
Lors de ces tests, on ne se concentre qu’à la partie que l’on souhaite tester. Tout ce qui s’apparente à des dépendances externes est laissé de côté et se retrouve généralement simulé (mocked). Ainsi, on ne se préoccupe pas des interactions utilisateur, d’affichage de composants ni même de la manière dont peuvent être récupérées les données passées en entrée.

Les tests de widget

Les tests de widget permettent de tester les éléments graphiques que l’on peut retrouver dans une application. On vient alors vérifier que ce qui apparaît à l’écran est correct et que toute interaction utilisateur que l’on peut avoir avec un composant graphique se déroule comme souhaité.

Les tests d’intégration

Les tests d’intégration, quant à eux, servent à vérifier des scénarios utilisateur en simulant des interactions telles qu’elles auraient réellement lieu en utilisant l’application. Parmi les tests d’intégration possibles, on retrouve notamment les interactions entre différents widgets, l’accès à une base de données ou encore des appels à une API.
Ils ressemblent en partie aux tests de widget dans le sens où on va être amené à utiliser les mêmes fonctions du package test. La principale différence qu’on va avoir entre ces deux types de tests est que dans un test d’intégration on va vouloir tester un scénario en conditions “réelles” (en lançant l’application) alors que pour un test de widget on va se cantonner à tester le widget en question (et donc en ne lançant que ce widget).

Les golden tests

L’utilité des golden tests est de tester l’interface utilisateur et de s’assurer qu’elle ne change pas de manière inattendue au fil des développements. Ils consistent à générer des images à partir des écrans d’une application et de les comparer avec des images de références qui auront été générées au préalable.

Les fonctions à connaître pour les tests Flutter

Le package test nous met à disposition de nombreux outils pour l’écriture de tests en Flutter. Parmi ces outils, il existe une poignée de fonctions dont on ne peut se passer.

La fonction main

Une fonction qui permet de créer un environnement dans lequel on peut lancer nos tests. C’est le point d’entrée dont on a besoin pour tout environnement de tests en Flutter.

void main() {  
 // Insert tests here  
}

La fonction test

En fonction du type de test que l’on souhaite réaliser, il existe deux fonctions qui vont nous permettre de le créer :

  • La fonction test qui va nous permettre de créer un test unitaire
  • La fonction testWidgets qui va nous permettre de créer un test de Widget

La fonction testWidgets vient avec un WidgetTester qui permet de faire le lien entre les Widgets et l’environnement de test. C’est avec ce WidgetTester que l’on peut demander la création d’un widget ou bien interagir avec ce dernier. Il existe ainsi plusieurs fonctions parmis lesquelles on retrouve notamment :

  • tap : simuler le clic sur un Widget
  • pumpWidget : dessine un widget dans l’environnement de test
  • pump : reconstruit le widget associé au test
  • pumpAndSettle : appelle la fonction pump jusqu’à ce qu’il n’y ait plus de changement de vue en attente – à utiliser notamment après une interaction utilisateur pour attendre la résolution de l’action et rafraîchir la vue
void main() {  
 test('Test description', () {  
   // Write test content here  
 });  
}

La fonction expect

Une fonction qui permet de comparer un comportement observé avec un résultat attendu.
Elle prend ainsi deux paramètres :

  • actual, le comportement observé
  • matcher, le résultat attendu

Il existe plusieurs types de matcher dont certains sont spécifiques aux tests de Widget. On peut en lister quelques uns :

  • isTrue / isFalse : vérifie si actual est vrai ou faux
  • equals(value) : vérifie si actual est égal à la valeur renseignée
  • isNull / isNotNull : vérifie si actual est null ou non
  • findsOneWidget : vérifie si on trouve bien un seul Widget avec le comportement décrit par actual
  • findsNothing : vérifie si on trouve bien aucun Widget avec le comportement décrit par actual
  • findsNWidgets(n) : vérifie si on trouve bien n Widgets avec le comportement décrit par actual
void main() {  
 test('Simple test', () {  
   const actual = true;  
   const matcher = true;  
   expect(actual, matcher);  
 });

 testWidgets('Simple widget test', (tester) async {  
   await tester.pumpWidget(Container());  
   const actual = true;  
   const matcher = true;  
   expect(actual, matcher);  
 });  
}

La fonction group

Lorsqu’on veut organiser nos tests au sein d’un même fichier, il existe la fonction group. Elle nous permet de regrouper certains tests ensembles lorsqu’ils sont liés à une même fonctionnalité.
À noter, il est tout à fait possible d’imbriquer plusieurs groupes de tests avec cette fonction. Par exemple, on va pouvoir regrouper tous les tests liés à un widget dans un même groupe, puis faire un sous-groupe pour chaque type d’interaction en relation avec ce widget que l’on souhaite tester.
Cependant, il est important de préciser que la hiérarchisation des tests ne sert que si celle-ci est faite de manière suffisamment explicite. Cela permettra aux développeurs passant après nous de comprendre en un coup d'œil nos différents cas de test. C’est pourquoi, la description de chaque group et test doit être la plus claire et descriptive possible.

void main() {  
 group('Group of tests', () {  
   test('First test of the group', () {  
     // First test content  
   });

   test('Second test of the group', () {  
     // Second test content  
   });

   group('Subgroup of tests', () {  
     test('First test of the subgroup', () {  
   	// First test content  
     });

     test('Second test of the subgroup', () {  
       // Second test content  
     });  
   });  
 });  
}

Voici le lien vers la liste de toutes les fonctions disponibles dans la package test : https://api.flutter.dev/flutter/flutter_test/flutter_test-library.html

Écriture des premiers tests en Flutter

Avant de commencer à écrire nos premiers tests, il est important de noter que tous nos fichiers de tests doivent être placés dans le dossier test et avoir un nom terminant par _test.dart.

La seule exception va se trouver au niveau des tests d’intégrations qui, eux, doivent se placer dans un dossier integration_test. Ils gardent tout de même la convention de nommage des autres tests.

Les tests unitaires

On a vu que les tests unitaires servaient à tester le comportement de parties spécifiques d’une application. Pour cet exemple, on va tester le comportement d’une classe relativement simple puisqu’elle permet uniquement de gérer un compteur :

class Counter {  
 int value = 0;

 void increment() => value++;

 void decrement() => value--;  
}

Cette classe présente deux méthodes increment et decrement qui, comme leur nom l’indique, permettent de respectivement incrémenter et décrémenter la valeur de notre compteur.

Afin de pouvoir tester le comportement de cette classe, on crée un environnement de test dans lequel on va retrouver le contenu de notre tout premier test :

void main() {  
 test('Counter value should be incremented', () {  
     final counter = Counter();  
     counter.increment();  
     expect(counter.value, 1);  
   });  
}

Ce dernier nous permet de vérifier que notre méthode increment fonctionne comme souhaité, à savoir incrémenter la valeur de notre compteur de 1 à chaque utilisation.
C’est ce que l’on vérifie avec la fonction expect, on s’attend à ce que la valeur de notre compteur soit égale à 1.

Cependant, on ne souhaite pas se cantonner à tester uniquement la méthode increment. On veut également tester la méthode decrement dont le rôle est de décrémenter la valeur du compteur. Pour cela, il est possible de regrouper plusieurs tests avec la fonction group :

void main() {  
 group('Test Counter class', () {  
   test('Counter value should be incremented', () {  
     final counter = Counter();  
     counter.increment();  
     expect(counter.value, 1);  
   });

   test('Counter value should be decremented', () {  
     final counter = Counter();  
     counter.decrement();  
     expect(counter.value, -1);  
   });  
 });  
}

On peut ainsi regrouper tous les tests réalisés sur une même unité de logique afin d’avoir une meilleure organisation. Ce n’est pas le seul avantage puisque cela nous permet également de lancer ces tests avec une seule ligne de commande, ce que nous verrons en fin d’article.

Les tests de widget

Afin de pouvoir illustrer ce que sont les tests de widget il nous faut tout d’abord un widget à tester. On va donc en créer un en se basant sur la classe Counter que l’on a utilisée précédemment. Ce widget comprend deux boutons permettant respectivement d’utiliser les méthodes increment et decrement de notre classe, ainsi qu’un texte affichant la valeur courante du compteur. Voici le code de ce widget accompagné par le screenshot de son rendu :

class MyCounterWidget extends StatefulWidget {
 const MyCounterWidget({super.key});


 @override
 State<StatefulWidget> createState() => _MyCounterWidgetState();
}


class _MyCounterWidgetState extends State<MyCounterWidget> {
 final counter = Counter();


 @override
 void initState() {
   super.initState();
 }


 @override
 Widget build(BuildContext context) {
   return Column(
     mainAxisAlignment: MainAxisAlignment.center,
     children: [
       Text(counter.value.toString()),
       Row(
         mainAxisAlignment: MainAxisAlignment.center,
         children: [
           FloatingActionButton(
             onPressed: () => setState(() => counter.increment()),
             child: const Text("+"),
           ),
           const SizedBox(width: 50),
           FloatingActionButton(
             onPressed: () => setState(() => counter.decrement()),
             child: const Text("-"),
           ),
         ],
       ),
     ],
   );
 }
}

Les tests de widget permettent de vérifier leurs comportements. Ici, on veut s’assurer que la valeur du compteur est correcte, et ce, quelles que soient les interactions utilisateur.

Le premier test que l’on va faire consiste à initialiser notre widget et vérifier que la valeur du compteur affichée à l’écran est bien celle renseignée dans la classe Counter, à savoir 0.

void main() {
 testWidgets('MyCounterWidget displays the initial value', (tester) async {
   await tester.pumpWidget(const MyCounterWidget());
   final initialValue = find.text('0');
   expect(initialValue, findsOneWidget);
 });
}

Pour cela, on utilise la fonction de recherche textuelle find.text pour voir si on trouve un widget avec le texte ‘0’. Une nouvelle fois, on compare le résultat obtenu avec la fonction expect. Étant donné qu’on se trouve dans un test de Widget, on utilise le matcher findsOneWidget pour  vérifier que l’on trouve bien un unique widget correspondant à notre recherche.

On peut consolider les tests du widget MyCounterWidget en vérifiant son comportement suite aux interactions utilisateur qu’il est possible de réaliser. Dans notre exemple, on se charge de vérifier si un clic sur les différents boutons du widget induit la bonne mise à jour du compteur.

void main() {
 testWidgets('MyCounterWidget displays the right value after user interactions', (tester) async {
   await tester.pumpWidget(const MyCounterWidget());


   // Start with tap on + button
   await tester.tap(find.text('+'));
   await tester.pumpAndSettle();
   tester.pump();
   final incrementedValue = find.text('1');
   expect(incrementedValue, findsOneWidget);


   // Then deal with tap on - button
   await tester.tap(find.text('-'));
   await tester.pumpAndSettle();
   final decrementedValue = find.text('0');
   expect(decrementedValue, findsOneWidget);
 });
}

Pour cela, il faut tout d’abord trouver le widget sur lequel on souhaite cliquer. On peut alors à nouveau utiliser la fonction de recherche que l’on a utilisée dans notre premier test, à savoir find.text, pour trouver les boutons présents dans notre Widget.

Une fois les boutons trouvés, on peut réaliser le clic en faisant appel à la fonction tap. Puisqu’une interaction utilisateur a lieu, il est alors nécessaire d’attendre que le traitement qui s'ensuit soit fini avant de pouvoir visualiser la mise à jour du widget. On utilise alors la fonction pumpAndSettle.

Enfin, on vérifie la valeur de notre compteur de la même manière que dans notre premier test, avec la fonction expect. Si tout se passe bien, le test se résout après avoir trouvé le texte ‘1’ suite au clic sur le bouton ‘+’ puis le texte ‘0’ après un clic sur le bouton ‘-’.

Avant de passer à la suite de cet article, on peut s’attarder sur un détail de notre widget. On a passé à ce dernier une Key, elle sert d’identifiant à notre widget. Dans le cas présent, elle ne nous est pas vraiment utile mais il existe des situations dans lesquelles elle peut l’être. C’est notamment le cas lorsqu’on souhaite trouver un widget spécifique parmi un ensemble de widgets de même type et visuellement indiscernables (par exemple dans une ListView). On peut alors utiliser la recherche par Key (findByKey) pour trouver un widget spécifique grâce à sa Key, son identifiant.

Les tests d’intégration

Comme on l’a expliqué précédemment, les tests d’intégration permettent de vérifier le bon comportement d’une application en conditions “réelles”, c’est-à-dire en lançant non pas un widget particulier mais l’application elle-même.

Pour illustrer cela, on va prendre un nouvel exemple simple avec une application affichant un bouton qui ouvre une AlertDialog. Voilà le code et le rendu de cette application :

class MyIntegrationTestApp extends StatelessWidget {
 const MyIntegrationTestApp({super.key});


 @override
 Widget build(BuildContext context) {
   return Center(
     child: Column(
       mainAxisAlignment: MainAxisAlignment.center,
       children: [
         const Text("Integration test app"),
         const SizedBox(height: 50),
         ElevatedButton(
           onPressed: () => _showMyDialog(context),
           child: const Text('Show my custom dialog'),
         ),
       ],
     ),
   );
 }


 Future<void> _showMyDialog(BuildContext context) {
   return showDialog(
     context: context,
     builder: (context) => AlertDialog(
       title: const Text("My custom dialog"),
       content: const Text("Some content for my custom dialog"),
       actions: [
         ElevatedButton(
           onPressed: () => Navigator.pop(context),
           child: const Text("Close"),
         ),
       ],
     ),
   );
 }
}

On va ainsi s’assurer que les interactions utilisateurs avec ces composants fonctionnent bien.

void main() {
 // Init integration test environment
 IntegrationTestWidgetsFlutterBinding.ensureInitialized();


 testWidgets('Integration test', (WidgetTester tester) async {
   // Start app
   app.main();
   await tester.pumpAndSettle();


   // Check we are on main screen and verify text is displayed
   final textFinder = find.text("Integration test app");
   expect(textFinder, findsOneWidget);


   // Click on the button to show the dialog
   final showDialogButtonFinder = find.text("Show my custom dialog");
   await tester.tap(showDialogButtonFinder);
   await tester.pumpAndSettle();


   // Check the dialog is displayed on the screen
   final dialogFinder = find.byType(AlertDialog);
   expect(dialogFinder, findsOneWidget);


   // Click on the close button and verify we are back on the initial screen
   final closeButtonFinder = find.text("Close");
   await tester.tap(closeButtonFinder);
   await tester.pumpAndSettle();
   expect(textFinder, findsOneWidget);
 });
}

Avant toute chose, lorsqu’on souhaite lancer des tests d’intégration, il est nécessaire d’initialiser l’environnement de test. C’est le rôle de la méthode ensureInitialized.

Ensuite, on vient directement lancer l’application afin de pouvoir la tester dans son ensemble. On peut alors commencer notre test d’intégration. Comme ce type de test vise à vérifier les interactions utilisateur, on va reprendre les mêmes fonctions qu’on utilise pour les tests de widget.

Dans ce test, on vient donc vérifier :

  • si l’écran principal s’affiche bien lorsque l’application se lance
  • si l’AlertDialog s’affiche bien au clique sur le bouton
  • si on retourne bien sur l’écran principal lorsqu’on clique sur le bouton de la dialog

Les golden test

On va à présent s’attaquer aux golden tests afin de s’assurer que nos interfaces utilisateurs ne mutent pas de manière non intentionnelle. On va recycler notre tout premier exemple et repartir avec l’application qui gère un compteur à l’aide de deux boutons “+” et “-”. Voici le test que l’on va donc avoir :

void main() {
 testWidgets('Golden Test for MyCounterWidget', (WidgetTester tester) async {
   await tester.pumpWidget(const MyCounterWidget());


   await expectLater(
     find.byType(MyCounterWidget),
     matchesGoldenFile('golden/my_widget_golden.png'),
   );
 });
}

Dans ce test, on vient construire notre widget de la même manière qu’on peut le faire lors d’un test de widget. On vient ensuite comparer ce que le test affiche avec une image de référence qu’on aura généré au préalable. Deux nouvelles fonctions sont utilisées ici avec :

  • expectLater : identique à la fonction expect mais renvoie un Future qui s’achève à la résolution du matcher
  • matchesGoldenFile : matcher permettant de comparer un widget avec une image générée

À noter, la toute première fois qu’on lance un golden test il est nécessaire de lancer la commande suivante :

flutter test --update-goldens

On vient ainsi générer l’image de référence pour notre test. Elle sera par la suite utilisée à chaque fois qu’on va venir lancer nos golden tests. Ci-dessous, l’image générée par notre test :

On remarque que cette image n’est pas une représentation exacte de ce à quoi ressemble notre application. Le rendu a été abstractisé par la commande flutter test pour formaliser le rendu et éviter les problèmes mineurs tels que la résolution d’écran ou la présence d’effets visuels.

Comment lancer les tests

Pour un projet en Flutter, le lancement des tests passe essentiellement par la commande flutter test (ou via l’interface de l’IDE selon les préférences de chacun). Elle permet de lancer tous les tests du projet, ce qui peut s’avérer bien pratique lorsqu'on veut simplement s’assurer que le dernier développement réalisé ne “casse” rien. Aussi, on peut ajouter des options à cette commande pour ne lancer qu’une partie des tests.

Lancer tous les tests d’un projet

flutter test

Lancer tous les tests présents dans un même fichier

flutter test {test_folder_name}/{test_file_name}.dart

Lancer uniquement les tests présents dans un group donné

flutter test --plain-name "{group_name}"

Lancer un test d’intégration

On a vu plus tôt qu’on devait séparer les tests d’intégration des autres types de test en les mettant dans un autre dossier integration_test. Ce n’est pas la seule chose à mettre en place avant de pouvoir lancer nos tests d’intégrations en ligne de commande. En effet, pour pouvoir le faire on va utiliser la commande suivante :

flutter drive --driver=test_driver/integration_test.dart --target=integration_test/{integration_test_file_name}.dart

Elle va nous permettre d’exécuter nos tests d’intégration en simulant les interactions utilisateur.

Comme on peut le voir, cette commande prend deux arguments :

  • driver : le fichier qui va générer le cycle de vie du test d’intégration
  • target : le fichier contenant le test d’intégration qu’on souhaite lancer

Le fichier passé en paramètre de l’argument driver est souvent placé dans un dossier test_driver et nommé integration_test.dart. Voici son contenu :

Future<void> main() => integrationDriver();
flutter/packages/integration_test at main · flutter/flutter
Flutter makes it easy and fast to build beautiful apps for mobile and beyond - flutter/flutter

La notion de test coverage

Il est possible de générer un rapport de couverture (test coverage) avec la commande flutter test en y ajoutant l’option --coverage :

flutter test --coverage

On obtient alors un fichier lcov.info, inutilisable en l’état. C’est pour cela qu’on va passer par une commande intermédiaire permettant d’interpréter le fichier (à noter qu’il est nécessaire d’avoir installé lcov) :

genhtml coverage/lcov.info -o coverage/html

Il ne nous suffit plus que lancer cette dernière commande pour ouvrir le rapport dans le navigateur :

open coverage/html/index.html

Et voilà à quoi peut ressembler un rapport de couverture pour un projet Flutter :

Et la vue détaillée pour un fichier donné :

On peut ainsi observer quelles parties du projet font l’objet de tests, que ce soient des tests unitaires, de widget ou d’intégration. Les pourcentages indiqués représentent le nombre de lignes testées à l’échelle d’un fichier, d’un dossier ou du projet.

Un seuil de couverture bas signifie donc qu’une faible partie du code est testé. Cela peut entraîner des difficultés dans la maintenabilité d’un projet puisqu’un code non testé est un code qui n’exclut aucun bug potentiel.

Cependant, ce seuil de couverture ne donne qu’une idée de la proportion du code testé au sein du projet. Elle n’assure en rien de la qualité des tests. Il est essentiel de garder à l’esprit qu’il est préférable d’avoir des tests qualitatifs et pertinents plutôt que de chercher la couverture à tout prix. Auquel cas le développeur pourrait être amené à tricher en ne faisant que des tests visant à couvrir un maximum de lignes de code sans pour autant les tester. Par exemple, un développeur pourrait très bien écrire des tests vides, ne tester que des cas simples ou encore utiliser le mocking de manière excessive. Il est donc important de garder à l’esprit que la notion de test coverage ne doit pas servir impérativement d’objectif pour le développeur mais plutôt être un indicateur qui l’aide à visualiser l’étendue de ses tests.

Conclusion

Vous voilà maintenant fins prêts pour écrire et lancer vos premiers tests pour vos projets Flutter. Bien évidemment, cet article ne présente que les bases de ce qu’il est possible de faire et il ne vous reste désormais plus qu’à approfondir tout ce que l’on a vu pour faire évoluer et adapter votre bagage de tests en fonction de vos projets.

Pour aller plus loin, vous pouvez notamment vous pencher sur des sujets tels que :