Flutter & les tests automatisés (widget, unit, e2e, golden)

Pourquoi tester

Intégrer des tests à notre code présente plusieurs avantages, notamment l'amélioration de sa qualité et de sa fiabilité. Cependant, la raison la plus essentielle réside dans le fait que les tests nous permettent d'avoir confiance lors de l'ajout de nouvelles fonctionnalités ou de la refactorisation, en garantissant le bon fonctionnement de notre application.

Les types de tests

Sans rentrer dans les détails, car ce n’est pas le but de l’article, il est important de mentionner les différents types de tests qui sont adaptés pour différents cas d’utilisation.

Tests statiques

Les tests statiques sont effectués sans nécessiter l’exécution du code. Cela permet de détecter très rapidement et à moindre coût les fautes de frappe, les typages incorrect, etc…
Le langage Dart étant typé, ce type d’erreur sera remonté très tôt dans votre IDE.

Un linter est fourni par défaut lors de l’installation du SDK, mais il ne permet pas de créer ses règles. D’autres librairies permettant cela existent cependant (custom_lint par exemple).

Tests unitaires

Comme leur nom l’indique ils permettent de tester unitairement une petite partie du code (une fonction, une classe, un widget…). Ils sont simples à mettre en œuvre et sont exécutés très rapidement.

Tests d’intégration

Les tests d’intégration permettent de valider que plusieurs widget, entités de code interagissent entre eux correctement.

Tests E2E

Les tests E2E (end-to-end) ou fonctionnels sont beaucoup plus poussés, ils simulent dans un environnement complet des actions utilisateurs. Ils permettent de tester des scénarios précis de l’application (authentification, consultation d’un solde, soumission d’un formulaire…).

Les golden tests, nous en reparlerons plus bas dans l’article, font parties des e2e. Il faut dans un premier temps lancer des tests avec des valeurs d’entrées qui nous permettront d’avoir un résultat de référence. Par la suite il suffira de comparer les résultats des nouveaux tests avec ceux de référence.

Tests manuels

Enfin, les tests manuels, qui peuvent être coûteux en temps mais permettent de valider humainement la livraison d’une nouvelle fonctionnalité ou prévenir d’une potentielle régression.

Mise en place de tests unitaires

Ecriture des tests

Nativement Flutter possède des outils pour réaliser des tests unitaires, il suffit d’ajouter le package flutter_test dans les dev_dependencies du fichier pubspec.yaml.

Par convention tous les fichiers de tests doivent être suffixés par _test.dart.

Les deux principales fonction fournies par le package sont les fonctions test et expect.

Ci dessous un exemple d’un fichier de test permettant de s’assurer que les fonctions de conversion Celsius vers Fahrenheit et Fahrenheit vers Celsius fonctionnent correctement.

