如何在Flutter中使用流匹配器和谓词编写测试

204 阅读8分钟

几乎每个Flutter应用程序都需要与异步数据打交道。

一个常见的例子是,当一个部件的状态因异步操作(初始数据→加载→成功或失败)而改变时。

而我们可以用各种方式来表示一个随时间变化的状态

  • ChangeNotifierValueNotifier (实现Listenable )。
  • 用一个Stream
  • 与其他一些不可变的状态类,这些状态类使用StateNotifier (使用Riverpod)来控制

特别是,在编写异步测试时,流是非常方便的,Flutter提供了一些丰富和富有表现力的测试API,我们可以使用。

因此,让我们详细看看这些API,了解一下。

  • 如何观察和测试程序内部的状态变化StateNotifier
  • 如何使用流匹配器谓词
  • expect 和 之间的区别expectLater
  • 使用流时的一些常见陷阱(以及如何避免它们)

用StreamMatcher APIs匹配流事件

假设我们想为这个流写一个测试。

test('stream example', () {
  final stream = Stream.fromIterable([
    'Ready.',
    'Loading took 5 seconds',
    'Succeeded!',
  ]);
  // TODO: write expectation
}

在这种情况下,我们可以使用 expect函数来检查我们的流是否发出了我们期望的值。

expect(
  stream,
  emitsInOrder([    'Ready.',    'Loading took 5 seconds',    'Succeeded!'  ]),
);

这可以用 emitsInOrder来完成,这是一个函数,返回一个 StreamMatcher.

下面是一个更有趣的例子,基于 StreamMatcher文档的一个更有趣的例子。

test('stream example', () {
  final stream = Stream.fromIterable([
    'Ready.',
    'Loading took 5 seconds',
    'Succeeded!',
  ]);
  expect(
    stream,
    emitsInOrder([
      // Values match individual events.
      'Ready.',

      // Matchers also run against individual events.
      startsWith('Loading took'),

      // Stream matchers can be nested. This asserts that one of two events are
      // emitted after the "Loading took" line.
      emitsAnyOf(['Succeeded!', 'Failed!']),

      // By default, more events are allowed after the matcher finishes
      // matching. This asserts instead that the stream emits a done event and
      // nothing else.
      emitsDone
    ]),
  );
});

所有这些匹配器让我们检查一个Stream 。 而且它们在现实世界的场景中非常有用。

让我们看看吧!👇

一个使用StateNotifier的真实世界场景

假设我们有这样一个 StateNotifier子类,我们可以用它来管理一个SignInScreen 小部件的状态。

import 'package:flutter_riverpod/flutter_riverpod.dart';

class SignInScreenController extends StateNotifier<AsyncValue<void>> {
  SignInScreenController({required this.authRepository})
      : super(const AsyncData(null));
  final AuthRepository authRepository;

  Future<void> signInAnonymously() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(authRepository.signInAnonymously);
  }
}

这个类包含一个叫做signInAnonymously() 的方法,我们可以用来。

  • 设置一个加载状态
  • 匿名登录(使用作为依赖关系的AuthRepository )。
  • 更新状态为成功 (AsyncData) 或错误 (AsyncError) ,通过使用AsyncValue.guard

如果你对AsyncValue.guard 不熟悉,请阅读这个。在你的StateNotifier子类中使用AsyncValue.guard而不是try/catch

让我们看看如何为这个类写一些单元测试。

使用Mocktail包添加单元测试

为了测试我们的SignInScreenController ,我们需要创建一个MockAuthRepository ,使用 mocktail包创建一个。

import 'package:mocktail/mocktail.dart';

class MockAuthRepository extends Mock implements AuthRepository {}

然后,我们可以为签到成功的情况写一个测试。

