Dart Isolate and Async Testing Deep Dive

Dart Isolate and Async Testing Deep Dive

Asynchronous code is everywhere in Dart — HTTP calls, file I/O, timers, isolates. Testing it correctly requires understanding how Dart's event loop works and how the flutter_test / test package interacts with it. This post goes deep on testing Future, Stream, Completer, time-sensitive code with fake_async, and isolate communication.

Testing Futures

The simplest async test: async/await in the test body. The test runner automatically waits for the returned Future to complete.

import 'package:test/test.dart';

Future<String> fetchGreeting(String name) async {
  await Future.delayed(const Duration(milliseconds: 10));
  return 'Hello, $name!';
}

void main() {
  test('fetchGreeting returns correct string', () async {
    final result = await fetchGreeting('Alice');
    expect(result, equals('Hello, Alice!'));
  });

  test('fetchGreeting throws for empty name', () async {
    expect(
      () => fetchGreeting(''),
      throwsA(isA<ArgumentError>()),
    );
    // Or with async:
    await expectLater(
      fetchGreeting(''),
      throwsA(isA<ArgumentError>()),
    );
  });
}

Testing Futures That Should Fail

Future<User> loadUser(String id) async {
  if (id.isEmpty) throw ArgumentError('id cannot be empty');
  final data = await api.get('/users/$id');
  if (data == null) throw UserNotFoundException(id);
  return User.fromJson(data);
}

test('throws UserNotFoundException for unknown user', () async {
  when(() => mockApi.get(any())).thenAnswer((_) async => null);

  await expectLater(
    loadUser('unknown'),
    throwsA(
      isA<UserNotFoundException>().having(
        (e) => e.userId,
        'userId',
        equals('unknown'),
      ),
    ),
  );
});

Future Matchers

The test package provides matchers specifically for futures:

// Completes with a value
await expectLater(future, completion(equals('expected')));

// Completes with any value
await expectLater(future, completes);

// Throws a specific error
await expectLater(future, throwsA(isA<FormatException>()));

// Throws with a message
await expectLater(
  future,
  throwsA(
    isA<StateError>().having(
      (e) => e.message,
      'message',
      contains('already closed'),
    ),
  ),
);

// Multiple async assertions
await Future.wait([
  expectLater(cache.get('key1'), completion('value1')),
  expectLater(cache.get('key2'), completion('value2')),
]);

Testing Streams

Streams require different matchers. The key is emitsInOrder, emits, emitsDone, and emitsError.

Stream<int> countDown(int from) async* {
  for (int i = from; i >= 0; i--) {
    await Future.delayed(const Duration(milliseconds: 100));
    yield i;
  }
}

void main() {
  test('countDown emits expected sequence', () async {
    await expectLater(
      countDown(3),
      emitsInOrder([3, 2, 1, 0, emitsDone]),
    );
  });

  test('countDown emits values in order regardless of count', () async {
    await expectLater(
      countDown(2),
      emitsInOrder([
        emits(greaterThan(0)),
        emits(greaterThan(0)),
        emits(0),
        emitsDone,
      ]),
    );
  });
}

Stream Matchers Reference

// Emits a single value matching the matcher
emits(matcher)

// Emits a specific value
emits(42)

// Emits these values in order (allows other emissions between)
emitsInOrder([1, 2, 3])

// Emits these values and nothing else
emitsInOrder([1, 2, 3, emitsDone])

// Stream closes without error
emitsDone

// Stream emits an error
emitsError(isA<Exception>())

// Either/or
emitsAnyOf([emits(1), emits(2)])

// Emits through the stream until matcher matches
emitsThrough(5)

// Never emits a particular value
neverEmits(0)

Testing Hot Streams with StreamController

Most real streams in Flutter apps are "hot" — driven by external events. Use StreamController in tests to push values on demand:

class ConnectionMonitor {
  final Stream<bool> _networkStream;
  ConnectionMonitor(this._networkStream);

  Stream<String> get status => _networkStream.map(
    (online) => online ? 'Connected' : 'Disconnected',
  );
}

