测试
对任何中型到大型应用,测试应用都是至关重要的。
为了成功地测试我们的应用,我们需要以下东西:
test
和testWidgets
之间不应该保留任何状态。 这意味着应用中没有全局状态,或者所有的全局状态都应该在每次测试后重置。能够强制我们的provider具有特定的状态,无论是通过模拟还是通过操纵它们直到我们达到所需的状态。
让我们来逐个看看 Riverpod 是如何帮助你处理这些问题的。
test
和testWidgets
之间不应该保留任何状态。
由于provider通常声明为全局变量,你可能会担心这一点。
毕竟,全局状态使得测试非常困难,因为它可能需要漫长的 配置(setUp)
和 销毁(tearDown)
。
但实际情况是虽然provider声明为全局的,但provider的状态却不是全局的。
相反,它存储在一个名为 ProviderContainer 的对象中, 如果你查看Dart的示例,你可能已经看到了这个对象。 如果你还没有看,请了解这个 ProviderContainer 对象是由 ProviderScope 隐式创建的, 这个widget可以让我们在Flutter项目中启用 Riverpod。
具体来说,这意味着两个使用provider的 testWidgets
不共享任何状态。
因此,根本不需要任何 配置(setUp)
和 销毁(tearDown)
。
解释这么多不如来一个例子:
- 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);
});
}
可以看到,虽然 counterProvider
被声明为全局变量,但测试间没有共享任何状态。
因此,如果以不同的顺序执行,我们不必担心我们的测试可能表现不同,因为它们是在完全隔离的情况下运行的。
在测试期间重写provider的行为
现实中,一个常见的应用可能有以下对象:
- 它将有一个
Repository
类,该类提供类型安全且简单的API来执行HTTP请求。 - 一个管理应用程序状态的对象,可以使用
Repository
根据不同的因素来执行HTTP请求。 这可能是一个ChangeNotifier
,Bloc
,甚至是一个provider。
使用 Riverpod,可以这样表示:
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();
});
在这种情况下,当进行单元测试或widget测试时,
我们一般希望用一个返回预定义响应的伪实现来替换 Repository
实例,而不是发出真正的HTTP请求。
然后,我们希望我们的 todoListProvider
或类似的组件使用 Repository
的模拟实现。
为了实现这一点,我们可以使用ProviderScope 或 ProviderContainer的 overrides
参数来覆盖 repositoryProvider
的行为:
- 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),
]);
});
正如您可以从高亮的代码中看到的,ProviderScope/ProviderContainer 允许用不同的行为替换provider的实现。
一些provider暴露了重写其行为的简化方法。
例如,FutureProvider 允许使用 AsyncValue
重写provider:
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(),
);
注意:作为2.0.0版本的一部分, overrideWithValue
方法被暂时移除。
它们将在未来的版本中重新添加。
使用 family
修饰符覆盖provider的语法略有不同。
如果你像这样使用provider:
final response = ref.watch(myProvider('12345'));
你可以这样覆盖provider:
myProvider('12345').overrideWithValue(...));
完整的widget测试用例
最后,这里是我们Flutter测试的完整代码。
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),
]);
});
}