void main() {
    group('Temperature converter', () {
      test('Convert celsius to fahrenheit', () {
        expect(TemperatureConverter.celsiusToFahrenheit(32), 89.6);
        expect(TemperatureConverter.celsiusToFahrenheit(100), 212);
      });

      test('Convert fahrenheit to celsius', () {
        expect(TemperatureConverter.fahrenheitToCelsius(428), 220);
        expect(TemperatureConverter.fahrenheitToCelsius(99.5), 37.5);
      });
    });

La fonction expect prend en paramètre la valeur de que l’on souhaite tester et en second la valeur que doit prendre notre test pour réussir.
A noter qu’il est possible de regrouper des tests liés entre eux avec la méthode group.

Pour plus d’information sur le sujet vous pouvez vous référer au Cookbook Flutter.

Lancer les tests

L’exécution des tests se fait très simplement en lançant la méthode flutter test.

Générer un rapport de code coverage

Flutter propose de générer un rapport de couverture de codes nativement en ajoutant l’option --coverage à la commande flutter test. Un fichier lcov.info sera alors généré dans un dossier /coverage à la racine du projet.

Il faudra ensuite installer un outil type genhtml pour convertir notre fichier lcov.info en beau rapport HTML: genhtml coverage/lcov.info -ocoverage/report

Il manque actuellement la fonctionnalité de couverture de branche qui permet de valider que nos tests sont passés par chaque point de décision dans notre code (instruction conditionnelle “if” ou “switch”). Cependant l'équipe Flutter travaille sur ce sujet: https://github.com/dart-lang/coverage/issues/141

Mocking

Quand on fait des tests unitaires, l’objectif est de tester chaque partie du code pour s’assurer que chacune d’elle fonctionne comme elle le doit. Pour aller dans ce sens, une bonne pratique consiste à découper son code en plus petit bouts de manière à ce que chacun d’eux ait un rôle bien précis (voir le principe de responsabilité unique).

Prenons un exemple simple : je veux que mon code puisse faire un appel Http GET sur une api pour obtenir des informations à partir d’une data. L’image suivante représente la base de construction de notre code :

L’objectif est de pouvoir tester le comportement de notre data source qui fait appel à notre client Http, et le comportement de notre repository qui fait appel à notre data source.

Voici le code des différentes classes utilisées:

my_entity.dart

class MyEntity {
    const MyEntity({
      required this.id,
      required this.label,
      required this.description,
      required this.isCool,
    });

    final int id;
    final String label;
    final String description;
    final bool isCool;
 }

my_model.dart

import 'my_entity.dart';

class MyModel extends MyEntity {
  const MyModel({
    required int id,
    required String label,
    required String description,
    required bool isCool,
  }) : super(
          id: id,
          label: label,
          description: description,
          isCool: isCool,
        );

  factory MyModel.fromJson(Map<String, dynamic> json) {
    return MyModel(
      id: json['id'] ?? 0,
      label: json['label'] ?? '',
      description: json['description'] ?? '',
      isCool: json['isCool'] ?? false,
    );
  }
}

data_source.dart

import 'dart:convert';
import 'package:http/http.dart';
import 'my_model.dart';

class DataSource {
  DataSource(this._client);

  final Client _client;

  Future<MyModel> getEntityById({
    required int id,
  }) async {
    final url = Uri.https('my_api.com', 'entities/$id');
    final Response response = await _client.get(url);
    return MyModel.fromJson(jsonDecode(response.body));
  }
}

repository.dart

import 'data_source.dart';
import 'my_entity.dart';

class Repository {
  Repository(this._dataSource);

  final DataSource _dataSource;

  Future<MyEntity> getEntityById({
    required int id,
  }) async {
    try {
      final MyEntity entity = await _dataSource.getEntityById(id: id);
      return entity;
    } catch (error) {
      rethrow;
    }
  }
}

Il est important ici d’observer la construction de notre data source et de notre repository. En effet le data source est construit avec une instance de client http, et le repository est construit avec une instance de data source. Cette façon de construire ces deux classes va nous permettre de mocker le comportement des instances transmises aux classes que l’on veut tester. Un mock a pour rôle d'étendre une classe existante et de reproduire le comportement de cette dernière de manière contrôlée.

Maintenant que nous avons construit notre code à tester, il nous faut une solution pour réaliser le mocking de nos classes. Pour les tests Flutter, il existe deux plugins soutenus par la communauté : mockito et mocktail. Nous avons préféré utiliser mocktail car il présente les mêmes fonctionnalités que mockito sans avoir besoin de générer des fichiers.

La première chose à faire, c’est de tester notre data source en mockant notre client Http qui est en bout de chaine. Pour se faire, nous avons créé un fichier json correspondant à ce qui serait obtenu après décodage du body d’une response 200 :

{
  "id": 1234,
  "label": "Awesome Entity",
  "description": "The best entity of all time",
  "isCool": true
}

Puis nous avons créé un helper qui nous permet de lire ce fichier, d’obtenir son contenu en String ou en Map :

import 'dart:convert';
import 'dart:io';

abstract class TestResourcesHelper {
  static File getFile(String name) => File('test/test_resources/$name');

  static Future<String> getFileAsString(String fileName) async =>
      getFile(fileName).readAsString();

  static Future<Map<String, dynamic>> getJsonMock(String fileName) async =>
      jsonDecode(await getFileAsString(fileName));

  static Future<String> getEntityString() =>
      getFileAsString('entity_mock.json');

  static Future<Map<String, dynamic>> getEntityJson() =>
      getJsonMock('entity_mock.json');
}

Nous pouvons maintenant tester notre data source et mocker le comportement de notre client Http :

import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart';
import 'package:mocktail/mocktail.dart';
import 'package:untitled/data_source.dart';
import 'package:untitled/my_model.dart';

import 'test_resources.helper.dart';

// Création de notre classe client Http mockée via mocktail
class MockClient extends Mock implements Client {}

void main() {
  late DataSource dataSource;
  late MockClient mockClient;
  int id = 1234;
  final url = Uri.https('my_api.com', 'entities/$id');

  // La méthode setup est appelée avant chaque test
  // Chaque instance créée ici est donc unique pour chacun de nos test
  setUp(() {
    mockClient = MockClient();
    dataSource = DataSource(mockClient);
  });

  group('Test: data source', () {
    test('Success datasource get entity case', () async {
      final String entityAsString = await TestResourcesHelper.getEntityString();

      // Quand la méthode get est appelée, notre instance mockée renverra
      // une réponse 200 et un body contenant les infos voulu
      when(
        () => mockClient.get(url),
      ).thenAnswer(
        (_) async => Response(entityAsString, 200),
      );

      // On récupère la réponse
      final dynamic response = await dataSource.getEntityById(id: id);
      // on test l'obtention d'une instance de MyModel
      expect(response is MyModel, true);
    });

    test('Error datasource get entity exception', () async {
      // Ici notre instance mockée renverra toujours une exception
      when(
        () => mockClient.get(url),
      ).thenAnswer(
        (_) async => throw Exception(),
      );

      // On test que notre datasource rethrow une exeption
      expect(
        dataSource.getEntityById(id: id),
        throwsA(isA<Exception>()),
      );
    });
  });
}

Le plus important ici est le mot clé when qui permet de définir le comportement que notre instance mockée doit adopter pendant le test. Dans le premier test on vérifie que notre data source renvoie bien une instance MyModel après avoir fait appel au client Http. Dans le deuxième test on vérifie que si le client http soulève une erreur, notre data source la soulèvera aussi pour la propager au reste du code.

Maintenant voici le test de notre repository :

import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:untitled/data_source.dart';
import 'package:untitled/my_entity.dart';
import 'package:untitled/my_model.dart';
import 'package:untitled/repository.dart';

import 'test_resources.helper.dart';

// Création de notre datasource mocké via mocktail
class MockDataSource extends Mock implements DataSource {}

void main() {
  late MockDataSource mockDataSource;
  late Repository repository;
  int id = 1234;

  setUp(() {
    mockDataSource = MockDataSource();
    repository = Repository(mockDataSource);
  });

  group('Test: repository', () {
    test('Success repository get entity case', () async {
      final Map<String, dynamic> json =
          await TestResourcesHelper.getEntityJson();
  
      // On définit le comportement de notre datasource mocké
      // il renverra ici une instance de MyModel
      when(
        () => mockDataSource.getEntityById(id: id),
      ).thenAnswer(
        (_) async => MyModel.fromJson(json),
      );

      final dynamic response = await repository.getEntityById(id: id);

      // On test l'obtention d'une instance MyEntity
      expect(response is MyEntity, true);
    });

    test('Error repository get entity exception', () async {
      // On définit le comportement de notre datasource mocké
      // il renverra ici une exception
      when(
        () => mockDataSource.getEntityById(id: id),
      ).thenAnswer(
        (_) async => throw Exception(),
      );

      // On test que notre repository rethrow une exeption
      expect(
        repository.getEntityById(id: id),
        throwsA(isA<Exception>()),
      );
    });
  });
}

La logique reste exactement la même : on veut mocker notre data source pour pouvoir créer un scénario de succès (avec l’obtention d’une instance de MyEntity) et un d’erreur qui soulèvera une exception.

Dans notre exploration nous avons réalisé des tests très simples du type OK & KO. On retiendra qu’il est possible de mocker une partie du code pour pouvoir tester celle qui nous intéresse en créant différents scénarios d’intérêt.

Mise en place de tests E2E

Environnement et contexte

Tout comme les tests unitaires, Flutter intègre nativement les tests E2E à la seule différence qu’en plus du package flutter_test dans les dev_dependencies du fichier pubspec.yaml il faut aussi ajouter integration_test.

Les tests doivent être rangés dans le répertoire integration_test/, et seront exécutés ceux suffixé par _test.dart (cette convention est décrite dans la documentation Integration testing de Flutter).

Ainsi, la suite de cet article décrit nos premiers pas dans le monde des tests E2E avec Flutter. Pour ce faire, nous avons décidé de tester l’application de compteur proposée par le package flutter_bloc.

Écriture des tests

Premièrement, nous avons définis nos scénarios : un test d’incrément et de décrément du compteur.

Dans chacun des cas le code teste :

·      la présence d’un widget Text représentant le compteur et qu’il est initialisé à 0

·      la présence du widget d’incrément ou de décrément (FloatingActionButton)

·      la simulation de l’appui sur ce bouton

·      la vérification du changement d'état du compteur, affichant 1 ou -1 en fonction de l’action précédente.

Dans le cas du test de la fonctionnalité de décrément, le code est le suivant :

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('end-to-end test', () {
    testWidgets('Decrement by 1, starting at 0',
        (tester) async {
      app.main();
      // Trigger a frame.
      await tester.pumpAndSettle();

      // Verify the counter starts at 0.
      expect(find.text('0'), findsOneWidget);

      // Finds the floating action button to tap on.
      final Finder fab = find.byIcon(Icons.remove);

      // Emulate a tap on the floating action button.
      await tester.tap(fab);

      // Trigger a frame.
      await tester.pumpAndSettle();

      // Verify the counter decrements by 1.
      expect(find.text('-1'), findsOneWidget);
    });
  });
}

