Flutter Widget Testing: WidgetTester, Finders, Pumping, and Async Widgets

Flutter Widget Testing: WidgetTester, Finders, Pumping, and Async Widgets

Flutter widget tests mount a widget in a test environment — no device or emulator needed. The WidgetTester API gives you control over the widget tree: find elements by text, key, or type; tap, drag, and enter text; advance the clock; and assert on the rendered output. This guide covers the complete widget testing API with practical patterns for common scenarios.

Key Takeaways

find.byKey(const Key('id')) is the most stable finder. Text and widget type finders break when content changes. Add Key to widgets you'll interact with in tests.

pump() advances one frame; pumpAndSettle() runs until idle. Use pump(duration) for precise animation control. Use pumpAndSettle() when you just want everything to finish.

testWidgets always needs a MaterialApp or WidgetsApp wrapper. Most widgets depend on Material theme, MediaQuery, or navigation. Without the wrapper, you get MediaQuery not found errors.

Async widgets need pump() after futures complete. If your widget awaits a Future in initState(), call await tester.pump() after setting up mock responses to let the widget rebuild.

expect(find.descendant(...), findsOneWidget) for scoped queries. When the same text appears multiple times, scope your finder with find.descendant(of: ..., matching: ...).

Test Setup

Every widget test follows the same structure:

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

void main() {
  group('MyWidget', () {
    testWidgets('description of what is tested', (WidgetTester tester) async {
      // 1. Build the widget
      await tester.pumpWidget(
        const MaterialApp(home: MyWidget()),
      );
      
      // 2. Find elements
      final button = find.byKey(const Key('my-button'));
      
      // 3. Interact
      await tester.tap(button);
      await tester.pump();  // or pumpAndSettle()
      
      // 4. Assert
      expect(find.text('Clicked!'), findsOneWidget);
    });
  });
}

Finders

Flutter's find object provides many ways to locate widgets:

// By text
find.text('Submit')
find.textContaining('Hello')

// By key
find.byKey(const Key('submit-btn'))
find.byKey(const ValueKey<int>(42))

// By widget type
find.byType(ElevatedButton)
find.byType(ListView)

// By icon
find.byIcon(Icons.close)

// By widget instance
find.byWidget(specificWidgetInstance)

// Combined
find.descendant(
  of: find.byKey(const Key('user-card-1')),
  matching: find.byType(ElevatedButton),
)

find.ancestor(
  of: find.text('Delete'),
  matching: find.byType(Card),
)

Assertion Matchers

expect(find.text('Hello'), findsOneWidget);      // exactly one
expect(find.byType(ListTile), findsNWidgets(3)); // exactly 3
expect(find.byType(CircularProgressIndicator), findsAny);   // at least one
expect(find.text('Error'), findsNothing);         // none

Pumping

pump() and pumpAndSettle() advance the widget's lifecycle:

// Advance one frame
await tester.pump();

// Advance by a specific duration (for animations)
await tester.pump(const Duration(seconds: 1));

// Run until no more frames are scheduled (animations complete, futures settle)
await tester.pumpAndSettle();

// Run until no more frames, with timeout
await tester.pumpAndSettle(const Duration(seconds: 5));

When to use which:

  • After tap(), enterText(), drag() that don't trigger animations → pump()
  • After navigating between routes → pumpAndSettle()
  • After triggering an animation you want to test mid-animation → pump(duration)
  • After a async operation completes → pump() then assert, or pumpAndSettle()

Testing User Interactions

Tapping

testWidgets('checkbox toggles on tap', (tester) async {
  await tester.pumpWidget(MaterialApp(
    home: Scaffold(body: Checkbox(value: false, onChanged: (_) {})),
  ));

  await tester.tap(find.byType(Checkbox));
  await tester.pump();

  // Checkbox is now checked
  final checkbox = tester.widget<Checkbox>(find.byType(Checkbox));
  expect(checkbox.value, isTrue);
});

Text Input

testWidgets('search input filters results', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: SearchScreen()));

  await tester.enterText(find.byKey(const Key('search-input')), 'flutter');
  await tester.pump();

  expect(find.text('Flutter Testing'), findsOneWidget);
  expect(find.text('React Testing'), findsNothing);
});

Scrolling

testWidgets('loads more items on scroll to bottom', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: InfiniteList()));
  
  // Initial items
  expect(find.byType(ListTile), findsNWidgets(20));

  // Scroll to bottom
  await tester.drag(find.byType(ListView), const Offset(0, -5000));
  await tester.pumpAndSettle();

  // More items loaded
  expect(find.byType(ListTile), findsNWidgets(40));
});

Dragging and Swiping

testWidgets('swipe to dismiss removes the item', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: DismissibleList()));

  // Swipe the first item left
  await tester.drag(find.byType(Dismissible).first, const Offset(-500, 0));
  await tester.pumpAndSettle();

  expect(find.byType(Dismissible), findsNothing);
});

Testing Async Widgets