test('signInAnonymously succeeds', () async {
  // setup
  final authRepository = MockAuthRepository();
  // stub -> success
  when(authRepository.signInAnonymously).thenAnswer(
    (_) => Future.value(),
  );
  final controller = SignInScreenController(authRepository: authRepository);
  // run
  await controller.signInAnonymously();
  // verify
  verify(authRepository.signInAnonymously).called(1);
  expect(controller.debugState, const AsyncData<void>(null));
});

该测试的工作原理如下。

  • 设置阶段,我们存根signInAnonymously() 方法,返回Future.value() (成功)。
  • 运行阶段,我们在控制器上调用该方法。
  • 验证阶段,我们检查signInAnonymously() ,并将控制器的debugState预期值进行比较。

请注意,我们没有直接访问控制器的状态。相反,我们使用 debugState属性,它只用于开发(在编写测试时是理想的)。

如果我们运行这个测试,我们可以看到它是成功的。✅

然而,我们的测试只检查了最终的状态(AsyncData),但并没有验证我们尝试登录之前是否将其设置为AsyncLoading

Future<void> signInAnonymously() async {
  // We're not testing this
  state = const AsyncLoading(); // the test would still pass if we comment this out
  // We're testing this
  state = await AsyncValue.guard(authRepository.signInAnonymously);
}

如果有一种方法可以检查状态如何随时间变化就好了。

事实证明。 StateNotifier有一个 stream属性,我们正好可以用于这个目的。👌

使用流进行测试

这是更新后的测试,使用controller.stream 来检查所有的预期状态。

test('signInAnonymously succeeds', () async {
  // setup
  final authRepository = MockAuthRepository();
  when(authRepository.signInAnonymously).thenAnswer(
    (_) => Future.value(),
  );
  final controller = SignInScreenController(authRepository: authRepository);
  // run
  await controller.signInAnonymously();
  // verify
  verify(authRepository.signInAnonymously).called(1);
  expect(
    controller.stream,
    emitsInOrder(const [
      AsyncLoading<void>(),
      AsyncData<void>(null),
    ]),
  );
});

不幸的是,如果我们运行这个测试,我们在30秒后得到一个超时。

TimeoutException after 0:00:30.000000: Test timed out after 30 seconds.

为什么会出现这种情况?🧐

使用 expect 与 expectLater 进行测试

让我们记住,流是随着时间的推移而发射数值的,这意味着当我们调用expect ,所有的数值都已经被发射出去了,这时再去检查它们已经太晚了。

相反,我们需要我们的流发出任何值之前开始观察它。

换句话说,我们需要调用controller.signInAnonymously()之前预期我们的流会发出一些值。

test(
  'signInAnonymously succeeds',
  () async {
    // setup
    when(authRepository.signInAnonymously).thenAnswer(
      (_) => Future.value(),
    );
    // expect later
    expectLater(
      controller.stream,
      emitsInOrder(const [
        AsyncLoading<void>(),
        AsyncData<void>(null),
      ]),
    );
    // run
    await controller.signInAnonymously();
    // verify
    verify(authRepository.signInAnonymously).called(1);
  },
  timeout: const Timeout(Duration(milliseconds: 500)),
);

这段代码使用了 expectLater函数,因为 emitsInOrder是一个异步匹配器

根据文档的内容。

expectLater的工作原理和expect一样,但它返回一个Future,当匹配器完成匹配时,它就完成了。

请注意,在这种情况下,等待 expectLater() 返回是不正确的,因为这将导致测试挂起(并最终超时),因为我们需要在任何数值被添加到流中之前调用controller.signInAnonymously()

事实上,我还添加了一个明确的500毫秒的超时。这保证了如果调用expectLater() ,在等待所有流匹配器返回时挂起,测试将快速失败

关于超时的更多信息,包括如何为单个文件中的所有测试设置相同的超时,请阅读这个。如何在Flutter中添加一个自定义测试超时

一般来说,如果我们运行被测试的代码之前调用expectLater() ,我们应该等待它的返回。另一方面,我们可以测试的最后使用await expectLater() 。一个常见的情况是,在编写金色图像测试时,使用 matchesGoldenFile匹配器编写Golden图像测试。

