flutter dart
npx skills add dhruvanbhalara/skills --skill flutter-add-widget-test

Contents

Setup

  • flutter_test is an SDK dependency, so no pub add is needed.
  • Test file naming: test/<mirror_path>/<widget>_test.dart.
  • Every test file starts with:
    import 'package:flutter/material.dart';
    import 'package:flutter_test/flutter_test.dart';
    
  • Use testWidgets('description', (WidgetTester tester) async { ... }) for all widget tests.
  • Always wrap the widget under test in MaterialApp to provide MediaQuery, Theme, and Navigator context.

Core APIs

API Purpose
tester.pumpWidget(widget) Render widget into test environment
tester.pump() Trigger a single frame
tester.pump(Duration(...)) Advance by specific duration
tester.pumpAndSettle() Wait for all animations to complete
tester.tap(finder) Simulate tap gesture
tester.longPress(finder) Simulate long press
tester.enterText(finder, 'text') Type into text field
tester.drag(finder, Offset(dx, dy)) Simulate drag gesture
tester.scrollUntilVisible(finder, delta) Scroll until widget is visible

Finder Patterns

Use finders to locate widgets in the test tree. Prefer Key-based finders for stability.

  • find.byKey(const ValueKey('login_button'))Preferred. Most stable across refactors.
  • find.byType(ElevatedButton) — By widget type. Fails if multiple instances exist.
  • find.text('Submit') — By displayed text. Avoid with localized strings.
  • find.byIcon(Icons.add) — By icon data.
  • find.descendant(of: parentFinder, matching: childFinder) — Nested lookup.
  • find.ancestor(of: childFinder, matching: parentFinder) — Reverse lookup.

Key Naming Convention: Use Key('feature_action_id') format on interactive widgets.

// Production code
ElevatedButton(
  key: const Key('login_submit_button'),
  onPressed: _onSubmit,
  child: const Text('Login'),
)

// Test code
final submitButton = find.byKey(const Key('login_submit_button'));
await tester.tap(submitButton);

Interaction Patterns

Tap and Verify State Change

testWidgets('increments counter on tap', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: CounterPage()));

  expect(find.text('0'), findsOneWidget);

  await tester.tap(find.byKey(const Key('increment_button')));
  await tester.pump();

  expect(find.text('1'), findsOneWidget);
});

Enter Text and Validate Form

testWidgets('validates email field', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: LoginForm()));

  await tester.enterText(find.byKey(const Key('email_field')), 'invalid');
  await tester.tap(find.byKey(const Key('submit_button')));
  await tester.pumpAndSettle();

  expect(find.text('Enter a valid email'), findsOneWidget);
});

Scroll to Off-Screen Widget

testWidgets('finds item in long list', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: ItemListPage()));

  final listFinder = find.byType(Scrollable);
  final itemFinder = find.byKey(const Key('item_99'));

  await tester.scrollUntilVisible(itemFinder, 500.0, scrollable: listFinder);

  expect(itemFinder, findsOneWidget);
});

Pump Strategies

Choose the right pump method based on your scenario:

Scenario Method Why
Simple state change pump() Single frame is enough
Animation completes pumpAndSettle() Waits for all frames
Timed animation pump(Duration(milliseconds: 300)) Advance specific time
Infinite animation (e.g., CircularProgressIndicator) pump() pumpAndSettle() will timeout
Debounced input pump(Duration(milliseconds: 500)) Wait for debounce period

WARNING: pumpAndSettle() throws PumpAndSettleTimedOutException on infinite animations. Use pump() instead when testing loading states.

Testing with BLoC

When testing widgets that depend on BLoC/Cubit:

testWidgets('shows user name from BLoC', (tester) async {
  final mockBloc = MockUserBloc();

  whenListen(
    mockBloc,
    Stream.fromIterable([UserLoaded(User(name: 'Alice'))]),
    initialState: UserInitial(),
  );

  await tester.pumpWidget(
    MaterialApp(
      home: BlocProvider<UserBloc>.value(
        value: mockBloc,
        child: const UserProfilePage(),
      ),
    ),
  );

  await tester.pumpAndSettle();

  expect(find.text('Alice'), findsOneWidget);
});
  • Use MockBloc / MockCubit from bloc_test package.
  • Use whenListen() to stub state stream responses.
  • Use BlocProvider.value() to inject mock into the widget tree.

Common Pitfalls

Error Cause Fix
No MediaQuery widget ancestor Missing MaterialApp wrapper Wrap in MaterialApp(home: ...)
A RenderFlex overflowed Widget exceeds test viewport Constrain with SizedBox or Expanded
Vertical viewport was given unbounded height ListView without height constraint Wrap in SizedBox(height: 600)
Widget not found after navigation Missing pumpAndSettle() Add await tester.pumpAndSettle() after navigation
PumpAndSettleTimedOutException Infinite animation running Use pump() instead of pumpAndSettle()

Workflow: Adding a Widget Test

Task Progress

  • [ ] Step 1: Add Keys to interactive widgets in production code.
  • [ ] Step 2: Create test file at test/<mirror_path>/<widget>_test.dart.
  • [ ] Step 3: Wrap widget in MaterialApp and call tester.pumpWidget().
  • [ ] Step 4: Use appropriate finder (byKey preferred).
  • [ ] Step 5: Simulate interactions (tap, enterText, drag).
  • [ ] Step 6: Assert with expect(finder, findsOneWidget) or state checks.
  • [ ] Step 7: Apply Golden Variant / State Matrix / Interaction Contract pattern (see flutter-testing).
  • [ ] Step 8: Run flutter test test/path/to/widget_test.dart.
  • [ ] Step 9: Feedback Loop — fix failures → re-run until green.

Examples

Minimal Widget Test

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/features/counter/counter_page.dart';

void main() {
  group('$CounterPage', () {
    testWidgets('renders initial counter value', (tester) async {
      await tester.pumpWidget(const MaterialApp(home: CounterPage()));

      expect(find.text('0'), findsOneWidget);
      expect(find.byType(FloatingActionButton), findsOneWidget);
    });

    testWidgets('increments counter when FAB is tapped', (tester) async {
      await tester.pumpWidget(const MaterialApp(home: CounterPage()));

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

      expect(find.text('1'), findsOneWidget);
    });
  });
}