Widgets that load data asynchronously:

// Widget with FutureBuilder
class UserProfile extends StatelessWidget {
  final Future<User> Function(String) fetchUser;
  final String userId;

  const UserProfile({required this.fetchUser, required this.userId, super.key});

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<User>(
      future: fetchUser(userId),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const CircularProgressIndicator();
        }
        if (snapshot.hasError) {
          return Text('Error: ${snapshot.error}');
        }
        return Text(snapshot.data!.name);
      },
    );
  }
}
testWidgets('shows loading indicator while fetching', (tester) async {
  final completer = Completer<User>();
  
  await tester.pumpWidget(MaterialApp(
    home: UserProfile(
      fetchUser: (_) => completer.future,  // never completes yet
      userId: '1',
    ),
  ));

  expect(find.byType(CircularProgressIndicator), findsOneWidget);
  
  // Complete the future
  completer.complete(User(id: '1', name: 'Alice'));
  await tester.pumpAndSettle();
  
  expect(find.byType(CircularProgressIndicator), findsNothing);
  expect(find.text('Alice'), findsOneWidget);
});

testWidgets('shows error when fetch fails', (tester) async {
  await tester.pumpWidget(MaterialApp(
    home: UserProfile(
      fetchUser: (_) async => throw Exception('Network error'),
      userId: '1',
    ),
  ));

  await tester.pumpAndSettle();

  expect(find.textContaining('Error:'), findsOneWidget);
});

Testing Navigation

testWidgets('tapping a user card navigates to user detail', (tester) async {
  await tester.pumpWidget(MaterialApp(
    routes: {
      '/': (context) => const UserList(),
      '/user-detail': (context) => const UserDetail(),
    },
  ));

  // Tap the first user card
  await tester.tap(find.byKey(const Key('user-card-0')));
  await tester.pumpAndSettle();

  // Should be on user detail screen
  expect(find.byType(UserDetail), findsOneWidget);
  expect(find.byType(UserList), findsNothing);
});

Testing Forms

testWidgets('form validation shows errors on empty submit', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: RegisterForm()));

  // Submit without filling anything
  await tester.tap(find.byKey(const Key('submit-btn')));
  await tester.pump();

  // Error messages appear
  expect(find.text('Email is required'), findsOneWidget);
  expect(find.text('Password is required'), findsOneWidget);
});

testWidgets('form submits with valid data', (tester) async {
  bool submitted = false;

  await tester.pumpWidget(MaterialApp(
    home: RegisterForm(
      onSubmit: (email, password) { submitted = true; },
    ),
  ));

  await tester.enterText(find.byKey(const Key('email-field')), 'alice@example.com');
  await tester.enterText(find.byKey(const Key('password-field')), 'password123');
  await tester.tap(find.byKey(const Key('submit-btn')));
  await tester.pumpAndSettle();

  expect(submitted, isTrue);
  expect(find.text('Email is required'), findsNothing);
});

Testing with MockNavigatorObserver

class MockNavigatorObserver extends Mock implements NavigatorObserver {}

testWidgets('pushes named route with correct arguments', (tester) async {
  final observer = MockNavigatorObserver();

  await tester.pumpWidget(MaterialApp(
    navigatorObservers: [observer],
    routes: {
      '/': (context) => const HomeScreen(),
      '/detail': (context) => const DetailScreen(),
    },
  ));

  await tester.tap(find.byKey(const Key('go-to-detail-btn')));
  await tester.pumpAndSettle();

  verify(() => observer.didPush(any(), any())).called(1);
});

Common Mistakes

Missing await before pump():

// Wrong
tester.tap(find.byKey(const Key('btn')));
expect(find.text('Clicked'), findsOneWidget);  // may fail — not pumped yet

// Correct
await tester.tap(find.byKey(const Key('btn')));
await tester.pump();
expect(find.text('Clicked'), findsOneWidget);

Forgetting MaterialApp wrapper:

// Wrong — throws 'No Directionality widget found'
await tester.pumpWidget(const MyWidget());

// Correct
await tester.pumpWidget(const MaterialApp(home: MyWidget()));

Using pumpAndSettle() with infinite animations: If your widget has a CircularProgressIndicator or looping animation that never stops, pumpAndSettle() will time out. Pump manually with a duration instead.

Running Widget Tests

flutter test <span class="hljs-built_in">test/widgets/          <span class="hljs-comment"># all widget tests
flutter <span class="hljs-built_in">test <span class="hljs-built_in">test/widgets/user_card_test.dart  <span class="hljs-comment"># single file
flutter <span class="hljs-built_in">test --update-goldens       <span class="hljs-comment"># update golden files

Beyond Widget Tests

Widget tests verify UI behavior in isolation. They don't test your app on real devices, with real network conditions, or across different Android/iOS versions.

HelpMeTest monitors your backend APIs and web services 24/7 — the services your Flutter app depends on. Start with the free tier to get alerted before your users are affected.

Read more