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:
- testWidgets (Flutter)
- test (Dart)
// 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);
});
}
// 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);
// Utilise mockito pour vérifier le nombre de fois que un provider notifie ses dépendences
class Listener extends Mock {
void call(int? previous, int value);
}
void main() {
test('defaults to 0 and notify listeners when value changes', () {
// Un objet qui nous permet de lire nos providers
// À ne pas partager entre test
final container = ProviderContainer();
addTearDown(container.dispose);
final listener = Listener();
// Écoute un provider
container.listen<int>(
counterProvider,
listener,
fireImmediately: true,
);
// "listener" est is appelé immediatement avec 0, la valeur par défaut
verify(listener(null, 0)).called(1);
verifyNoMoreInteractions(listener);
// On incrémente le compteur
container.read(counterProvider.notifier).state++;
// "listener" est appelé à nouveau, avec 1 cette fois
verify(listener(0, 1)).called(1);
verifyNoMoreInteractions(listener);
});
test('the counter state is not shared between tests', () {
// Nous utilisons un nouveau ProviderContainer dans ce test
// Cela assure que nos tests ne partagent pas d'état
final container = ProviderContainer();
addTearDown(container.dispose);
final listener = Listener();
container.listen<int>(
counterProvider,
listener,
fireImmediately: true,
);
// Le compteur est à nouveau à 0
verify(listener(null, 0)).called(1);
verifyNoMoreInteractions(listener);
});
}
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 HTTPUn 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 unStateNotifier
,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
:
- ProviderScope (Flutter)
- ProviderContainer (Dart)
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(),
),
);
}
test('override repositoryProvider', () async {
final container = ProviderContainer(
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`
],
);
// Obtains le premier état de la liste de tache, actuellement un chargement
expect(
container.read(todoListProvider),
const AsyncValue<List<Todo>>.loading(),
);
/// On attends que la requete finisse
await container.read(todoListProvider.future);
// La liste des taches est maintenant disponible
expect(container.read(todoListProvider).value, [
isA<Todo>()
.having((s) => s.id, 'id', '42')
.having((s) => s.label, 'label', 'Hello world')
.having((s) => s.completed, 'completed', false),
]);
});
Comme vous pouvez le voir par le code mis en avant, ProviderScope/ProviderContainer nous permettent de changer le comportement d'un provider.
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),
]);
});
}