Aller au contenu principal

Tests

Pour des applications de moyenne à grande taille, il est critique d'écrire des tests automatisés.

Pour cela, il est important que

  • Aucun état ne soit préservé entre test/testWidgets.
    Cela signifie aucune variable globale ou de remettre à zero toutes les variables globale entre chaque test.

  • Être capable de changer le comportement par défaut de nos providers, soit via "mocks", soit en les manipulant pour obtenir un état désiré.

Voyons comment Riverpod peut nous aider pour cela.

Ne pas préserver d'état entre test/testWidgets.#

Vu que les providers sont déclarés en tant que variables globale, on pourrait s'inquiéter à ce niveau. Après tout, des variables globales pourraient rendre les tests difficile à écrire, car necessitant de complexe setUp/tearDown.

Mais dans les faits: Certes les providers sont déclarés en tant que globales, mais leur état n'est quant-à lui pas global.

À la place, il est stocké dans un objet namé ProviderContainer, que vous avez sûrement si vous avez lû les exemple de code écrit avec dart exclusivement.
Dans le cas contraire, sachez que ce ProviderContainer est implicitement crée par ProviderScope, le widget qui permet d'utiliser Riverpod dans une application Flutter.

De manière concrète, cela signifie que deux testWidgets utilisant des providers ne partagent aucun état.
Et donc, utiliser setUp/tearDown n'est pas nécessaire.

Mais un éxemple est mieux qu'une longue explication:

// Un compteur implementé et testé avec Flutter
// Note provider est déclaré de manière globale, et sera utilisé// par nos deux tests, pour verifier que l'état se remet bien à zero// entre chaque test.final counterProvider = StateProvider((ref) => 0);
// Affiche le compteur et permet de le modifierclass MyApp extends StatelessWidget {  @override  Widget build(BuildContext context) {    return MaterialApp(      home: Consumer(builder: (context, ref, _) {        final counter = ref.watch(counterProvider);        return RaisedButton(          onPressed: () => counter.state++,          child: Text('${counter.state}'),        );      }),    );  }}
void main() {  testWidgets('update the UI when incrementing the state', (tester) async {    await tester.pumpWidget(ProviderScope(child: MyApp()));
    // La valeur par défaut est `0`, comme déclaré dans notre provider    expect(find.text('0'), findsOneWidget);    expect(find.text('1'), findsNothing);
    // Incrémente le compteur et met à jour l'UI    await tester.tap(find.byType(RaisedButton));    await tester.pump();
    // Le compteur a bien changé    expect(find.text('1'), findsOneWidget);    expect(find.text('0'), findsNothing);  });
  testWidgets('the counter state is not shared between tests', (tester) async {    await tester.pumpWidget(ProviderScope(child: MyApp()));
    // Le compteur est de nouveau à `0`, sans utiliser tearDown/setUp    expect(find.text('0'), findsOneWidget);    expect(find.text('1'), findsNothing);  });}

Comme vous pouvez le voir, bien que counterProvider soit global, aucun état n'est partagé entre test. Nous n'avons donc pas à nous inquiéter de poentiels problème survenant si l'ordre de nos tests changent, car ils s'executent en complète isolation.

Surcharger le comportement d'un provider durant un test#

Un exemple typique d'application aura certainement les objets suivants:

  • Une classe Repository, qui expose une API pour faire des requêtes HTTP

  • Un objet qui manage l'état de l'application, et qui potentiellement utilise Repository pour faire des requêtes HTTP selon différentes variables. Cela pourrait être un StateNotifier, ChangeNotifier, ...

Avec Riverpod, cela pourrait être représenté par:

class Repository {  Future<List<Todo>> fetchTodos() async {}}
// Un provider qui expose une instance de Repositoryfinal repositoryProvider = Provider((ref) => Repository());
/// Une liste de tâches. Ici nous nous contentons de d'obtenir la liste de taches/// via [Repository].final todoListProvider = FutureProvider((ref) async {  // Récupère l'instance de Repository  final repository = ref.read(repositoryProvider);
  // Requête la liste des tâches  return repository.fetchTodos();});

Dans cette situation, lors de l'écriture de tests, il est commun de vouloir remplacer l'instance de Repository par une fausse implémentation qui retourne une liste prédéfinie de tâches plutôt que de faire une vrai requête HTTP.

Nous voudrons ensuite que notre todoListProvider (ou équivalent) utilise cette fausse implémentation de Repository.

Pour cela, nous pouvons utiliser le paramètre overrides de ProviderScope/ProviderContainer pour surcharger le comportement de repositoryProvider:

testWidgets('override repositoryProvider', (tester) async {  await tester.pumpWidget(    ProviderScope(      overrides: [        // Surcharge le comportement de `repositoryProvider` pour renvoyer        // une instance de FakeRepository plutôt que Repository.        repositoryProvider.overrideWithProvider(Provider((ref) => FakeRepository()))        // Il n'est pas utile de surcharger `todoListProvider`, il va automatiquement        // utiliser le nouveau comportement de `repositoryProvider`      ],      child: MyApp(),    ),  );}

Comme vous pouvez le voir par le code mis en avant, ProviderScope/ProviderContainer nous permettent de changer le comportement d'un provider.

info

Certains providers exposent une façon simplifié de changer leur comportement. Par exemple, FutureProvider permet de directement specifier une AsyncValue:

final todoListProvider = FutureProvider((ref) async => <Todo>[]);// ...ProviderScope(  overrides: [    /// Permet de surcharger un FutureProvider avec une valeur prédéfinie    todoListProvider.debugOverrideWithValue(      AsyncValue.data([Todo(id: '42', label: 'Hello', completed: true)]),    ),  ],  child: MyApp(),);

Exemple complet#

Pour conclure, voici le code complet de l'exemple.

import 'package:flutter/material.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';import 'package:flutter_test/flutter_test.dart';
class Repository {  Future<List<Todo>> fetchTodos() async {}}
class Todo {  Todo({    required this.id,    required this.label,    required this.completed,  });
  final String id;  final String label;  final bool completed;}
// Un provider qui expose une instance de Repositoryfinal repositoryProvider = Provider((ref) => Repository());
/// Une liste de tâches. Ici nous nous contentons de d'obtenir la liste de taches/// via [Repository].final todoListProvider = FutureProvider((ref) async {  // Récupère l'instance de Repository  final repository = ref.read(repositoryProvider);
  // Requête la liste des tâches  return repository.fetchTodos();});
/// Une fausse implémentation de Repository retournant une liste prédéfinie de tachesclass FakeRepository implements Repository {  @override  Future<List<Todo>> fetchTodos() async {    return [      Todo(id: '42', label: 'Hello world', completed: false),    ];  }}
class TodoItem extends StatelessWidget {  const TodoItem({Key? key, required this.todo}) : super(key: key);  final Todo todo;  @override  Widget build(BuildContext context) {    return Text(todo.label);  }}
void main() {  testWidgets('override repositoryProvider', (tester) async {    await tester.pumpWidget(      ProviderScope(        overrides: [          repositoryProvider.overrideWithProvider(Provider((ref) => FakeRepository()))        ],        // Notre application, qui va utiliser todoListProvider pour acchifer une liste de taches.        // À potentiellement extraire dans un widget "MyApp".        child: MaterialApp(          home: Scaffold(            body: Consumer(builder: (context, ref, _) {              final todos = ref.watch(todoListProvider);              // La liste de tache est en chargement ou erreur              if (todos.data == null) {                return const CircularProgressIndicator();              }              return ListView(                children: [                  for (final todo in todos.data.value) TodoItem(todo: todo)                ],              );            }),          ),        ),      ),    );
    // On verifie l'etat de chargement de la liste    expect(find.byType(CircularProgressIndicator), findsOneWidget);
    // Maintenant que la liste des tâches est obtenue, on rafraichit l'UI    await tester.pump();
    // L'UI n'affiche plus un indicateur de chargement    expect(find.byType(CircularProgressIndicator), findsNothing);
    // L'UI affiche la tache qui à été retournée par FakeRepository    expect(tester.widgetList(find.byType(TodoItem)), [      isA<TodoItem>()          .having((s) => s.todo.id, 'todo.id', '42')          .having((s) => s.todo.label, 'todo.label', 'Hello world')          .having((s) => s.todo.completed, 'todo.completed', false),    ]);  });}