Zum Hauptinhalt springen

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:


// 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);
});
}

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 ein ChangeNotifier, 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:


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(),
),
);
});

Wie Sie anhand des hervorgehobenen Codes sehen können, ermöglicht ProviderScope/ProviderContainer die Implementierung eines Providers durch ein anderes Verhalten zu ersetzen.

info

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(),
);
info

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),
]);
});
}