[官文翻译]Flutter状态管理库Riverpod - 指南 - 测试

1,022 阅读6分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 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),
    ]);
  });
}