void main() {
  test('status maps network events correctly', () async {
    final controller = StreamController<bool>();
    final monitor = ConnectionMonitor(controller.stream);

    final emitted = <String>[];
    final sub = monitor.status.listen(emitted.add);

    controller.add(true);
    controller.add(false);
    controller.add(true);

    // Give microtasks time to process
    await Future.microtask(() {});

    expect(emitted, equals(['Connected', 'Disconnected', 'Connected']));

    await sub.cancel();
    await controller.close();
  });
}

Testing Broadcast Streams

test('multiple listeners receive same events', () async {
  final controller = StreamController<String>.broadcast();

  final listener1 = <String>[];
  final listener2 = <String>[];

  final sub1 = controller.stream.listen(listener1.add);
  final sub2 = controller.stream.listen(listener2.add);

  controller.add('event1');
  controller.add('event2');
  await Future.microtask(() {});

  expect(listener1, equals(['event1', 'event2']));
  expect(listener2, equals(['event1', 'event2']));

  await sub1.cancel();
  await sub2.cancel();
  await controller.close();
});

Testing Completer

Completer lets you complete a Future from outside the async function. Testing Completer-based code requires controlling when completion happens:

class DownloadManager {
  final _completers = <String, Completer<void>>{};

  Future<void> waitForDownload(String fileId) {
    final completer = Completer<void>();
    _completers[fileId] = completer;
    return completer.future;
  }

  void markComplete(String fileId) {
    _completers[fileId]?.complete();
    _completers.remove(fileId);
  }

  void markFailed(String fileId, Object error) {
    _completers[fileId]?.completeError(error);
    _completers.remove(fileId);
  }
}

void main() {
  test('waitForDownload completes when markComplete is called', () async {
    final manager = DownloadManager();

    bool completed = false;
    final future = manager.waitForDownload('file1').then((_) {
      completed = true;
    });

    expect(completed, isFalse); // Not yet

    manager.markComplete('file1');
    await future;

    expect(completed, isTrue);
  });

  test('waitForDownload throws when markFailed is called', () async {
    final manager = DownloadManager();

    final future = manager.waitForDownload('file2');
    manager.markFailed('file2', Exception('disk full'));

    await expectLater(future, throwsA(isA<Exception>()));
  });
}

fake_async: Controlling Time Without Sleeping

fake_async (bundled in flutter_test as package:fake_async) lets you control the passage of time in tests without actually waiting. Timers, Future.delayed, periodic timers — all respond to fakeAsync's clock.

import 'package:fake_async/fake_async.dart';
import 'package:test/test.dart';

void main() {
  test('debounce fires after delay', () {
    fakeAsync((async) {
      final results = <String>[];
      final debounced = debounce<String>(
        (value) => results.add(value),
        const Duration(milliseconds: 300),
      );

      debounced('a');
      debounced('b');
      debounced('c'); // Only this should fire

      // Nothing fired yet
      expect(results, isEmpty);

      // Advance 299ms — still nothing
      async.elapse(const Duration(milliseconds: 299));
      expect(results, isEmpty);

      // Advance 1 more ms — fires
      async.elapse(const Duration(milliseconds: 1));
      expect(results, equals(['c']));
    });
  });

  test('periodic timer fires every second', () {
    fakeAsync((async) {
      int count = 0;
      Timer.periodic(const Duration(seconds: 1), (_) => count++);

      async.elapse(const Duration(seconds: 5));

      expect(count, equals(5));
    });
  });

  test('Future.delayed resolves after elapsed time', () {
    fakeAsync((async) {
      String? result;
      Future.delayed(
        const Duration(seconds: 10),
        () => result = 'done',
      );

      async.elapse(const Duration(seconds: 9));
      expect(result, isNull);

      async.elapse(const Duration(seconds: 1));
      expect(result, equals('done'));
    });
  });
}

fakeAsync in Flutter Widget Tests

In Flutter widget tests, the WidgetTester already uses fakeAsync internally. You interact with it through tester.pump(duration):

