Tester l'accessibilité de votre app Flutter directement dans votre IDE

Quand on a la chance de travailler sur des applications mobiles touchant des milliers, ou des centaines de milliers d’utilisateurs (ou des millions pour les plus chanceux d’entre nous), ce sont autant d’utilisateurs différents que nous touchons. Et avec en France au moins 20 % de personnes ayant des déficiences influant sur leur utilisation des outils numériques, nous rencontrons vite un volume important de personnes et de déficiences différentes à prendre en compte.
C'est pour éviter ces discriminations numériques que les app mobiles sont soumises à obligation légale d'accessibilité. Celle-ci impose la mise en conformité à la norme européenne EN 301 549.
Le sujet est heureusement de plus en plus documenté et on voit apparaître des solutions dans la plupart de nos outils pour pouvoir répondre à la réglementation en vigueur ainsi qu’à nos utilisateurs. Par exemple, Flutter nous met à disposition la checklist suivante, affichée comme non-exhaustive, pour aider à produire des applications accessibles :

  • Interactions actives : chaque interaction doit avoir un effet visible sur l’écran.

  • Tests sur lecteurs d’écran : les applications doivent être fonctionnelles avec Talkback sur Android et VoiceOver sur iOS.

  • Ratios de contrastes : les composants interactifs, les couleurs de fonds et les couleurs de textes doivent avoir un contraste suffisant, que Flutter fixe à 4.5:1.

  • Changements de contextes : le contexte de l'utilisateur ne devrait pas changer sans qu’il ait effectué une action, encore moins lorsqu’il est en train d’interagir.

  • Zones cliquables : toutes les zones cliquables doivent être larges et hautes d’au moins 48 pixels.

  • Erreurs : les actions importantes des utilisateurs devraient être annulables, et les erreurs devraient proposer des corrections.

  • Tests de déficience en vision des couleurs : l’application doit être utilisables dans les modes de nuances de gris et d’inversion des couleurs.

  • Grossissement de police : l’application doit rester utilisable dans les modes de police et d’affichage grossis.

Appliquer ces guidelines à chacune de nos fonctionnalités demande un effort conséquent, que ce soit au niveau du développement, de la conception mais aussi des tests manuels. Et de la même manière qu’il est plus facile de résoudre un bug juste après l’avoir écrit que si on le découvre trois mois plus tard, il est plus facile de corriger un problème d’accessibilité pendant que l’on développe la fonctionnalité, qu’une fois qu’elle est mise en prod. Dans l’optique de pouvoir être plus performant, il est donc nécessaire de pouvoir réduire au minimum la boucle de feedback, et pour cette raison, nous allons creuser plus profondément les tests automatisés d’accessibilité proposés par Flutter.

Les tests automatisés avec Flutter

Avant toute chose, précisons que seuls certains points de la check-list du dessus sont testables de manière automatisée et aussi que, comme tous tests automatisés, ils ne sont pas un gage de conformité à la norme ni aux attentes d’utilisabilité. En l’occurrence nous pourrons tester ici les contrastes, les tailles des zones cliquables et les labels de ces zones cliquables pour les lecteurs d’écrans.
Nous aurons à notre disposition 4 règles venant de la classe AccessibilityGuideline :

androidTapTargetGuideline
iOSTapTargetGuideline
textContrastGuideline
labeledTapTargetGuideline

Et suivant la documentation de Flutter, nous allons simplement avoir la structure de test suivante :

void main() {
  testWidgets(
    'accessibilité de ma page',
    (WidgetTester tester) async {
      // Given
      final SemanticsHandle handle = tester.ensureSemantics();

      // When
      await tester.pumpWidget(
        NotreWidgetATester(),
      );

      // Then

      // Checks that tappable nodes have a minimum size of 48 by 48 pixels
      // for Android.
      await expectLater(tester, meetsGuideline(androidTapTargetGuideline));

      // Checks that tappable nodes have a minimum size of 44 by 44 pixels
      // for iOS.
      await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));

      // Checks that touch targets with a tap or long press action are labeled.
      await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
      
      // Checks whether semantic nodes meet the minimum text contrast levels.
      // The recommended text contrast is 3:1 for larger text
      // (18 point and above regular).
      await expectLater(tester, meetsGuideline(textContrastGuideline));
      handle.dispose();
    },
  );
}

Voilà voilà, a priori rien de bien compliqué pour tester n’importe quel Widget ! Sauf que si cela marche très bien pour des Widgets simples, ce principe peut vite paraître plus compliqué à appliquer aux Widgets que l’on rencontre sur nos projets et que l’on a rarement l’occasion d’instancier de manière aussi rapide. Et encore moins à des pages entières. Et nous, ce que l’on cherche ici, c’est une solution nous permettant d’appliquer ces tests, rapidement et simplement à des pages plus ou moins complexes, qui sont potentiellement déjà écrites.

Des tests d'accessibilité dans la vraie vie avec une solution de State Management

Nous allons maintenant nous concentrer sur une application fictive utilisant l’architecture REDUX et plus particulièrement la librairie flutter_redux.

Structure de l'application

Sans rentrer beaucoup plus dans les détails de cette partie, nous allons décrire rapidement la structure de notre application qui suit d’assez près ce qui est recommandé par la page d’accueil de la librairie.
Nous aurons tout d’abord le state suivant :

class MyState extends Equatable {
  final TypeDeState1 state1;
  final TypeDeState2 state2;

  MyState({
    required this.state1,
    required this.state2,
  });

  factory MyState.default() {
    return MyState(
      state1: TypeDeState1.default(),
      state2: TypeDeState2.default(),
    );
  }
}

Ce state sera injecté dans notre app à l'aide d'un StoreProvider à sa racine :

