Nearly every Flutter app needs to work with asynchronous data.
A common example of this is when the state of a widget changes in response to an asynchronous operation (initial data → loading → success or failure).
And we can represent a state that changes over time in various ways:
- with a
ChangeNotifier
orValueNotifier
(which implementsListenable
) - with a
Stream
- with some other immutable state class that is controlled using a
StateNotifier
(using Riverpod)
In particular, streams are very handy when writing asynchronous tests, and Flutter offers some rich and expressive testing APIs that we can use.
So let's take a detailed look at these APIs and learn about:
- how to observe and test state changes inside a
StateNotifier
- how to work with stream matchers and predicates
- the difference between
expect
andexpectLater
- some common pitfalls when working with streams (and how to avoid them)
As of Riverpod 2.0, we can use
AsyncNotifier
as a replacement forStateNotifier
. To learn how to unit testAsyncNotifier
subclasses, read: How to Unit Test AsyncNotifier Subclasses with Riverpod 2.0 in Flutter
Matching stream events with the StreamMatcher APIs
Suppose we want to write a test for this stream:
test('stream example', () {
final stream = Stream.fromIterable([
'Ready.',
'Loading took 5 seconds',
'Succeeded!',
]);
// TODO: write expectation
}
In this case, we can use the expect
function to check if our stream emits the values we expect:
expect(
stream,
emitsInOrder([
'Ready.',
'Loading took 5 seconds',
'Succeeded!'
]),
);
This can be done with emitsInOrder
, which is a function that returns a StreamMatcher
.
Here's a more interesting example based on the StreamMatcher
documentation:
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
]),
);
});
All these matchers let us check the values emitted by a Stream
. And they are very useful in real-world scenarios.
Let's take a look! 👇
A real-world scenario using StateNotifier
Suppose we have this StateNotifier
subclass that we can use to manage the state of a SignInScreen
widget:
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);
}
}
This class contains a method called signInAnonymously()
that we can use to:
- set a loading state
- sign in anonymously (using the
AuthRepository
that is given as a dependency) - update the state to success (
AsyncData
) or error (AsyncError
) by usingAsyncValue.guard
If you're not familiar with
AsyncValue.guard
, read this: Use AsyncValue.guard rather than try/catch inside your AsyncNotifier subclasses.
Let's see how we can write some unit tests for this class.
Adding a unit test using the Mocktail package
To test our SignInScreenController
, we need to create a MockAuthRepository
using the mocktail package:
import 'package:mocktail/mocktail.dart';
class MockAuthRepository extends Mock implements AuthRepository {}
Then, we can write a test for the case where sign-in succeeds:
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));
});
The test works as follows:
- in the setup stage, we stub the
signInAnonymously()
method to return aFuture.value()
(success). - in the run stage, we invoke the method on our controller
- in the verify stage, we check that
signInAnonymously()
was called on our repository, and compare the controller'sdebugState
against the expected value
Note how we are not accessing the controller's state directly. Instead, we use the
debugState
property, which is intended for development-only (and ideal when writing tests).
If we run this test, we can see that it succeeds. ✅
However, our test only checks the final state (AsyncData
), but doesn't verify that we're setting it to AsyncLoading
before we attempt to sign in:
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);
}
If only there was a way to check how the state changes over time. 😉
As it turns out, StateNotifier
has a stream
property that we can use exactly for this purpose. 👌
Testing with Streams
Here's the updated test that uses controller.stream
to check all the expected states:
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),
]),
);
});
Unfortunately, if we run this test, we get a timeout after 30 seconds:
TimeoutException after 0:00:30.000000: Test timed out after 30 seconds.
Why is this happening? 🧐
Testing with expect vs expectLater
Let's remember that streams emit values over time, meaning that by the time we call expect
, all the values have already been emitted, and it's too late to check them.
Instead, we need to start observing our stream before it emits any value.
In other words, we need to expect that our stream will emit some values before we call 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)),
);
This code uses the expectLater
function, because emitsInOrder
is an asynchronous matcher.
According to the documentation:
expectLater works just like expect, but returns a Future that completes when the matcher has finished matching.
Note that in this case it would be incorrect to await for expectLater()
to return, as this would cause the test to hang (and eventually timeout) because we need to call controller.signInAnonymously()
before any values get added to the stream.
In fact, I've also added an explicit timeout of 500 milliseconds. This guarantees that the test will fail fast if the call to expectLater()
hangs while waiting for all the stream matchers to return.
For more info about timeouts, including how to set the same timeout for all tests in a single file, read this: How to Add a Custom Test Timeout in Flutter.
As a general rule, we should not await for
expectLater()
to return if we call it before running the code under test. On the other hand, we can useawait expectLater()
at the end of a test. A common use case for this is when writing Golden image tests using thematchesGoldenFile
matcher.
Adding an unit test for the error case
Up until now, we've only tested the case where authRepository.signInAnonymously
succeeds.
But we should also check what happens when it fails. Let's add a test for this:
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)),
);
In this case, we stub authRepository.signInAnonymously
to throw an exception, and check that the stream will emit an AsyncError
.
However, our test fails with this error:
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 ... )>
In this case, both the expected and actual value emit AsyncLoading<void>
followed by AsyncError<void>
. However:
- the expected value has a null stack trace
- the actual value has a non-null stack trace
This is because AsyncValue.guard
will capture both the exception and the stack trace when creating the output AsyncError
state:
// 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);
But on our test we can't create an expected value with a matching stack trace, so we need to take a different approach.
Testing with predicates
To fix our test, we can rewrite our expectation with a custom predicate:
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;
}),
]),
);
The documentation defines the predicate
as:
An arbitrary function that returns true or false for the actual value.
This allows us to write fine-grained expectations by checking if we have an error (or even use isA
, which is a type matcher) without worrying about the properties we don't care about (such as the stack trace in this case).
Conclusion
That's it! We've managed to test each individual state change inside our StateNotifier
subclass.
And we've also learned how to use stream matchers and predicates to our advantage.
While the example I presented was intentionally simple, you use these APIs to test more complex asynchronous logic while avoiding common pitfalls:
- use
expectLater
before calling the code under test - use predicates to write fine-grained expectations
- add a custom timeout to ensure our tests fail fast when needed
Here are some other things that will make your life easier:
- always implement the equality operator and toString() method if you want to test custom classes
- always be explicit with your type annotations as this will prevent some unexpected test failures
Flutter Foundations Course Now Available
I launched a brand new course that covers testing in great depth, along with other important topics like app architecture, state management, navigation & routing, and much more: