dart-lang / fake_async

Fake asynchronous events for deterministic testing.
https://pub.dev/packages/fake_async
Apache License 2.0
90 stars 16 forks source link

Single-step version of flushTimers #84

Open gnprice opened 3 months ago

gnprice commented 3 months ago

I have some tests that I've written using this package, and initially I ran into the same kinds of pitfalls and sharp edges that have been described in a few previous issues in this tracker like #24, #38, and #51.

Then after spending some time getting my head around how FakeAsync works, I developed a pattern for using it that I'm fairly happy with, with few enough sharp edges that I've been comfortable recommending it to coworkers without expecting them to spend a lot of time understanding the implementation like I did. There still are some sharp edges, though. In order to fix those, I think I need a bit more control over the loop that happens in flushTimers.

Specifically I'd like to have a FakeAsync method I can call that does much the same thing as flushTimers, but only goes one iteration through the loop, running one timer. Then I can write a loop that calls that method but also runs some logic of my own at each iteration, potentially deciding to break out of the loop.

I have a draft implementation of such a method, runNextTimer, which I'll send as a PR shortly (→#85). I wanted to file this issue to give the background on its motivation and my use case, and especially in case someone here has ideas for alternate solutions for the use case.


To make things concrete, the pattern I've found helpful is a helper function I call awaitFakeAsync. The docs say (cutting boring parts):

/// Run [callback] to completion in a [Zone] where all asynchrony is
/// controlled by an instance of [FakeAsync].
///
/// … After calling [callback], this function uses [FakeAsync.flushTimers] to
/// advance the computation started by [callback], and then expects the
/// [Future] that was returned by [callback] to have completed. …
T awaitFakeAsync<T>(Future<T> Function(FakeAsync async) callback,
    {DateTime? initialTime}) {

One uses it like so, more or less freely mixing plain await with calls to FakeAsync methods:

  test('the thing works', () {
    awaitFakeAsync((async) async {
      final thing = Thing();
      // Plain old await!  The [awaitFakeAsync] keeps it moving.
      await thing.start();
      check(thing.count).equals(0);

      await thing.schedule(Duration(seconds: 1));
      check(thing.count).equals(0);
      // But the test can also manipulate time via FakeAsync methods.
      async.elapse(Duration(seconds: 1));
      check(thing.count).equals(1);
    });
  });

Mostly this works great. The one main gotcha we've encountered is that if the callback's returned future completes with an error — i.e. when the test body throws an exception, when used in the above pattern — there's no way to break flushTimers out of its loop. As I write in a TODO comment in the awaitFakeAsync implementation:

  // TODO: if the future returned by [callback] completes with an error,
  //   it would be good to throw that error immediately rather than finish
  //   flushing timers.  (This probably requires [FakeAsync] to have a richer
  //   API, like a `fireNextTimer` that does one iteration of `flushTimers`.)
  //
  //   In particular, if flushing timers later causes an uncaught exception, the
  //   current behavior is that that uncaught exception gets printed first
  //   (while `flushTimers` is running), and then only later (after
  //   `flushTimers` has returned control to this function) do we throw the
  //   error that the [callback] future completed with.  That's confusing
  //   because it causes the exceptions to appear in test output in an order
  //   that's misleading about what actually happened.

I have a draft commit (https://github.com/gnprice/zulip-flutter/commit/1ad0a6f9da2b50db7102f4a82ef00046ee983a73) that converts that awaitFakeAsync helper to drive its own loop and call the FakeAsync.runNextTimer from my upcoming PR (→#85), and it solves that problem.