Testing Flutter Applications

Testing Stateful Widgets and Navigation

15 min Lesson 8 of 12

Testing Stateful Widgets and Navigation

When widgets hold internal state or push routes onto the navigation stack, you need specialised testing techniques beyond simple find and expect calls. This lesson covers two essential tools: pumpAndSettle() for waiting on frame-level animations and state rebuilds, and MockNavigatorObserver for asserting that your code calls the right navigation actions without launching a real route.

Why Stateful Widgets Need Extra Care

A StatefulWidget builds itself from mutable state. After you interact with it in a test (tapping a button, submitting a form), the framework needs one or more frames to call setState, schedule a rebuild, and paint the new tree. If you assert immediately after triggering the interaction, the widget tree may not have settled yet and your test will see stale UI.

  • pump() advances the clock by exactly one frame — useful when you know exactly how many frames an animation takes.
  • pumpAndSettle() keeps pumping frames until there is no more pending work — the right choice after user interactions that trigger setState or short animations.
  • Skipping either call is the most common source of false-negative test failures.
Note: pumpAndSettle() has a default timeout of 100 milliseconds and a frame interval of 16 ms (60 fps). If your animation runs longer than 100 ms, pass a custom duration: pumpAndSettle(const Duration(seconds: 2)).

Example 1 — Testing a Toggle Counter

Below is a stateful counter widget and a complete widget test that verifies the UI rebuilds correctly after tapping the increment button.

Counter Widget + Test

// counter_widget.dart
import 'package:flutter/material.dart';

class CounterWidget extends StatefulWidget {
  const CounterWidget({super.key});

  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text('Count: $_count', key: const Key('counter_text')),
        ElevatedButton(
          key: const Key('increment_btn'),
          onPressed: () => setState(() => _count++),
          child: const Text('Increment'),
        ),
      ],
    );
  }
}

// counter_widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/counter_widget.dart';

void main() {
  testWidgets('increments counter and rebuilds UI', (tester) async {
    await tester.pumpWidget(
      const MaterialApp(home: Scaffold(body: CounterWidget())),
    );

    // Initial state
    expect(find.text('Count: 0'), findsOneWidget);

    // Simulate a tap
    await tester.tap(find.byKey(const Key('increment_btn')));

    // Let Flutter process the setState and rebuild
    await tester.pumpAndSettle();

    // Assert the new state is visible
    expect(find.text('Count: 1'), findsOneWidget);
    expect(find.text('Count: 0'), findsNothing);
  });
}

Testing Widget Rebuilds After Multiple Interactions

You can chain several interactions within one test to verify cumulative state changes. Each interaction must be followed by pumpAndSettle() so the widget tree reflects the latest state before the next assertion.

Tip: Assign Key values to widgets you need to find in tests. This makes finders resilient to text changes and widget tree reorganisation.

Testing Navigation with MockNavigatorObserver

When a button calls Navigator.of(context).push(...), you normally do not want to render the destination screen in a unit test — that would pull in unrelated dependencies. Instead, you inject a NavigatorObserver mock into the MaterialApp and verify the observer received a didPush call with the correct route name.

The pattern requires three steps:

  • Create a mock class that extends NavigatorObserver and overrides didPush.
  • Pass the mock instance to MaterialApp(navigatorObservers: [mockObserver]).
  • After tapping the navigation trigger, call pumpAndSettle() and then verify the mock was called.

Example 2 — Verifying a Route Push

This test checks that pressing a "Go to Details" button pushes a named route without actually rendering the details screen.

NavigatorObserver Mock Test

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';

// 1. Define the mock
class MockNavigatorObserver extends Mock implements NavigatorObserver {}

// Widget under test
class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: Center(
        child: ElevatedButton(
          key: const Key('go_details_btn'),
          onPressed: () => Navigator.of(context).pushNamed('/details'),
          child: const Text('Go to Details'),
        ),
      ),
    );
  }
}

void main() {
  testWidgets('pushes /details route when button is tapped', (tester) async {
    final mockObserver = MockNavigatorObserver();

    await tester.pumpWidget(
      MaterialApp(
        navigatorObservers: [mockObserver],
        routes: {
          '/': (_) => const HomeScreen(),
          '/details': (_) => const Scaffold(body: Text('Details')),
        },
        initialRoute: '/',
      ),
    );

    // Tap the navigation button
    await tester.tap(find.byKey(const Key('go_details_btn')));
    await tester.pumpAndSettle();

    // Verify the observer was notified of a push
    verify(mockObserver.didPush(any, any)).called(greaterThanOrEqualTo(1));
  });
}
Warning: verify(mockObserver.didPush(any, any)) counts ALL pushes, including the initial route push when the app starts. Use called(greaterThanOrEqualTo(1)) or capture the route argument to assert a specific route name rather than just any push.

Capturing and Asserting the Exact Route

To assert the exact route that was pushed, override didPush in your mock to capture the route argument, then inspect it after the interaction:

  • Override didPush(Route route, Route? previousRoute) in a custom observer subclass.
  • Store the pushed route name in a local variable inside the override.
  • Assert that variable equals the expected route name after pumping.
Tip: When using go_router instead of the built-in Navigator, prefer testing routes via GoRouter's redirect and location assertions rather than raw NavigatorObserver mocks, since go_router wraps the underlying navigator.

Summary

Stateful widget tests require synchronising the test harness with the Flutter frame scheduler. Call pumpAndSettle() after every user interaction that triggers a state change or animation. For navigation, inject a MockNavigatorObserver into the widget tree to assert that routes are pushed without rendering full destination screens. Together, these techniques give you fast, reliable tests that verify both UI state and routing behaviour.