Testing Times: Async Unit test with Dart

13 Mar 2019

test image

Some of my favourite Sci-fi revolves around the idea of time travel, from Star Trek and Dr Who, all the way back to Well’s Time Machine, manipulating time forms a fascinating basis for thought provoking stories. And while we don’t yet have the technology to do so in the physical world at large, manipulating the flow of time is possible on the small scale of the software that we write and more importantly test.

In my particular case at this moment in time [sic], unit testing Flutter applications using the BLoC pattern means that much of the Dart code outside the UI layer is based on Darts asynchronous Streams and Futures, the unit testing of which really needs a means to manipulate the flow of time.

In Dart, what makes this possible is the use of clocks for Darts async functionality and the existence of the FakeAsync class in the Quiver package which makes it possible to create microcosms where we are able to freely manipulate the clocks that the async code uses.

In fact Flutter as a framework, in its widget tests library already makes use of FakeAsync to allow widget tests to manipulate the passage of time using the pump() method that Flutter developers would already be familiar with.

As an aside it's interesting to note that Dart's use of the clock mechanism for its async implementation that FakeAsync then makes use of, is more developer-friendly than is the case with for instance RxJava where the developer needs to allow for passing in a specific Scheduler that can then in tests be replaced with a TestScheduler implementation.

While there is good documentation for FakeAsync, in fact there are two sets of documentation, I did not find it easy or quick to figure out how to best make use of FakeAsync, so I wanted to provide some examples of how to use FakeAsync in your unit tests to hopefully help others in the future.

Ok so you may be wondering why there are 2 packages that provide FakeAsync? Well I wondered that too and not being able to find anything in the Flutter or package documentation I asked And so it turns out that the history of this is a bit unclear, but the fantastic answer I got suggests that using the Quiver package is the best way to go.

Right so onto the actual use of FakeAsync. For this, let us have a look at a rather contrived-to-be-simple example, a BLoC which provides a stream of some useful data:


class TypewriterBloc {
  final _charStreamController = StreamController<String>();

  Stream<String> get chars => _charStreamController.stream;

  void monkeyType() {
  }
}

Given such a magnificent piece of software engineering, we would want to take the TDD approach and write some tests for it, beginning with say:

group('Test the typewriter', () {
    TypewriterBloc typewriter;

    setUp(() {
      typewriter = TypewriterBloc();
    });

    test('get a char when monkey types', () {
      String char;
      typewriter.chars.listen((event) {
        char = event;
      });
      typewriter.monkeyType();
      expect(char, isNotNull);
    });
  });

and as expected, our test duly fails.

So we move onto writing an actual implementation:


class TypewriterBloc {
  static const _keys = 'abcdefghijklmnopqrstuvwxyz0123456789';
  final _charStreamController = StreamController<String>();
  final _rnd = Random();

  Stream<String> get chars => _charStreamController.stream;

  void monkeyType() {
    final char = _keys[_rnd.nextInt(36)];
    _charStreamController.add(char);
  }
}

And run our test again in the sure knowledge that we'll now see GREEN.

But alas! as you may have expected having read the initial part of this article, our tests will sadly stay in the RED.

This is because, as is quite clearly documented for a StreamControllers add() method:

Sends a data [event].

Listeners receive this event in a later microtask.

Note the bit about "later microtask", as what that's referring to is that due to Darts single-threaded run-loop nature, microtasks are essentially little jobs that get run at the end of each run though the run-loop.

Hence the reason our test fails, is that it does not do anything about waiting until the end of the current iteration of the run-loop before testing for output from the stream. And here FakeAsync comes to our rescue, with its ability to manipulate time within Dart runtime, it provides the very helpful flushMicrotasks() method which we can use like so:

test('get a char when monkey types (with time travel)', () {
      FakeAsync().run((async) {
        String char;
        typewriter.chars.listen((event) {
          char = event;
        });
        typewriter.monkeyType();

        // now need to flush to get Stream to emit
        async.flushMicrotasks();
        expect(char, isNotNull);
      });
    });

Allowing our test to wait for those pesky microtasks to run, get our hands on the stream emission and allow our test to now pass!

More time

Of course it's not just about plain streams and microtasks. Your async code may also reply on "wall clock" time passing as well. For example here is some code that debounce's our input:

// how long we wait to check if typing has paused
// eg. so we can for example update something in one go
static const WAIT_FOR_TYPING_PAUSE = Duration(milliseconds: 500);
final buffer = <String>[];

//... 

Observable<String> get charGroup => Observable(chars).map((c) {
  buffer.add(c);
  return c;
}).debounce(WAIT_FOR_TYPING_PAUSE);

Testing this again presents a problem, because we don't want our test to have to wait for an actual duration (in this case 500ms) to pass to test this new stream.

For this case, FakeAsync provides us with a way to fast-forward into the future, with the async.elapse() method which allows us to easily test this without holding up our tests for any real amounts of time:

 test('get a set of chars when monkey pauses typing', () {
      FakeAsync().run((async) {
        String stanza;
        typewriter.charGroup.listen((event) {
          stanza = event;
        });
        typewriter.monkeyType();
        typewriter.monkeyType();
        typewriter.monkeyType();

        //fast forward time to after the required pause
        async.elapse(TypewriterBloc.WAIT_FOR_TYPING_PAUSE);

        expect(stanza, isNotNull);
        expect(typewriter.buffer.length, 3);
      });
    });

As an exercise for the reader, you could try replacing the duration inside elapse() with 499ms just to make sure I'm not hiding anything up my sleeve...

Inside the Tardis

Of course unlike the Doctor, we don't have an actual TARDIS, but thanks to the builders of Dart having provided the mechanism of Zones, we are, via FakeAsync able to manipulate time within a Zone, which if you look into the implementation of FakeAsync class inside Quiver looks like this:

final Queue<Function> _microtasks = Queue();

//...

ZoneSpecification get _zoneSpec => ZoneSpecification(
          createTimer: (_, __, ___, Duration duration, Function callback) {
        return _createTimer(duration, callback, false);
      }, createPeriodicTimer:
              (_, __, ___, Duration duration, Function callback) {
        return _createTimer(duration, callback, true);
      }, scheduleMicrotask: (_, __, ___, Function microtask) {
        _microtasks.add(microtask);
      });

while the async.flushMicrotasks(); call we used above in our test is within FakeAsync implemented as:

@override
void flushMicrotasks() {
   _drainMicrotasks();
}
//...
void _drainMicrotasks() {
    while (_microtasks.isNotEmpty) {
      _microtasks.removeFirst()();
    }
}

With that we conclude our little journey through the space-time continuum and hopefully dear reader, leave you with both a rough idea of how async work happens in Dart and some practical techniques to how to go about testing your async code.


You can find full working code for the examples given in this article in a companion GitHub repo.