← Articles

Widget testing in Flutter

By Ann Tech · 23 February 2025

Widget tests verify that your UI renders correctly and responds to interactions — without running on a device. They're faster than integration tests and more realistic than unit tests.

Setup

No extra dependencies needed — flutter_test is included with the Flutter SDK.

// test/widgets/product_card_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/widgets/product_card.dart';

void main() {
  testWidgets('shows product name and price', (tester) async {
    await tester.pumpWidget(
      MaterialApp(
        home: ProductCard(
          product: Product(
            id: '1',
            name: 'Flutter Hat',
            price: 29.99,
            imageUrl: 'https://example.com/hat.png',
          ),
        ),
      ),
    );

    expect(find.text('Flutter Hat'), findsOneWidget);
    expect(find.text('\$29.99'), findsOneWidget);
  });
}
flutter test test/widgets/product_card_test.dart

The pump methods

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

// Pump until no more frames are pending (animations complete, FutureBuilders resolve)
await tester.pumpAndSettle();

// Pump a specific duration (useful for timed animations)
await tester.pump(const Duration(milliseconds: 500));

Finders

find.text('Submit')               // By display text
find.byKey(const Key('submit'))   // By Key
find.byType(ElevatedButton)       // By widget type
find.byIcon(Icons.add)            // By icon
find.byTooltip('Add item')        // By tooltip string
find.descendant(
  of: find.byKey(const Key('cart')),
  matching: find.byType(IconButton),
)                                 // Scoped search

Matchers

expect(find.text('Hello'), findsOneWidget);     // Exactly one
expect(find.text('Item'), findsNWidgets(3));    // Exactly three
expect(find.text('Ghost'), findsNothing);       // Zero
expect(find.text('Button'), findsWidgets);      // One or more

Testing interactions

testWidgets('add to cart button triggers callback', (tester) async {
  var addedProductId = '';

  await tester.pumpWidget(
    MaterialApp(
      home: ProductCard(
        product: testProduct,
        onAddToCart: (id) => addedProductId = id,
      ),
    ),
  );

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

  expect(addedProductId, equals(testProduct.id));
});

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

  await tester.enterText(find.byKey(const Key('email_field')), 'not-an-email');
  await tester.tap(find.byKey(const Key('submit_button')));
  await tester.pump();

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

Testing with Riverpod

testWidgets('cart shows item count', (tester) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        cartNotifierProvider.overrideWith(() {
          return CartNotifier()..state = Cart(
            items: [CartItem(product: testProduct, quantity: 2)],
          );
        }),
      ],
      child: const MaterialApp(home: CartBadge()),
    ),
  );

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

Testing async widgets

testWidgets('shows loading then content', (tester) async {
  final completer = Completer<List<Product>>();

  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        productsProvider.overrideWith((_) => completer.future),
      ],
      child: const MaterialApp(home: ProductsScreen()),
    ),
  );

  // Before future resolves
  expect(find.byType(CircularProgressIndicator), findsOneWidget);

  // Resolve the future
  completer.complete([testProduct]);
  await tester.pumpAndSettle();

  // After future resolves
  expect(find.byType(CircularProgressIndicator), findsNothing);
  expect(find.text(testProduct.name), findsOneWidget);
});

Testing with BLoC

testWidgets('error state shows retry button', (tester) async {
  final bloc = MockProductsBloc();
  whenListen(
    bloc,
    Stream.value(ProductsError('Network error')),
    initialState: ProductsLoading(),
  );

  await tester.pumpWidget(
    BlocProvider<ProductsBloc>.value(
      value: bloc,
      child: const MaterialApp(home: ProductsScreen()),
    ),
  );
  await tester.pumpAndSettle();

  expect(find.text('Network error'), findsOneWidget);
  expect(find.text('Retry'), findsOneWidget);
});

Golden tests

Golden tests compare widget screenshots to stored reference images:

testWidgets('ProductCard matches golden', (tester) async {
  await tester.pumpWidget(
    MaterialApp(home: ProductCard(product: testProduct)),
  );

  await expectLater(
    find.byType(ProductCard),
    matchesGoldenFile('goldens/product_card.png'),
  );
});

// Generate goldens:
// flutter test --update-goldens
// Check in the generated images

Common pitfalls

Not calling pumpAndSettle after async operations. If a widget triggers a network call (even a mocked one), you need to await tester.pumpAndSettle() to let the FutureBuilder rebuild with the result.

Wrapping test widgets in Scaffold when not needed. Some widgets (Text, Container) work without Scaffold. But widgets that use Theme, MediaQuery, Navigator, or Scaffold.of(context) need to be wrapped in MaterialApp. When in doubt, wrap in MaterialApp.

Testing implementation details. Tests that check whether a specific private method was called are brittle. Test visible behavior: the text that appears, the navigation that happens, the callback that fires.

Sign in to like, dislike, or report.

Widget testing in Flutter — ANN Tech