A Flutter widget test fails with 'pumpAndSettle timed out' after tapping Sign in on a login screen that shows a CircularProgressIndicator; why, and how do you fix it?
answer
- an indeterminate spinner never finishes
- hasScheduledFrame stays true
- ten fake minutes, then it throws
- control the fake auth future
- pump explicitly while loading
basics
~20 sAn indeterminate CircularProgressIndicator repeats its animation forever, so a frame is always scheduled and pumpAndSettle never settles until its ten-minute fake-time timeout throws. Assert the loading state with pump(), then complete the faked sign-in and settle.
solid answer
~40 s`pumpAndSettle` loops while `hasScheduledFrame` is true, and an indeterminate `CircularProgressIndicator` (no `value`) drives a repeating `AnimationController`, so every frame schedules another. The loop runs until the fake clock passes the default ten-minute `timeout` and throws `pumpAndSettle timed out` - seconds of wall time, which looks like a hang. The fix is to control the state rather than wait it out: back the sign-in with a fake whose `Future` comes from a `Completer`, tap, call `pump()` and assert the spinner with `findsOneWidget`, then `complete()` the completer, `pump()` again, and once the spinner is gone `pumpAndSettle()` is safe for any route transition. Raising `timeout` or deleting the spinner assertion only hides the problem.
code
dart · 23 linesclass FakeAuth implements AuthService {
final Completer<User> pending = Completer<User>();
@override
Future<User> signIn(String email, String password) => pending.future;
}
testWidgets('shows a spinner until sign-in completes', (WidgetTester tester) async {
final FakeAuth auth = FakeAuth();
await tester.pumpWidget(MaterialApp(home: LoginScreen(auth: auth)));
await tester.enterText(find.byKey(const Key('login-email')), '[email protected]');
await tester.enterText(find.byKey(const Key('login-password')), 'secret-pass');
await tester.tap(find.byKey(const Key('login-submit')));
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
auth.pending.complete(const User(id: 'u1'));
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsNothing);
await tester.pumpAndSettle();
expect(find.byType(HomeScreen), findsOneWidget);
});go deeper
Recall that a spinning indicator never stops animating, so pumpAndSettle cannot finish while it is on screen; use pump instead.
Explain that pumpAndSettle loops while a frame is scheduled, that an indeterminate indicator always schedules one, and that the timeout is ten fake minutes.
Diagnose which animation keeps frames coming, then restructure the test around a Completer-backed fake so it asserts the loading state and settles only after it ends.
Push for injectable async dependencies on screens so tests own their timing, and treat raised pumpAndSettle timeouts in review as a smell to reject.
## The symptom The test taps **Sign in**, calls `await tester.pumpAndSettle()` to "wait for the login", and after a noticeable pause fails with: ``` FlutterError: pumpAndSettle timed out ``` Nothing is wrong with the login code. The test asked `pumpAndSettle` for something it cannot deliver. ## Why it never settles `WidgetTester.pumpAndSettle` repeatedly calls `pump(duration)` (100 ms by default) **while `binding.hasScheduledFrame` is true**. A frame stays scheduled as long as something keeps asking for one - an `AnimationController` that is running, a `Ticker` that is active, a widget marked dirty. An **indeterminate** `CircularProgressIndicator` - one built without a `value` - animates with a controller that repeats forever. Every frame advances it, and every advance requests the next frame. So: 1. The first pump renders the spinner and it schedules another frame. 2. Each later pump moves the fake clock 100 ms and renders again; the spinner schedules again. 3. The loop checks the **fake** clock against its `timeout`, ten minutes by default, and throws when it passes it. Ten fake minutes at 100 ms per iteration is roughly six thousand frames of layout and paint, which is why the failure takes seconds rather than arriving instantly. `flutter_test`'s own documentation calls out exactly this case: an infinite animation such as an indeterminate progress indicator makes `pumpAndSettle` throw. The spinner is only the most common culprit. The same happens with a `repeat()`ing `AnimationController` in a shimmer placeholder, a pulsing "live" badge, a looping vector animation, or a `Ticker` someone forgot to stop. ## Diagnosing which widget keeps frames coming - Replace `pumpAndSettle()` with a few `pump(const Duration(milliseconds: 100))` calls and `debugDumpApp()` or inspect `tester.hasRunningAnimations` (true while transient frame callbacks exist). - Search the screen under test for indeterminate indicators, `repeat()` calls and `Ticker`s started in `initState`. - Check whether the **loading state ever ends** in the test: if the sign-in call is a real network request, it will not complete (the test binding's default `HttpClient` answers every request with an empty 400), or it is a fake that never completes. ## Fixes, from best to worst 1. **Control the asynchronous dependency.** Inject the auth service and give the test a fake whose `signIn` returns `completer.future`. Then the test decides when loading ends: - tap, `pump()`, assert the spinner is shown (a real behaviour worth testing); - `completer.complete(user)`, `pump()` - the spinner is replaced by the next state; - now `pumpAndSettle()` is safe for the route transition that follows. 2. **Pump explicitly while an endless animation is on screen.** `pump()` or `pump(const Duration(milliseconds: 500))` renders and moves on without waiting for idleness. 3. **Make the fake resolve immediately** (`Future.value(user)`) when the loading state is not what the test is about. 4. **Do not** pass a longer `timeout`, loop `pump` "until it works", or remove the spinner from production code for the test's sake. Those hide the fact that the test does not control its own state. ## Why the fix matters beyond one test | Approach | Proves the loading state | Deterministic | Survives a new looping animation | |---|---|---|---| | `pumpAndSettle()` with real or never-ending async | no | no | no | | Bigger `timeout` | no | no | no | | Explicit `pump()` only | yes | yes | yes | | `Completer`-backed fake + pumps + settle after | yes | yes | yes | A test that owns the future can assert **both** sides of the transition - spinner visible, then error message or next screen - and it will keep passing when a designer adds a shimmer to the loading screen, because it never relies on the screen becoming idle while loading.
- Is the timeout measured in wall-clock or fake time?Fake time. `pumpAndSettle` compares `binding.clock` against `timeout`, and under `flutter test` that clock is the fake-async clock. Ten minutes elapse in about six thousand pumps, which is seconds of CPU, not ten real minutes.
- How can you check the spinner shows without pumpAndSettle at all?Tap, then `await tester.pump()` - one frame is enough for the `setState` that swaps the button for the indicator - and `expect(find.byType(CircularProgressIndicator), findsOneWidget)`. Follow with `pump(const Duration(...))` if you need the indicator to have animated.
- Why does a real HTTP call not complete in this test either?`flutter_test` installs `HttpOverrides` whose default `HttpClient` returns an empty 400 response for every request and makes no network call, so real network code never produces the success your test expects. Replace the dependency with a fake instead.
saying these in an interview costs you the question
- The login call is slow, so raise pumpAndSettle's timeout
- pumpAndSettle timed out means the test really waited ten minutes
- An indeterminate spinner stops animating once the test stops pumping
- Delete the loading indicator assertion so the test goes green
- pumpAndSettle waits until the sign-in Future completes