final store = Store<MyState>(
   myReducers,
   initialState: MyState.default(),
   middleware: myMiddlewares,
);

runApp(
   StoreProvider<MyState>(
      store: store,
      MyApp(),
   ),
);

Ensuite, chacun de nos écrans utilisera un ViewModel, qui contiendra les données ayant besoin d'être affichées à l'écran et qui se construit à partir du store :

class MyViewModel1 {
  final String label1;
  final String label2;
  
  const MyViewModel1._({
    required this.label1,
    required this.label2,
  });

  factory MyViewModel1(Store<MyState> store) {
    final state1 = store.state.state1;
    ... // on calcule les différents labels en fonction de state1
    return MyViewModel1._(
      label1: label1,
      label2: label2,
    );
  }
}

Et enfin, nos écrans peuvent récupérer ces ViewModels à partir d'un StoreConnector pour ensuite construire notre arbre de Widget :

class Ecran1 extends StatelessWidget {
  
  @override
  Widget build(BuildContext context) {
    return StoreConnector<MyState, MyViewModel1>(
       converter: (store) => MyViewModel1(store),
       builder: (context, vm) {
         return Scaffold(...);
       },
    );
  }
}

Pour résumer, nous avons donc un store contenant notre state qui est injecté à la racine de notre app, puis qui est utilisé dans chacun de nos écrans via un ViewModel pour afficher les différentes données dont on a besoin. Pour en revenir à nos tests d’accessibilité, nous ne pourrons donc pas simplement instancier notre widget Ecran1, puisqu’il a besoin pour bien s’afficher, d’un state rempli avec les bonnes données.

Création de notre premier test d'accessibilité

Afin de pouvoir créer notre Widget Ecran1, nous allons d’abord devoir instancier un state nous permettant de simuler l’état de notre écran quand on veut le tester. Si par exemple on veut tester l’accessibilité de l’écran de loading sur notre page, il nous faudra un state qui correspond à cet état de loading. Si on veut tester le cas passant, après récupération des données, il nous faudra un state correspondant également.
Par contre, seuls les sous states correspondant à notre écran auront besoin d’être instanciés puisque nous n'utilisons que Ecran1 ici. Donc si on suppose que notre Ecran1 n’a pas besoin de state2, nous pouvons avoir :

final myStateToBeTested1 = MyState(
   state1: TypeDeState1.success(label1, label2),
   state2: TypeDeState2.default(),
);

Précisons ici que si votre ViewModel est testé unitairement (ce que je vous recommande fortement), vous avez normalement déjà à votre disposition dans ces tests un ou plusieurs states que vous pouvez réutiliser ici.

Nous allons maintenant devoir encapsuler Ecran1 dans un StoreProvider, afin qu'il puisse récupérer un store. Mais comme nous n'allons pas ici utiliser d'actions, nous pouvons nous permettre de ne pas lui donner de reducers ni de middlewares, et comme son state ne changera pas, nous pouvons directement lui donner le state calculé plus haut myStateToBeTested1 :

final store = Store<MyState>(
  combineReducers<MyState>([]),
  initialState: myStateToBeTested1,
  middleware: [],
);
final notreWidgetATester = MaterialApp(
  home: StoreProvider(
    store: store,
    child: Ecran1(),
  );
);

Et voilà, notre widget est prêt à être injecté dans le test décrit dans la première partie ! Pas si compliqué finalement.

Réutilisation de ce squelette de test sur les autres écrans

Bien que la partie du dessus ne soit pas particulièrement complexe, il serait dommage de s'arrêter là. Nous donnerions des excuses à nos collègues pour ne pas écrire ce type de test sur leurs futurs écrans.

Essayons de leur simplifier la tâche en extrayant dans un widget la logique ci dessus.

class ScaffoldTestA11Y extends StatelessWidget {
  final MyState state;
  final Widget child;

  const ScaffoldTestA11Y._({
    required this.state,
    required this.child,
  });

  factory ScaffoldTestA11Y.fromStates({
    required Widget child,
    TypeDeState1? state1,
    TypeDeState2? state2,
  }) {
    return ScaffoldTestA11Y._(
      state: MyState(
        state1: state1 ?? TypeDeState1.default(),
        state2: state2 ?? TypeDeState2.default(),
      ),
      child: child,
    );
  }

  @override
  Widget build(BuildContext context) {
    final store = Store<MyState>(
      combineReducers<MyState>([]),
      initialState: state,
    );
    return MaterialApp(
      home: StoreProvider(
        store: initialStore,
        child: child,
      ),
    ); 
  }
}

Une fois que l'on a fait cette extraction, si on veut rajouter un test pour le deuxième écran, rien de plus simple, il ne nous reste plus qu'à faire :

void main() {
  testWidgets(
    'accessibilité de ma page',
    (WidgetTester tester) async {
      // Given
      final SemanticsHandle handle = tester.ensureSemantics();

      // When
      await tester.pumpWidget(
        ScaffoldTestA11Y.fromStates(
          state2: TypeDeState2.success(...),
          child: Ecran2(),
        ),
      );

      // Then
      await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
      await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
      await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));   
      await expectLater(tester, meetsGuideline(textContrastGuideline));
      handle.dispose();
    },
  );
}

Tadaaa !! Plus d’excuses maintenant ! Il ne vous reste plus qu’à rajouter au fur et à mesure les différents sous states que vous pouvez avoir dans votre application, en précisant leur état par défaut (par exemple leur état initial à la création de l’application), il vous pouvez dupliquer ce test pour chaque écran.

Attention toutefois aux erreurs que ça peut faire remonter si vos pages n’étaient pas conformes aux quatre guidelines appliquées.