一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第10天,点击查看活动详情。
Riverpod的官方文档有多国语言,但是没有汉语,所以个人简单翻译了一版。
官网文档:Riverpod
GitHub:GitHub - rrousselGit/river_pod
Pub:riverpod | Dart Package (flutter-io.cn)
译时版本:riverpod 1.0.3
Testing
对于任何中型或大型的应用,测试应用非常重要。
要成功测试应用,我们需要以下几方面:
-
test
/testWidgets
之间不应该保持状态。这意味着在应用中没有全局的状态,或者在每个测试后所有的全局状态都应该重置。
-
能够强制 provider 拥有一个指定的状态,可通过 mock 或者 操作他们直到得到想要的状态。
让我们一步步看下 [Riverpod] 如何用这些帮助你测试。
test
/testWidgets
之间不应该保持状态。
由于 provider 通常会声明为全局变量,所以你可能会担心这一点。
毕竟,全局状态会使测试变得很困难,因为它需要冗长的 setUp
/tearDown
。
但是现实是:当 provider 声明为全局时,它的状态不是全局的。
取代的是,它是存储在称作 ProviderContainer 的对象中,可能在只用于 dart 的例子中你已经看到这一点。
如果还没看到,要知道这个 ProviderContainer 对象由 ProviderScope 隐式创建,该组件使 Riverpod 在工程中可用。
这个具体意思是,两个使用 provider 的 testWidgets
并不共享任何状态。
因此,不需要任何 setUp
/tearDown
。
不过还是例子会胜过冗长的解释:
- testWidgets (Flutter)
// 使用 Flutter 实现和测试的计数器程序
// 我们会声明一个全局 provider ,然后会在两个测试中使用它,看看能否在测试之间它的状态被正确地重置为 0 。
final counterProvider = StateProvider((ref) => 0);
// 渲染当前状态和可增加该状态的按钮
class MyApp extends StatelessWidget {
@override
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()));
// 默认值是 `0` ,就像在 provider 中声明的一样
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// 增加状态然后重新渲染
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
// 状态被正确增加
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()));
// 状态重新变为 `0` ,不需要任何 tearDown/setUp
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
});
}
- test(仅 Dart)
// 使用 Dart 实现和测试的计数器(没有 Flutter 依赖)
// 我们会声明一个全局 provider ,然后会在两个测试中使用它,看看能否在测试之间它的状态被正确地重置为 0 。
final counterProvider = StateProvider((ref) => 0);
// 当provider 通知它的监听器时,使用 mockito 追踪
class Listener extends Mock {
void call(int? previous, int value);
}
void main() {
test('defaults to 0 and notify listeners when value changes', () {
// 一个允许我们读取 provider 的对象
// 不要在测试之间共享该对象
final container = ProviderContainer();
addTearDown(container.dispose);
final listener = Listener();
// 监视 provider 并发现变化
container.listen<int>(
counterProvider,
listener,
fireImmediately: true,
);
// 紧接着使用默认值的 0 调用 listener
verify(listener(null, 0)).called(1);
verifyNoMoreInteractions(listener);
// 增加该值
container.read(counterProvider.notifier).state++;
// 再次调用 listener ,但是这次用 1 来调用
verify(listener(0, 1)).called(1);
verifyNoMoreInteractions(listener);
});
test('the counter state is not shared between tests', () {
// 我们使用不同的 ProviderContainer 读取 provider 。
// 这能确保在测试之间不会有状态被再次使用
final container = ProviderContainer();
addTearDown(container.dispose);
final listener = Listener();
container.listen<int>(
counterProvider,
listener,
fireImmediately: true,
);
// 新的测试正确使用了默认值: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 => [];
}
// 在 provider 中暴露 Repository 的实例
final repositoryProvider = Provider((ref) => Repository());
/// TODO 列表。这里我们简单地用 [Repository] 从服务器获取它们,并不做其它处理。
final todoListProvider = FutureProvider((ref) async {
// 获取 Repository 实例
final repository = ref.watch(repositoryProvider);
// 获取 TODO ,将它们暴露给 UI 。
return repository.fetchTodos();
});
这种情况下,当创建单体/组件测试时,典型地我们会想用一个返回预定义的响应替换 Repository
实例,从而代替执行真实的 HTTP 请求。
然后我们会想让 todoListProvider
或相当的对象使用 Repository
的 mock 实现。
要实现这一点,可以使用ProviderScope/ProviderContainer 的 overrides
参数覆写 repositoryProvider
的行为:
- ProviderScope (Flutter)
testWidgets('override repositoryProvider', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
// 覆写 repositoryProvider 的行为返回 FakeRepository 来代替Repository。
repositoryProvider.overrideWithValue(FakeRepository())
// 不需要覆写 `todoListProvider` ,它会自动使用覆写后的 repositoryProvider
],
child: MyApp(),
),
);
});
- ProviderContainer (仅 Dart)
test('override repositoryProvider', () async {
final container = ProviderContainer(
overrides: [
// 覆写 repositoryProvider 的行为返回 FakeRepository 来代替Repository。
repositoryProvider.overrideWithValue(FakeRepository())
// 不需要覆写 `todoListProvider` ,它会自动使用覆写后的 repositoryProvider
],
);
// The first read if the loading state
// 第一次读取加载的状态??
expect(
container.read(todoListProvider),
const AsyncValue<List<Todo>>.loading(),
);
/// 等待请求完成
await container.read(todoListProvider.future);
// 暴露获取到的数据
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),
]);
});
原文的高亮代码:
repositoryProvider.overrideWithValue(FakeRepository())
正如看到的高亮代码, ProviderScope / ProviderContainer 允许用有不同行为的 provider 实现来替换。
INFO
一些 provider 暴露了覆写 它们行为的简化方式。
例如,FutureProvider 允许覆写带 AsyncValue
的 provider :
final todoListProvider = FutureProvider((ref) async => <Todo>[]);
// ...
ProviderScope(
overrides: [
/// 允许覆写 FutureProvider 返回一个固定值
todoListProvider.overrideWithValue(
AsyncValue.data([Todo(id: '42', label: 'Hello', completed: true)]),
),
],
child: const MyApp(),
);
INFO
用 family
修饰符的 provider 的覆写语法稍微有些不同。
如果使用了如下的 provider :
final response = ref.watch(myProvider('12345'));
可以将 provider 覆写为:
myProvider('12345').overrideWithValue(...));
完整的组件测试示例
结束语,这里有 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;
}
// 在 provider 中暴露 Repository 的实例
final repositoryProvider = Provider((ref) => Repository());
/// TODO 列表。这里我们只能简单地使用[Repository]从服务器获取数据,不做其它处理。
final todoListProvider = FutureProvider((ref) async {
// 获取 Repository 实例
final repository = ref.read(repositoryProvider);
// 获取 TODO 并暴露给 UI
return repository.fetchTodos();
});
/// Repository 的 mock 实现,返回预定义的 TODO 列表
class FakeRepository implements Repository {
@override
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;
@override
Widget build(BuildContext context) {
return Text(todo.label);
}
}
void main() {
testWidgets('override repositoryProvider', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
repositoryProvider.overrideWithValue(FakeRepository())
],
// 我们的应用从 todoListProvider 读取 TODO 列表并显示。
// You may extract this into a MyApp widget
child: MaterialApp(
home: Scaffold(
body: Consumer(builder: (context, ref, _) {
final todos = ref.watch(todoListProvider);
// TODO 列表在加载或有错误
if (todos.asData == null) {
return const CircularProgressIndicator();
}
return ListView(
children: [
for (final todo in todos.asData!.value) TodoItem(todo: todo)
],
);
}),
),
),
),
);
// 第一帧是一个加载中的状态
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// 重新渲染。TodoListProvider 现在应该完成了获取 TODO
await tester.pump();
// 不再加载
expect(find.byType(CircularProgressIndicator), findsNothing);
// 用 FakeRepository 返回的数据渲染单个 TodoItem
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),
]);
});
}