Description
In some cases, the control over the Dart zone can be limited when we use Future or Stream because some inner constants are tied to the root zone.
For example (inspired by FakeAsync from Quiver):
import 'dart:async';
import 'package:test/test.dart';
void noop([Object _]) {}
void main() {
group('Counter should', () {
List<void Function()> actions;
int testCounter;
int callbacksCount;
int periodicCallbacksCount;
int microtasksCount;
// Create zone specification that just saves all callbacks
// for timers and microtasks and then does nothing
final zoneSpecification = ZoneSpecification(
createTimer:
(source, parent, zone, duration, f) {
callbacksCount += 1;
actions.add(f);
return parent.createTimer(zone, duration, noop);
},
createPeriodicTimer:
(source, parent, zone, period, f) {
periodicCallbacksCount += 1;
actions.add(() => f(null));
return parent.createPeriodicTimer(zone, period, noop);
},
scheduleMicrotask:
(source, parent, zone, f) {
microtasksCount += 1;
actions.add(f);
parent.scheduleMicrotask(zone, noop);
},
);
setUp(() {
actions = [];
testCounter = 0;
callbacksCount = 0;
periodicCallbacksCount = 0;
microtasksCount = 0;
});
test('be incremented two times', () {
runZoned(
() {
// Do some async work to register some callbacks
Stream<Object>
.periodic(const Duration(hours: 1))
.take(1)
.listen(
(_) {
print('onData');
testCounter += 1;
},
onDone: () {
print('onDone');
testCounter += 1;
},
);
// Call all registered callbacks syncroniously until there
// will be no new scheduled callbacks
while (actions.isNotEmpty) {
final fns = List.of(actions);
actions.clear();
fns.forEach((fn) => fn());
}
// Check registrations count
print('createTimer: $callbacksCount');
print('createPeriodicTimer: $periodicCallbacksCount');
print('scheduleMicrotask: $microtasksCount');
// Test counter
expect(testCounter, 2);
},
zoneSpecification: zoneSpecification,
);
});
});
}
It is expected that the test should pass, but it fails:
onData
createTimer: 0
createPeriodicTimer: 1
scheduleMicrotask: 0
Expected: <2>
Actual: <1>
package:test_api expect
test/dart_test.dart 81:11 main.<fn>.<fn>.<fn>
dart:async runZoned
test/dart_test.dart 50:7 main.<fn>.<fn>
onDone
✖ Counter should be incremented two times
Exited (1)
We found this on Dart SDK 2.4.0, it also can be reproduced with Dart SDK 2.7.0.
Before it fails, .take(1)
closes stream which cancels the subscription. In the current example, cancellation returns Future which is resolved with null
in the root zone. It is constant.
https://github.com/dart-lang/sdk/blob/master/sdk/lib/async/future.dart#L151
The onDone
callback will be called after cancellation Future resolving, but it is already resolved. So, it will be called with scheduleMicrotask
from the zone of the resolved Future, which is the root zone. And here we lost control over onDone
and why the test failed.
Unfortunately, this is a very popular fail case in tests, where Quiver, RxDart and other Stream-heavy libraries are used and when we try to test something synchronously.