为错误案例添加单元测试

到现在为止,我们只测试了authRepository.signInAnonymously 成功的情况。

但我们也应该检查它失败时的情况。让我们为这个添加一个测试。

test(
  'signInAnonymously fails',
  () async {
    // setup
    final exception = Exception('Connection failed');
    // note: this time we throw an exception
    when(authRepository.signInAnonymously).thenThrow(exception);
    // expect later
    expectLater(
      controller.stream,
      emitsInOrder([
        const AsyncLoading<void>(),
        // note: this time we check that the state is AsyncError
        AsyncError<void>(exception),
      ]),
    );
    // run
    await controller.signInAnonymously();
    // verify
    verify(authRepository.signInAnonymously).called(1);
  },
  timeout: const Timeout(Duration(milliseconds: 500)),
);

在这种情况下,我们存根authRepository.signInAnonymously ,抛出一个异常,并检查流是否会发出一个AsyncError

然而,我们的测试以这个错误而失败。

Expected: should do the following in order:
          • emit an event that AsyncLoading<void>:<AsyncLoading<void>()>
          • emit an event that AsyncError<void>:<AsyncError<void>(error: Exception: Connection failed, stackTrace: null)>
  Actual: <Instance of '_BroadcastStream<AsyncValue<void>>'>
   Which: emitted • AsyncLoading<void>()
                  • AsyncError<void>(error: Exception: Connection failed, stackTrace: #0 ... )>

在这种情况下,预期值和实际值都会发出AsyncLoading<void> ,然后是AsyncError<void> 。然而。

  • 预期值有一个空的堆栈跟踪
  • 实际值有一个非空的堆栈跟踪

这是因为AsyncValue.guard 在创建输出AsyncError 状态时,会同时捕获异常和堆栈跟踪

// here's how AsyncValue.guard is implemented:
abstract class AsyncValue<T> {
  static Future<AsyncValue<T>> guard<T>(Future<T> Function() future) async {
    try {
      return AsyncValue.data(await future());
    } catch (err, stack) {
      // both error and stack trace are included in the return value
      return AsyncValue.error(err, stackTrace: stack);
    }
  }
}

// both the error and stack trace are stored if the call fails
state = await AsyncValue.guard(authRepository.signInAnonymously);

但是在我们的测试中,我们无法创建一个具有匹配堆栈跟踪的预期值,所以我们需要采取不同的方法。

用谓词测试

为了修复我们的测试,我们可以用一个自定义的谓词重写我们的期望值。

expectLater(
  controller.stream,
  emitsInOrder([
    const AsyncLoading<void>(),
    predicate<AsyncValue<void>>((value) {
      // use either this:
      expect(value.hasError, true);
      // or this:
      expect(value, isA<AsyncError<void>>());
      return true;
    }),
  ]),
);

文档中定义了 predicate为。

一个任意的函数,对实际值返回真或假。

这使得我们可以通过检查是否有错误来编写细粒度的期望(甚至可以使用 isA,它是一个类型匹配器),而不必担心我们不关心的属性(比如本例中的堆栈跟踪)。

结论

这就是了!我们已经成功地测试了我们的StateNotifier 子类中的每个单独的状态变化

而且我们还学会了如何使用流匹配器谓词来发挥我们的优势。

虽然我所展示的例子是故意简单的,但你可以使用这些API来测试更复杂的异步逻辑,同时避免常见的陷阱。

  • 调用被测代码使用expectLater
  • 使用谓词来编写细粒度的期望值
  • 添加一个自定义超时,以确保我们的测试在需要时快速失败

这里还有一些其他的东西,会让你的生活更轻松。

  • 如果你想测试自定义类,总是实现平等运算符toString()方法
  • 始终明确您的类型注释,这将防止一些意外的测试失败。