Flutter permet de développer des applications Android, iOS, Windows, Linux, MacOS, Web. Les devices se diversifiant de plus en plus, allant du classique smartphone, au desktop jusqu’au smartphone pliable.

Face à cette diversité, la question suivante se pose :

Comment tester de façon automatisée mes interfaces ?

L’idéal serait de tester notre rendu afin de vérifier la non-régression dans le temps :

  • des composants
  • d’un écran

Mais aussi de décliner ces tests sur différentes cibles :

  • smartphone
  • tablette
  • OS
  • Light mode VS Dark Mode

Les différentes catégories de test

Aujourd’hui il existe 3 types de test en Flutter :

  • Unit test
  • Widget test
  • Integration test

Ces tests permettent de tester différentes parties de votre application :

Unit testWidget testIntegration test
Function
Method
class
Single Widget
(UI)
Tests a complete App
Part of an app
Les différentes catégories de test

Pour notre besoin, nous constatons ici que le Widget test semble être le type de test le plus approprié.

Widget test

Le widget test est plus complet qu’un test unitaire car il permet de :

  • charger un widget en boîte noire (pas besoin d’un device contrairement aux Integration test)
  • prend en compte les gestes utilisateurs (clic, saisir du texte, drag/drop, scrolling, etc…)
Écrire son premier testWidgets
void main() {
  testWidgets( 'My first testWidgets', (WidgetTester tester) async {
  // pump my custom widget
  await tester. pumpWidget (const MyCustomWidget() ) ;
  // check title widget
  final titleFinder = find. text( 'title');
  expect (titleFinder, findsOneWidget);
});

Plusieurs matchers sont disponibles dans les expect de nos testWidgets :

  • findsNothing
  • findsWidgets
  • findsNWidgets
  • matchesGoldenFile

C’est ce dernier qui va nous intéresser afin d’effectuer des vérifications de nos golden tests.

Un golden test c’est quoi ?
Prévisualisation des écrans générés avec les golden tests
Traitement des résultats d’un golden test

Par défaut, un golden test n’applique pas le rendu du texte, ni des images, car ils ne chargent pas les différentes ressources nécessaires au démarrage du test. Nous constatons que les textes sont des zones noires, et les images sont rendues par des carrés blancs.

Pour y remédier, charger les différentes ressources dans le fichier flutter_test_config.dart :

// flutter_test_config.dart
import 'dart: async';
import 'package: flutter_test/flutter_test. dart';

Future<void> testExecutable(Future0r<void> Function() testMain) async {
  TestWidgetsFlutterBinding.ensureInitialized();
  await _loadFonts();
  return testMain();
}

Le fichier flutter_test_config.dart doit être placé à la racine du répertoire test/ et peut être surchargé dans les différents sous dossier. Grâce à ce fichier, la fonction testExecutable sera exécutée avant chacun de vos tests. Ici, constatez que cette fonction s’occupe de charger les fonts grâce à la fonction _loadFonts() puis exécute le test grâce à la fonction passée en paramètre de fonction testMain.

Pour charger les différentes fonts vous devriez avoir un code similaire à ceci :

Future<void> _loadFonts() async {
  final fontManifest = await rootBundle.loadStructuredData<Iterable<dynamic>>(
    'FontManifest.json', (string) async => json.decode(string),
  );
  for (final Map<String, dynamic> font in fontManifest) {
    final fontLoader = FontLoader(_derivedFontFamily(font));
    for (final Map<String, dynamic> fontType in font[ 'fonts' ]) {
      fontLoader.addFont(rootBundle.load(fontType['asset']));
    }
    await fontLoader.load();
  }
}

String _derivedFontFamily(Map<String, dynamic> fontDefinition) {
  if (!fontDefinition.containsKey('family')) {
    return '';
  }
  final String fontFamily = fontDefinition['family'];
  if (_overridableFonts.contains(fontFamily)) {
    return fontFamily;
  }
  if (fontFamily.startsWith('packages/')) {
    final fontFamilyName = fontFamily
        .split('/')
        .last;
    if (_overridableFonts.any((font) => font == fontFamilyName)) {
      return fontFamilyName;
    }
  } else {
    for (final Map<String, dynamic> fontType in fontDefinition[' fonts']) {
      final String? asset = fontType['asset'];
      if (asset != null && asset.startsWith('packages')) {
        final packageName = asset.split('/')[1];
        return 'packages/$packageName/$fontFamily';
      }
    }
  }
  return fontFamily;
}

const List<String> _overridableFonts = [
'Roboto',
'SF UI Display',
'SF UI Text',
'SF Pro Text'.
'SF Pro Display',
];

Je suis d’accord avec vous ! Ce code est assez lourd et permet uniquement de charger les fonts de l’application. Mais pas d’inquiétude, il existe une dépendance permettant de simplifier cette syntaxe et de charger les fonts pour vous !

Golden_toolkit

La dépendance golden_tookit est développée par Ebay. Celle-ci contient un ensemble de fonctionnalités telles-que :

  • Charger des fonts
  • Afficher ou non les shadows
  • Créer des scénarios de tests sur un format d’écran spécifique
  • Créer des scénarios de tests sur plusieurs devices en parallèle
  • Créer des scénarios de tests personnalisés
  • Lancer les tests sur différents textScale pour vérifier l’accessibilité
  • etc…

Commençons par mettre en place notre premier golden test avec golden_toolkit. Il est nécessaire d’ajouter la dépendance dans le fichier pubspec.yaml grâce à la ligne de commande suivante :

flutter pub add golden_toolkit

Ou en ajoutant directement la dépendance dans le fichier pubspec.yaml de la façon suivante :

dependencies:
  golden_toolkit: ^0.14.0

Une fois la configuration de la dépendance terminée, il est nécessaire de modifier le fichier flutter_test_config.dart pour charger la dépendance golden_toolkit lors d’un testGolden :

// flutter_test_config.dart

import 'dart: async';
import 'package: flutter_test/flutter_test. dart';
import 'package: golden_toolkit/golden_toolkit.dart';

Future<void> testExecutable(Future0r<void> Function() testMain) {
  TestWidgetsFlutterBinding.ensureInitialized();
  return GoldenToolkit.runWithConfiguration(
    () async {
      await loadAppFonts();
      return testMain();
    },
    config: GoldenToolkitConfiguration(
    enableRealShadows: true,
    ),
  );
}

La fonction testExecutable exécute la fonction GoldenToolkit.runWithConfiguration provenant de la dépendance golden_toolkit. Celle-ci permet en interne d’appliquer un runZoned dart afin de fournir l’objet GoldenToolkitConfiguration à chacun des tests.

Si le concept de Zone et runZoned n’est pas clair pour vous, je vous invite à suivre la documentation suivante qui explique assez bien le principe : https://api.flutter.dev/flutter/dart-async/runZoned.html.

Écrire le golden test

Pour écrire un golden test, il faut remplacer la méthode testWidgets par testGoldens provenant de golden_tookit. Il est très important d’effectuer ce changement, car la méthode testGoldens permet de récupérer la configuration située dans le fichier flutter_test_config.dart et d’appliquer ce qui est nécessaire comme le chargement des shadows, l’application du tag sur les tests, …

Pour mon premier golden test, je vais créer différents scénarios pour un composant de mon design system et vérifier qu’il répond à différentes attentes. Mon composant bouton devra répondre aux scénarios suivants :

  • différents type de rendu
  • différents états : activé, désactivé
  • différents tailles de texte
  • avant ou sans icône

Voici le code de mon golden test :

testGoldens('Guidelines light', (tester) async {
  await tester.pumpWidgetBuilder(
    goldenBuilder.build(),
    wrapper: materialAppWrapper(
      platform: TargetPlatform.android,
    ),
    surfaceSize: screenSize,
  );
  await screenMatchesGolden(
    tester,
    'guidelines_light',
    autoHeight: true,
  );
});

Ce golden test permet de charger un widget grâce à la méthode tester.pumpWidgetBuilder. Celle-ci charge nativement une classe MaterialApp, qui est surchargée par la méthode materialAppWrapper(). Ce wrapper permet de personnaliser la plateforme cible, le thème et bien d’autres paramètres.

L’objet goldenBuilder est une instance de la classe GoldenBuilder qui permet de créer différents scénarios pour votre golden test. Dans notre cas, nous allons l’initialiser dans une grille à 3 colonnes afin de présenter le bouton de notre design system sous différents états à tester.

Pour ce test, l’instance goldenBuilder se présente de la façon suivante :

GoldenBuilder goldenBuilder = GoldenBuilder.grid(
    columns: 3,
    widthToHeightRatio: 2,
  );

for (var type in VitaminButtonType.values) {
  goldenBuilder.addScenario(
    'Scenario ${type.name}',
    CustomButton.icon(
        icon: const Icon(Icons.ten_k),
        type: type,
        onPressed: () {},
        child: Text(type.name),
    ),
  );

  goldenBuilder.addScenario(
    'Scenario ${type.name} disable',
    CustomButton(
        type: type,
        onPressed: null,
        child: Text('${type.name} disable'),
    ),
  );

  goldenBuilder.addTextScaleScenario(
    'Scenario ${type.name} text scale',
    CustomButton(
        type: type,
        onPressed: () {},
        child: Text('${type.name} x1.5'),
    ),
    textScaleFactor: 1.5,
  );
}

Pour finir, la méthode screenMatchesGolden va permettre de vérifier que notre test est conforme à l’image de référence préalablement générée.

Comment générer les images de référence ?

Tout d’abord à la racine du projet, créer le fichier dart_test.yaml permettant d’indiquer que celui-ci va contenir des tests avec le tag golden (pour rappel la méthode testGoldens l’ajoute implicitement) :

# dart_test.yaml

# timeout pour nos tests
timeout: 30m

# référencer le tag golden
tags:
  golden: 

Les images de références peuvent ainsi être générées grâce à la commande :

flutter test --update-goldens

La génération de nos images de référence va nous ajouter un répertoire /goldens à la racine du test afin d’y ajouter une image par golden test comme ceci :

Répertoire goldens généré avec les images de référence
Répertoire goldens généré avec les images de référence

Le nom d’une image générée correspond à l’information indiquée dans la fonction screenMatchesGolden. Par rapport à notre testGoldens, cela correspond à guidelines_light suffixé de l’extension png.

Voici l’image guidelines_light.png qui a été générée par la commande précédente :

Cartographie des boutons par le golden test
Image de référence générée

Maintenant les golden tests peuvent être exécutés grâce à la commande :

flutter test --tags golden

En cas de succès, cela indique qu’aucun changement n’a été apporté au composant, et donc qu’aucune régression visuelle a eu lieu.

En cas d’erreur le golden test va générer plusieurs fichiers d’erreur. Voici un exemple d’un cas erreur :

Liste des fichiers d'erreur généré par le golden test dans le répertoire failures
Répertoire failures généré

Lorsqu’un golden test produit une erreur, nous constatons que le répertoire failures est généré avec les différentes images :

  • xxx_masterImage.png : image de référence
  • xxx_testImage.png : image du test
  • xxx_maskedDiff.png : image affichant un masque avec les différentes entre les deux images
  • xxx_isolatedDiff.png : image isolant le masque des différences entre les deux images

Le fichier guidelines_light_maskedDiff.png est très intéressant, car il va nous permettre d’identifier directement parties de notre code qui ont évolué ou régressé par rapport à l’existant. Ici, nous constatons que l’évolution de mon code a opéré un changement sur mon bouton lorsqu’il a une icône.

Masque d'erreur appliqué par dessus l'image de référence généré par le golden test
Image guidelines_light_maskedDiff.png

Attention, lorsqu’un golden test échoue, cela n’indique pas forcément une erreur sur un composant. Ceci est une alerte permettant de remonter qu’une partie de notre code n’a plus le même rendu par rapport à l’image d’origine. Ceci peut être la conséquence d’une régression, suite à une règle d’affichage mal gérée ou tout simplement parce que votre composant a évolué dans le temps. Pour ce dernier, il faudra alors régénérer l’image de référence en conséquence uniquement pour ce test. Pour générer l’image de référence sur un test précis, la commande sera la suivante :

flutter test path/golden_test.dart --update-goldens

Pour conclure, n’hésitez-pas à compléter votre projet par des golden test en complément de vos tests unitaires et tests widgets. C’est très utile notamment pour vos packages UI. Par exemple, je l’exploite énormément pour mes design systems. Ces composants peuvent être soumis à différentes contraintes d’accessibilité, de tailles de texte, mode dark vs light, différentes tailles d’écran…