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 test | Widget test | Integration test |
Function Method class | Single Widget (UI) | Tests a complete App Part of an app |
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 ?
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 :
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 :
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 :
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.
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…