Testing
Für alle mittelgroßen bis großen Anwendungen ist es wichtig, die Anwendung zu testen.
Um unsere Anwendung erfolgreich zu testen, benötigen wir die folgenden Dinge:
Es sollten keine Zustände zwischen
test
/testWidgets
reserviert sein.
Das bedeutet, dass kein globaler Zustand in der Anwendung oder alle globalen Zustände nach jedem Test zurückgesetzt werden sollten.Wir können unsere Anbieter dazu zwingen, einen bestimmten Zustand einzunehmen, entweder durch Mocking oder durch Manipulation, bis wir den gewünschten Zustand erreicht haben.
Schauen wir uns nacheinander an, wie Riverpod hier hilft.
Es sollten keine Zustände zwischen test
/testWidgets
reserviert sein.
Da Provider in der Regel als globale Variablen deklariert werden, könnten
Sie sich darüber Gedanken machen.
Schließlich macht ein globaler Zustand das Testen sehr schwierig, da er langwierige
setUp
/tearDown
erfordern kann.
Aber die Realität sieht so aus: Während Provider als globale Variablen deklariert werden, ist der Zustand eines Providers nicht global.
Stattdessen wird der Zustand in einem Objekt namens ProviderContainer gespeichert, das Sie
vielleicht schon gesehen haben, wenn Sie sich die "dart-only"-Beispiele angesehen haben.
Falls nicht, sollten Sie wissen, dass dieses ProviderContainer-Objekt implizit von
ProviderScope erstellt wird, dem Widget, das Riverpod in unserem Projekt aktiviert.
Instead, it is stored in an object named ProviderContainer, that you may have
seen if you looked at the dart-only examples.
If you haven't, know that this ProviderContainer object is implicitly created
by ProviderScope, the widget that enables Riverpod on our project.
Konkret bedeutet dies, dass zwei testWidgets
, die Provider verwenden, keinen gemeinsamen
Status haben.
Es besteht also überhaupt kein Bedarf an setUp
/tearDown
.
Aber ein Beispiel ist besser als lange Erklärungen:
- testWidgets (Flutter)
- test (Dart only)
// A Counter implemented and tested using Flutter
// We declared a provider globally, and we will use it in two tests, to see
// if the state correctly resets to `0` between tests.
final counterProvider = StateProvider((ref) => 0);
// Renders the current state and a button that allows incrementing the state
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'),
);
}),
);
}
}
void main() {
testWidgets('update the UI when incrementing the state', (tester) async {
await tester.pumpWidget(ProviderScope(child: MyApp()));
// The default value is `0`, as declared in our provider
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Increment the state and re-render
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
// The state have properly incremented
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()));
// The state is `0` once again, with no tearDown/setUp needed
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
});
}
// A Counter implemented and tested with Dart only (no dependency on Flutter)
// We declared a provider globally, and we will use it in two tests, to see
// if the state correctly resets to `0` between tests.
final counterProvider = StateProvider((ref) => 0);
// Using mockito to keep track of when a provider notify its listeners
class Listener extends Mock {
void call(int? previous, int value);
}
void main() {
test('defaults to 0 and notify listeners when value changes', () {
// An object that will allow us to read providers
// Do not share this between tests.
final container = ProviderContainer();
addTearDown(container.dispose);
final listener = Listener();
// Observe a provider and spy the changes.
container.listen<int>(
counterProvider,
listener.call,
fireImmediately: true,
);
// the listener is called immediately with 0, the default value
verify(listener(null, 0)).called(1);
verifyNoMoreInteractions(listener);
// We increment the value
container.read(counterProvider.notifier).state++;
// The listener was called again, but with 1 this time
verify(listener(0, 1)).called(1);
verifyNoMoreInteractions(listener);
});
test('the counter state is not shared between tests', () {
// We use a different ProviderContainer to read our provider.
// This ensure that no state is reused between tests
final container = ProviderContainer();
addTearDown(container.dispose);
final listener = Listener();
container.listen<int>(
counterProvider,
listener.call,
fireImmediately: true,
);
// The new test correctly uses the default value: 0
verify(listener(null, 0)).called(1);
verifyNoMoreInteractions(listener);
});
}
Wie Sie sehen, wurde counterProvider
zwar als global deklariert, aber es wurde kein Zustand
zwischen den Tests ausgetauscht.
Wir müssen uns also keine Sorgen machen, dass sich unsere Tests möglicherweise anders verhalten,
wenn sie in einer anderen Reihenfolge ausgeführt werden, da sie völlig isoliert laufen.
Überschreiben des Verhaltens eines Providers während der Tests.
Eine gewöhnliche reale Anwendung kann die folgenden Objekte haben:
Sie hat eine
Repository
-Klasse, die eine typsichere und einfache API \ zur Durchführung von HTTP-Anfragen bietet.Ein Objekt, das den Anwendungsstatus verwaltet und das
Repository
dazu verwenden kann, HTTP-Anfragen auf Basis verschiedener Faktoren durchzuführen. Dies kann einChangeNotifier
,Bloc
oder sogar ein Provider sein.
Unter Verwendung von Riverpod kann dies wie folgt dargestellt werden:
class Repository {
Future<List<Todo>> fetchTodos() async => [];
}
// We expose our instance of Repository in a provider
final repositoryProvider = Provider((ref) => Repository());
/// The list of todos. Here, we are simply fetching them from the server using
/// [Repository] and doing nothing else.
final todoListProvider = FutureProvider((ref) async {
// Obtains the Repository instance
final repository = ref.watch(repositoryProvider);
// Fetch the todos and expose them to the UI.
return repository.fetchTodos();
});
In dieser Situation, wenn wir einen Unit/Widget-Test machen, werden wir typischerweise
unsere Repository
-Instanz durch eine Mock Implementierung ersetzen wollen, die eine vordefinierte
Antwort zurückgibt, anstatt eine echte HTTP-Anfrage zu senden.
Wir möchten dann, dass unser todoListProvider
oder ein Äquivalent die gemockte Implementierung
von Repository
verwendet.
Um dies zu erreichen, können wir den Parameter overrides
von ProviderScope/ProviderContainer
verwenden, um das Verhalten von repositoryProvider
zu überschreiben:
- ProviderScope (Flutter)
- ProviderContainer (Dart only)
testWidgets('override repositoryProvider', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
// Override the behavior of repositoryProvider to return
// FakeRepository instead of Repository.
repositoryProvider.overrideWithValue(FakeRepository())
// We do not have to override `todoListProvider`, it will automatically
// use the overridden repositoryProvider
],
child: MyApp(),
),
);
});
test('override repositoryProvider', () async {
final container = ProviderContainer(
overrides: [
// Override the behavior of repositoryProvider to return
// FakeRepository instead of Repository.
repositoryProvider.overrideWithValue(FakeRepository())
// We do not have to override `todoListProvider`, it will automatically
// use the overridden repositoryProvider
],
);
// The first read if the loading state
expect(
container.read(todoListProvider),
const AsyncValue<List<Todo>>.loading(),
);
/// Wait for the request to finish
await container.read(todoListProvider.future);
// Exposes the data fetched
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),
]);
});
Wie Sie anhand des hervorgehobenen Codes sehen können, ermöglicht ProviderScope/ProviderContainer die Implementierung eines Providers durch ein anderes Verhalten zu ersetzen.
Einige Provider bieten vereinfachte Möglichkeiten, ihr Verhalten zu überschreiben.
Zum Beispiel erlaubt FutureProvider das Überschreiben des Providers mit einem AsyncValue
:
final todoListProvider = FutureProvider((ref) async => <Todo>[]);
// ...
/* SKIP */
final foo =
/* SKIP END */
ProviderScope(
overrides: [
/// Allows overriding a FutureProvider to return a fixed value
todoListProvider.overrideWithValue(
AsyncValue.data([Todo(id: '42', label: 'Hello', completed: true)]),
),
],
child: const MyApp(),
);
Die Syntax für das Überschreiben eines Providers mit dem Modifikator family
ist etwas anders.
Wenn Sie einen Provider wie diesen nutzen:
final response = ref.watch(myProvider('12345'));
Können sie den Provider so überschreiben:
myProvider('12345').overrideWithValue(...));
Beispiel für einen vollständigen Widget-Test
Abschließend finden Sie hier den vollständigen Code für unseren Flutter-Test.
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;
}
// We expose our instance of Repository in a provider
final repositoryProvider = Provider((ref) => Repository());
/// The list of todos. Here, we are simply fetching them from the server using
/// [Repository] and doing nothing else.
final todoListProvider = FutureProvider((ref) async {
// Obtains the Repository instance
final repository = ref.read(repositoryProvider);
// Fetch the todos and expose them to the UI.
return repository.fetchTodos();
});
/// A mocked implementation of Repository that returns a pre-defined list of todos
class FakeRepository implements Repository {
Future<List<Todo>> fetchTodos() async {
return [
Todo(id: '42', label: 'Hello world', completed: false),
];
}
}
class TodoItem extends StatelessWidget {
const TodoItem({super.key, required this.todo});
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())
],
// Our application, which will read from todoListProvider to display the todo-list.
// You may extract this into a MyApp widget
child: MaterialApp(
home: Scaffold(
body: Consumer(builder: (context, ref, _) {
final todos = ref.watch(todoListProvider);
// The list of todos is loading or in error
if (todos.asData == null) {
return const CircularProgressIndicator();
}
return ListView(
children: [
for (final todo in todos.asData!.value) TodoItem(todo: todo)
],
);
}),
),
),
),
);
// The first frame is a loading state.
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Re-render. TodoListProvider should have finished fetching the todos by now
await tester.pump();
// No longer loading
expect(find.byType(CircularProgressIndicator), findsNothing);
// Rendered one TodoItem with the data returned by 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),
]);
});
}