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); // nonePumping
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, orpumpAndSettle()
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 filesBeyond 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.