几乎每个Flutter应用程序都需要与异步数据打交道。
一个常见的例子是,当一个部件的状态因异步操作(初始数据→加载→成功或失败)而改变时。
而我们可以用各种方式来表示一个随时间变化的状态。
- 用
ChangeNotifier或ValueNotifier(实现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()方法
- 始终明确您的类型注释,这将防止一些意外的测试失败。