Search code examples
unit-testingdartstream

How to unit test that my class listen to a stream and responds correctly in Dart


Consider the following class:

enum LoginState { loggedOut, loggedIn }

class StreamListener {
  final FirebaseAuth _auth;
  LoginState _state = LoginState.loggedOut;

  LoginState get state => _state;

  StreamListener({required FirebaseAuth auth}) : _auth = auth {
    _auth.userChanges().listen((user) {
      if (user != null) {
        _state = LoginState.loggedIn;
      } else {
        _state = LoginState.loggedOut;
      }
    });
  }
}

I would like to test that when a user login the state changes from loggedOut to loggedIn, see the following test code:

class FakeUser extends Fake implements User {}

@GenerateMocks([FirebaseAuth])
void main() {
  StreamController<User?> controller = StreamController<User?>();
  final User value = FakeUser();

  setUp(() {
    controller = StreamController.broadcast();
  });

  tearDown(() {
    controller.close();
  });

  test('Stream listen test', () {
    final MockFirebaseAuth mockAuth = MockFirebaseAuth();
    when(mockAuth.userChanges()).thenAnswer((_) => controller.stream);
    StreamListener subject = StreamListener(auth: mockAuth);

    controller.add(value);

    expect(subject.state, LoginState.loggedIn);
  });
}

However, due to the async behaviour the login state is still loggedOut. How could I test this properly?


Solution

  • I don't think that you can test that in a way that is strictly correct. Your StreamListener class promises to update state in response to Stream events, but it's asynchronous, you have no formal guarantee when those updates might happen, and you have no way to notify callers when those updates eventually do occur. You could solve that by modifying StreamListener to provide a broadcast Stream<LoginState> that is emitted whenever _state changes.

    From a practical perspective of being good enough, there are a few things you could do:

    • Rely on Dart's event loop to invoke all of the Stream's listeners synchronously. In other words, after adding an event to your Stream, allow Dart to return the event loop so that the Stream's listeners can execute:

      test('Stream listen test', () async {
        ...
      
        StreamListener subject = StreamListener(auth: mockAuth);
        controller.add(value);
      
        await Future<void>.value();
        expect(subject.state, LoginState.loggedIn);
      
    • Since you are using a broadcast Stream, you alternatively could rely on multiple Stream listeners firing in order of registration. (I don't see any formal documentation guaranteeing that ordering, but I think it is the sanest behavior.) Your test could register its own listener after your object has registered its listener, use expectAsync1 to verify that the test's listener is called, and have the test's listener verify the state of your object:

      StreamListener subject = StreamListener(auth: mockAuth);
      
      controller.stream.listen(expectAsync1((event) {
        expect(event, value);
        expect(subject.state, LoginState.loggedIn);
      }));
      
      controller.add(value);
      
    • Or combine the approaches:

      test('Stream listen test', () async {
        ...
        var eventReceived = Completer<void>();
        StreamListener subject = StreamListener(auth: mockAuth);
      
        controller.stream.listen((_) => eventReceived.complete());
      
        controller.add(value);
      
        await eventReceived.future;
        expect(subject.state, LoginState.loggedIn);