In Flutter widget tests, why does real asynchronous work such as reading a file never complete, and when should you use tester.runAsync?
answer
- testWidgets runs inside FakeAsync
- pump elapses fake time only
- Timer still pending after dispose
- OS threads and isolates need real time
- runAsync: no re-entry, errors to takeException
basics
~20 stestWidgets runs its body in a FakeAsync zone where time moves only when you pump, so work that depends on the OS or another isolate never gets real time to finish. Wrap such calls in tester.runAsync, then pump to render the result.
solid answer
~40 sUnder `flutter test`, `testWidgets` uses `AutomatedTestWidgetsFlutterBinding`, which runs the body inside `FakeAsync`: timers and `Future.delayed` fire only when `pump(duration)` elapses fake time, and microtasks are flushed on pump. That is perfect for timers, but work done by the OS or another isolate - a `dart:io` file read, image decoding for `Image.file`, `Isolate.run` - needs real time, which `pump` never gives it. `tester.runAsync(() async { ... })` runs its callback in a real-async zone and returns its result; afterwards you `pump()` so the widget shows it. It must not be re-entered, and an error inside it is caught and exposed through `tester.takeException()` while the call returns `null`. Timers you created but never elapsed fail the test with 'A Timer is still pending even after the widget tree was disposed'.
code
dart · 13 linestestWidgets('login screen shows the saved avatar', (WidgetTester tester) async {
final File avatar = File('test/fixtures/avatar.png');
await tester.pumpWidget(MaterialApp(home: LoginScreen(savedAvatar: FileImage(avatar))));
await tester.runAsync(() async {
final BuildContext context = tester.element(find.byType(LoginScreen));
await precacheImage(FileImage(avatar), context);
});
await tester.pump();
final RenderImage image = tester.renderObject(find.byKey(const Key('saved-avatar')));
expect(image.image, isNotNull);
});go deeper
Recall that widget tests use fake time: delays only pass when you pump with a duration, and real file or image work needs tester.runAsync.
Explain FakeAsync under AutomatedTestWidgetsFlutterBinding, why pump fires timers but cannot finish OS or isolate work, and what the pending-timer failure means.
Use runAsync narrowly, handle its null result and takeException, avoid re-entry, and prefer restructuring code behind injectable fakes so tests stay fully fake-timed.
Set guidance that keeps widget suites deterministic: fakes for I/O by default, runAsync only for engine-level work like image decoding, reviewed like any real-time dependency.
## The fake-async world of `testWidgets` When you run tests with `flutter test`, `testWidgets` initialises `AutomatedTestWidgetsFlutterBinding`. This binding runs each test body inside a **`FakeAsync`** zone (from `package:fake_async`). In that zone: - `Timer`, `Timer.periodic` and `Future.delayed` register **fake timers** that fire only when the test elapses fake time. - `tester.pump(duration)` elapses `duration` of fake time, fires the timers that fall due, flushes microtasks, and renders one frame if one is scheduled. - The binding's `clock` reports fake time, so animations and `pumpAndSettle` timeouts are measured on it too. This is the reason widget tests are fast and deterministic: a 30-second countdown takes microseconds, and nothing depends on how busy the CI machine is. ## Two failure modes it creates ### Timers you never elapsed If a widget starts `Future.delayed(const Duration(seconds: 2), ...)` and the test only calls `pump()` (no duration) or `pumpAndSettle()` (which stops as soon as no frame is scheduled), the timer is still pending when the test ends. After disposing the tree the binding checks for leftovers, prints each pending timer with its creation stack, and fails with the assertion **"A Timer is still pending even after the widget tree was disposed."** The fix is to `pump` past the delay, or cancel the timer in `dispose` - which the failure has usefully revealed was missing. ### Work that needs real time Some asynchronous work is not a timer at all. It is completed by something outside the Dart event loop that the fake zone controls: - `dart:io` file and socket operations performed by the OS, - image decoding for `Image.file` or `Image.memory` done by the engine, - `Isolate.run` / `compute`, which run on another isolate, - platform plugins that answer through real channels. `flutter_test`'s documentation describes these as methods that "spawn isolates or OS threads and thus cannot be executed synchronously by calling pump". No amount of pumping delivers their results; the test either stalls or finishes before the widget ever sees data. ## `tester.runAsync` `Future<T?> runAsync<T>(Future<T> Function() callback)` runs `callback` in a zone that uses **real** asynchrony and returns the future's value when it completes. Typical use: 1. `await tester.runAsync(() => file.readAsString())` or `await tester.runAsync(() => precacheImage(provider, context))`. 2. `await tester.pump()` so the widget that was waiting renders the result. Rules to know: | Behaviour | Detail | |---|---| | Re-entrancy | Calling `runAsync` again before the previous one completes throws a test failure ("Reentrant call to runAsync() denied") | | Errors | An error in the callback is caught, made available via `tester.takeException()`, and `runAsync` returns `null` | | Timers inside | Timers created in the callback use real time; the fake clock does not advance them | | Hangs | If a widget waits on a future created inside `runAsync`, the fake environment cannot resolve it; expose a readiness future and await it inside the callback | | Deprecated parameter | `additionalTime` still appears in the signature but has had no effect since it was deprecated after 3.12 | ## When not to reach for it `runAsync` makes a test depend on real time and real resources again, which is what fake async exists to avoid. The documentation's first recommendation is to **restructure so you do not need it**: inject a repository and give the widget a fake that returns `Future.value(data)`, load assets through a fake bundle, or move file handling behind an interface. Keep `runAsync` for the narrow cases where the real engine or OS must do the work - most often decoding a real image so an `Image` widget has a size. ## A quick decision list - A delay, debounce or countdown: `pump(thatDuration)`. - A fake service returning a completed or `Completer` future: `pump()`. - A real file, image decode or isolate: `runAsync`, then `pump()`. - A real HTTP request: neither - the test binding's default `HttpClient` returns an empty 400 for every request; replace the dependency with a fake.
- What happens if the callback passed to runAsync throws?The error is caught by the framework, `runAsync` completes with `null`, and the error is available from `tester.takeException()`. A test that ignores the return value and never checks `takeException` can therefore miss the failure until the binding reports it at the end.
- Why does advancing time inside runAsync not fire the widget's fake timers?Timers created inside `runAsync` are real timers in a real-async zone, while the widget's timers were created in the fake zone. Only `pump(duration)` elapses fake time, so each world advances only by its own means.
- Which binding runs a widget test launched with flutter run instead of flutter test?`LiveTestWidgetsFlutterBinding`, which drives a real device and uses the real clock; `pump(duration)` there actually delays. The fake-async behaviour described here is that of `AutomatedTestWidgetsFlutterBinding`, used by `flutter test`.
A widget test is a film set with a stopwatch the director controls: every scripted delay happens the instant the director clicks forward, but a real delivery truck ordered from outside only arrives if the director lets the real clock run, which is what runAsync does.
saying these in an interview costs you the question
- Wrapping every await in runAsync to make widget tests realistic
- Timers in a widget test fire on their own after the real delay
- Calling runAsync again before the previous call has completed
- pumpAndSettle will eventually let a file read or isolate finish
- Silencing the pending-timer failure instead of pumping past or cancelling the timer