测试
对任何中型到大型应用,测试应用都是至关重要的。
为了成功地测试我们的应用,我们需要以下东西:
- 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),
    ]);
  });
}