Si l’on décompose cet exemple, on comprend que le test est joué dans un main. Il est également impératif d’ajouter la ligne 2 afin d’assurer sa correcte initialisation. Ligne 4, un group de test est instancié. La suite permet de tester les widgets de notre application en exécutant un testWidgets. Cette méthode offre le lancement de notre application (ligne 7) suivi des tests décrits.

Des informations détaillées sur le rôle méthodes de tests utilisées sont disponibles dans cette documentation Flutter.

Lancer les tests

Afin d'éviter de build une application à chaque exécutions d’un de nos fichiers de tests (fichier d’incrément et de décrément), nous avons organisé notre répertoire integration_test comme suit :

integretion_test/

     |_ main_test.dart

     |_ single_decrement.test.dart

     |_ single_increment.test.dart

Seul main_test.dart sera exécuté pour lancer nos tests. Effectivement, ce dernier est codé afin de jouer le main de chacun des deux autres fichiers avec ce script :

import 'package:integration_test/integration_test.dart';
import 'single_decrement.test.dart' as decrement;
import 'single_increment.test.dart' as increment;


void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();
  decrement.main();
  increment.main();
}

De cette manière on build une application et nos tests sont joués les uns après les autres. Tests qui sont exécutés à partir de l’onglet Testing de Visual Studio Code :

