跳到主要内容

测试

对任何中型到大型应用,测试应用都是至关重要的。

为了成功地测试我们的应用,我们需要以下东西:

  • testtestWidgets 之间不应该保留任何状态。 这意味着应用中没有全局状态,或者所有的全局状态都应该在每次测试后重置。

  • 能够强制我们的provider具有特定的状态,无论是通过模拟还是通过操纵它们直到我们达到所需的状态。

让我们来逐个看看 Riverpod 是如何帮助你处理这些问题的。

testtestWidgets 之间不应该保留任何状态。

由于provider通常声明为全局变量,你可能会担心这一点。 毕竟,全局状态使得测试非常困难,因为它可能需要漫长的 配置(setUp)销毁(tearDown)

但实际情况是虽然provider声明为全局的,但provider的状态却不是全局的。

相反,它存储在一个名为 ProviderContainer 的对象中, 如果你查看Dart的示例,你可能已经看到了这个对象。 如果你还没有看,请了解这个 ProviderContainer 对象是由 ProviderScope 隐式创建的, 这个widget可以让我们在Flutter项目中启用 Riverpod

具体来说,这意味着两个使用provider的 testWidgets 不共享任何状态。
因此,根本不需要任何 配置(setUp)销毁(tearDown)

解释这么多不如来一个例子:


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

可以看到,虽然 counterProvider 被声明为全局变量,但测试间没有共享任何状态。
因此,如果以不同的顺序执行,我们不必担心我们的测试可能表现不同,因为它们是在完全隔离的情况下运行的。

在测试期间重写provider的行为

现实中,一个常见的应用可能有以下对象:

  • 它将有一个 Repository 类,该类提供类型安全且简单的API来执行HTTP请求。
  • 一个管理应用程序状态的对象,可以使用 Repository 根据不同的因素来执行HTTP请求。 这可能是一个 ChangeNotifierBloc,甚至是一个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 的模拟实现。

为了实现这一点,我们可以使用ProviderScopeProviderContaineroverrides 参数来覆盖 repositoryProvider 的行为:


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

正如您可以从高亮的代码中看到的,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),
]);
});
}