testWidgets('Timeout banner appears after 30 seconds', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: SessionPage()));

  expect(find.text('Session expiring soon'), findsNothing);

  // Advance 29 seconds — no banner
  await tester.pump(const Duration(seconds: 29));
  expect(find.text('Session expiring soon'), findsNothing);

  // Advance 1 more second — banner appears
  await tester.pump(const Duration(seconds: 1));
  expect(find.text('Session expiring soon'), findsOneWidget);
});

Flushing Microtasks

Sometimes you need all pending microtasks to complete without advancing real time. Use async.flushMicrotasks():

fakeAsync((async) {
  String? value;
  Future.microtask(() => value = 'from microtask');

  expect(value, isNull);

  async.flushMicrotasks();

  expect(value, equals('from microtask'));
});

Testing Dart Isolates

Isolates in Dart are independent workers with their own memory heap. They communicate via SendPort/ReceivePort pairs. Testing isolate communication requires starting the isolate, sending messages, and receiving responses.

Basic Isolate Test

import 'dart:isolate';
import 'package:test/test.dart';

// The function that runs in the isolate
void heavyComputation(SendPort sendPort) {
  final result = List.generate(1000, (i) => i * i).fold(0, (a, b) => a + b);
  sendPort.send(result);
}

void main() {
  test('isolate computes sum of squares', () async {
    final receivePort = ReceivePort();

    await Isolate.spawn(heavyComputation, receivePort.sendPort);

    final result = await receivePort.first as int;

    expect(result, equals(332833500)); // Sum of 0²+1²+...+999²
    receivePort.close();
  });
}

Testing Bidirectional Isolate Communication

void workerIsolate(SendPort mainSendPort) {
  final workerReceivePort = ReceivePort();
  mainSendPort.send(workerReceivePort.sendPort); // Send back our port

  workerReceivePort.listen((message) {
    if (message is int) {
      mainSendPort.send(message * 2); // Double the number
    } else if (message == 'shutdown') {
      workerReceivePort.close();
    }
  });
}

void main() {
  test('isolate processes multiple messages', () async {
    final mainReceivePort = ReceivePort();
    await Isolate.spawn(workerIsolate, mainReceivePort.sendPort);

    // First message is the worker's SendPort
    final workerSendPort = await mainReceivePort.first as SendPort;

    final results = <int>[];
    final subscription = mainReceivePort.listen((msg) {
      if (msg is int) results.add(msg);
    });

    workerSendPort.send(5);
    workerSendPort.send(10);
    workerSendPort.send(21);

    // Wait for all three results
    await Future.doWhile(() async {
      await Future.delayed(const Duration(milliseconds: 10));
      return results.length < 3;
    });

    expect(results, containsAll([10, 20, 42]));

    workerSendPort.send('shutdown');
    await subscription.cancel();
    mainReceivePort.close();
  });
}

Testing Isolate Error Handling

void faultyIsolate(SendPort sendPort) {
  throw Exception('Isolate crashed!');
}

void main() {
  test('isolate error is captured via onError port', () async {
    final receivePort = ReceivePort();
    final errorPort = ReceivePort();

    await Isolate.spawn(
      faultyIsolate,
      receivePort.sendPort,
      onError: errorPort.sendPort,
    );

    final error = await errorPort.first as List;

    expect(error[0], contains('Isolate crashed'));
    // error[1] is the stack trace

    receivePort.close();
    errorPort.close();
  });
}