Mise en place des tests golden

Les tests golden sont nativement pris en charge par le framework de test de Flutter.

Il suffit donc d’ajouter la dépendance au package flutter_test dans les dev_dependencies du fichier pubspec.yaml.

Écriture des tests

La gestion des golden tests est possible à travers la fonction matchesGoldenFile. Ce matcher est utilisé dans une méthode expectLater comme suit :

final finder = find.byIcon(Icons.add);

await expectLater(finder, matchesGoldenFile('goldens/vanilla/magical_button_unpressed.png'));

L’appel à la méthode matchesGoldenFile génère le fichier indiqué en paramètre.

Dans notre cas, nous obtenons le fichier suivant :

On remarque que les textes et icones de l’application sont remplacés par des placeholders par la librairie. En fait, Flutter choisi par défaut de remplacer toutes les fonts utilisées dans l’application par une font unique nommée “ahem”. Cette font embarque un seul et unique caractère, un carré, qui remplacera tous les caractères et icones affichés dans l’application testée.

Ceci a été mis en place pour éviter d’avoir des faux négatifs lorsque l’application est lancée sur des devices différents, le rendu d’une font pouvant varier.

Librairie golden_toolkit

Si vous souhaitez malgré tout embarquer les fonts dans vos tests, vous pouvez embarquer la librairie golden_toolkit qui offre cette possibilité.

La librairie permet d’afficher dans les tests les textes rendus avec les bonnes polices.

Cependant, d’une plateforme à l’autre, des différences peuvent apparaitre, il faudra donc utiliser cette option avec du recul.

Conclusion

En une après midi, nous avons pu mettre en place les différents types de tests existants sur Flutter, la plupart ne nécessitent pas d’ajouter de librairies spécifiques.

Nous avons notés quelques points perfectibles :

·      l’algorithme de détection de la couverture de code n’est pas optimal,

·      la gestion native des tests golden ne prend pas en compte les polices de caractère embarquées

Cependant, les tests sont simples à mettre en place et nous ont satisfaits.

A noter que, si nos tests ont montrés des performances acceptables, il est notoirement connu que l’exécution d’un grand nombre de tests nécessitera un certain temps et pourrait demander des optimisations sur le volume exécuté par exemple.

Simon Mahé – Lead Developer web / mobile

Simon Bernardin – Lead Developer Web / Mobile

Quentin Lebreton – Développeur Flutter

Guillaume Camanes – Lead Developer Flutter

Bastien CIVEL – Développeur web / mobile