How to Write Tests using Stream Matchers and Predicates in Flutter

Source code on GitHub

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 or ValueNotifier (which implements Listenable)
  • 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 and expectLater
  • some common pitfalls when working with streams (and how to avoid them)

As of Riverpod 2.0, we can use AsyncNotifier as a replacement for StateNotifier. To learn how to unit test AsyncNotifier 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 using AsyncValue.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 a Future.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's debugState 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 use await expectLater() at the end of a test. A common use case for this is when writing Golden image tests using the matchesGoldenFile 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:

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter & Firebase Masterclass

Flutter & Firebase Masterclass

Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Fully updated to Dart 2.15.

Flutter Animations Masterclass

Flutter Animations Masterclass

Master Flutter animations and build a completely custom habit tracking application.