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 globales, on pourrait s'inquiéter à ce niveau. Après tout, des variables globales pourraient rendre les tests difficile à écrire, car nécessitant de complexes opération de setUp et tearDown.

Dans les faits les providers sont déclarés en tant que variables globales mais leur état ne l'est pas.

À la place, il est stocké dans un objet nommé ProviderContainer, que vous avez sûrement déjà vu si vous avez lû les exemples de code pure Dart précédents.
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 des testWidgets utilisant des providers ne partagent aucun état et donc, utiliser setUp/tearDown n'est pas requis.

Mais un exemple vaut 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 modifier
class MyApp extends StatelessWidget {

Widget build(BuildContext context) {
return MaterialApp(
home: Consumer(builder: (context, ref, _) {
final counter = ref.watch(counterProvider);
return ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).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(ElevatedButton));
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 potentiels problèmes 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 Repository
final 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.overrideWithValue(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.overrideWithValue(
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 Repository
final 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 taches
class FakeRepository implements Repository {

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;

Widget build(BuildContext context) {
return Text(todo.label);
}
}

void main() {
testWidgets('override repositoryProvider', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
repositoryProvider.overrideWithValue(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.asData == null) {
return const CircularProgressIndicator();
}
return ListView(
children: [
for (final todo in todos.asData!.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),
]);
});
}