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.