Testing Compute (Flutter's Isolate Wrapper)

Flutter's compute() function is a simplified isolate API. Test it directly — it's just an async function:

import 'package:flutter/foundation.dart';

List<int> filterPrimes(List<int> numbers) {
  bool isPrime(int n) {
    if (n < 2) return false;
    for (int i = 2; i <= n ~/ 2; i++) {
      if (n % i == 0) return false;
    }
    return true;
  }
  return numbers.where(isPrime).toList();
}

void main() {
  test('filterPrimes finds correct primes', () async {
    final result = await compute(filterPrimes, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);

    expect(result, equals([2, 3, 5, 7]));
  });
}

Advanced Async Testing Patterns

Testing async generators (async*)

Stream<int> fibonacci() async* {
  int a = 0, b = 1;
  while (true) {
    yield a;
    final next = a + b;
    a = b;
    b = next;
  }
}

void main() {
  test('fibonacci produces correct sequence', () async {
    final first8 = await fibonacci().take(8).toList();
    expect(first8, equals([0, 1, 1, 2, 3, 5, 8, 13]));
  });
}

Testing race conditions with async

class Cache<K, V> {
  final _store = <K, Future<V>>{};
  final Future<V> Function(K) _fetch;

  Cache(this._fetch);

  Future<V> get(K key) {
    return _store.putIfAbsent(key, () => _fetch(key));
  }
}

void main() {
  test('concurrent cache.get does not call fetch twice', () async {
    int callCount = 0;
    final cache = Cache<String, String>((key) async {
      callCount++;
      await Future.delayed(const Duration(milliseconds: 50));
      return 'value_for_$key';
    });

    // Concurrent requests for the same key
    final results = await Future.wait([
      cache.get('key1'),
      cache.get('key1'),
      cache.get('key1'),
    ]);

    expect(callCount, equals(1)); // Fetch called only once
    expect(results, everyElement(equals('value_for_key1')));
  });
}

Using expectLater with timeout

For inherently time-bounded tests:

test('stream completes within timeout', () async {
  await expectLater(
    someStream.timeout(const Duration(seconds: 5)),
    emitsInOrder([1, 2, 3, emitsDone]),
  );
});

Testing Retry Logic

class ApiClient {
  int _attempts = 0;

  Future<String> fetchWithRetry(String url, {int maxRetries = 3}) async {
    for (int i = 0; i <= maxRetries; i++) {
      try {
        _attempts++;
        return await _makeRequest(url);
      } catch (e) {
        if (i == maxRetries) rethrow;
        await Future.delayed(Duration(milliseconds: 100 * (i + 1)));
      }
    }
    throw StateError('unreachable');
  }
}

void main() {
  test('retries on failure and succeeds on third attempt', () {
    fakeAsync((async) async {
      int callCount = 0;
      final client = ApiClient(
        makeRequest: (url) async {
          callCount++;
          if (callCount < 3) throw Exception('server error');
          return 'success';
        },
      );

      String? result;
      client.fetchWithRetry('/api/data').then((r) => result = r);

      // First attempt fails, 100ms retry delay
      async.elapse(const Duration(milliseconds: 100));
      expect(result, isNull);

      // Second attempt fails, 200ms retry delay
      async.elapse(const Duration(milliseconds: 200));
      expect(result, isNull);

      // Third attempt succeeds
      async.flushMicrotasks();
      expect(result, equals('success'));
      expect(callCount, equals(3));
    });
  });
}

Structuring Async Tests for Reliability

Always await futures in tests. Unawaited futures are silently ignored — the test passes regardless of what happens:

// BAD — unawaited, always passes
test('bad test', () {
  someAsyncFunction(); // ← no await!
});

// GOOD
test('good test', () async {
  await someAsyncFunction();
});

Use addTearDown to close streams and controllers:

test('with proper cleanup', () async {
  final controller = StreamController<int>();
  addTearDown(controller.close);

  // Test body...
});

Avoid Future.delayed in tests — it makes tests slow and timing-dependent. Use fake_async or tester.pump(duration) instead.

Conclusion

Async testing in Dart rewards understanding the underlying mechanics: how microtasks differ from event queue entries, how fakeAsync intercepts the timer system, and how isolates communicate via message passing. The key tools are: expectLater + stream matchers for reactive code, fakeAsync for any test involving timers or delays, and direct ReceivePort/SendPort manipulation for isolate communication. Master these and you can test any concurrency pattern Dart can express — without flaky sleep calls or undetermined race conditions.

Read more

Start now free