Skip to content
End To End Tester
Platformspractical

Flutter Testing

Flutter renders to a canvas with no native accessibility tree, so it brings its own three-layer test harness — unit, widget and integration — plus golden files.

2 min read · updated 19 September 2026

Flutter draws every pixel itself. There is no native view hierarchy, which means XCUITest and UIAutomator see a single canvas and nothing else.

So Flutter supplies its own harness, and it is unusually good: the middle layer — widget tests — is fast enough and capable enough that most Flutter suites live there.

#Three layers

Runs where Speed Covers
Unit Dart VM microseconds pure logic
Widget headless Flutter milliseconds a widget tree, layout, gestures
Integration real device seconds the whole app, platform channels

#Unit

dart
// test/pricing_test.dart
void main() {
  group('applyDiscount', () {
    for (final (subtotal, expected) in [(12000, 2400), (10000, 2000), (9999, 0)]) {
      test('discounts $subtotal by $expected', () {
        expect(applyDiscount(Order(subtotalCents: subtotal), policy).discountCents, expected);
      });
    }
  });
}

#Widget tests

The heart of a Flutter suite. A real widget tree, real layout, real gesture dispatch, in a headless environment.

dart
// test/checkout_test.dart
void main() {
  testWidgets('pay is disabled until a card is entered', (tester) async {
    await tester.pumpWidget(MaterialApp(
      home: CheckoutScreen(basket: Basket.withOneBook()),
    ));

    expect(tester.widget<ElevatedButton>(find.byKey(const Key('pay'))).enabled, isFalse);

    await tester.enterText(find.byKey(const Key('card-number')), '4242424242424242');
    await tester.pump();                       // one frame

    expect(tester.widget<ElevatedButton>(find.byKey(const Key('pay'))).enabled, isTrue);
  });

  testWidgets('shows a confirmation after paying', (tester) async {
    await tester.pumpWidget(MaterialApp(
      home: CheckoutScreen(basket: Basket.withOneBook(), gateway: FakeGateway.succeeding()),
    ));

    await tester.enterText(find.byKey(const Key('card-number')), '4242424242424242');
    await tester.tap(find.byKey(const Key('pay')));
    await tester.pumpAndSettle();              // run frames until nothing is animating

    expect(find.text('Order confirmed'), findsOneWidget);
  });

  testWidgets('the total stays visible on a small screen', (tester) async {
    tester.view.physicalSize = const Size(320, 640);
    tester.view.devicePixelRatio = 1.0;
    addTearDown(tester.view.reset);

    await tester.pumpWidget(const MaterialApp(home: CheckoutScreen()));
    await tester.pumpAndSettle();

    expect(find.text('£27.95'), findsOneWidget);
    expect(tester.takeException(), isNull);    // no overflow errors
  });
}

Three things worth noting:

pump versus pumpAndSettle. pump advances one frame; pumpAndSettle runs frames until nothing is scheduled. pumpAndSettle times out against an indefinite animation — a looping spinner is the usual culprit — so in that case pump a fixed count instead.

tester.takeException() catches layout overflow errors, which are Flutter's most common visual defect and are otherwise only a yellow stripe in a screenshot nobody looks at.

Screen size is settable, so responsive layout is testable at this level rather than needing a device.

#Finders

dart
find.text('Pay');
find.byKey(const Key('pay'));                  // the stable choice
find.byType(ElevatedButton);
find.byIcon(Icons.shopping_cart);
find.bySemanticsLabel('Card number');          // what a screen reader announces
find.widgetWithText(ElevatedButton, 'Pay');
find.descendant(of: find.byType(ListTile), matching: find.text('Field Notes'));

find.byKey with a const Key is the most stable, and bySemanticsLabel is the one that also checks the app is accessible — see accessibility testing.

#Golden tests

Flutter's built-in visual regression:

dart
testWidgets('checkout matches the golden', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: CheckoutScreen()));
  await tester.pumpAndSettle();

  await expectLater(
    find.byType(CheckoutScreen),
    matchesGoldenFile('goldens/checkout.png'),
  );
});
bash
flutter test --update-goldens

Font rendering differs between platforms, so goldens generated on macOS will not match a Linux CI runner. Generate them in the same container CI uses, or restrict golden tests to a dedicated job. The general snapshot caveats apply in full.

#Integration tests

dart
// integration_test/checkout_test.dart
void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('a customer can complete a purchase', (tester) async {
    app.main();
    await tester.pumpAndSettle();

    await tester.tap(find.byKey(const Key('product-field-notes')));
    await tester.pumpAndSettle();
    await tester.tap(find.byKey(const Key('add-to-basket')));
    await tester.pumpAndSettle();

    expect(find.text('1 item'), findsOneWidget);
  });
}
bash
flutter test integration_test --device-id emulator-5554

These run the real app on a real device, so platform channels, plugins and native integrations are genuinely exercised. Keep the count small — the pyramid argument applies, and widget tests already cover most of what these would.

#Mocking

dart
// mocktail — no code generation, which mockito requires
class MockGateway extends Mock implements PaymentGateway {}

setUpAll(() => registerFallbackValue(ChargeRequest.empty()));

test('charges the basket total', () async {
  final gateway = MockGateway();
  when(() => gateway.charge(any())).thenAnswer((_) async => ChargeResult.ok('pi_1'));

  await Checkout(gateway).pay(basket);

  verify(() => gateway.charge(any(that: isA<ChargeRequest>()
      .having((r) => r.cents, 'cents', 2795)))).called(1);
});

#Appium and Flutter

If you need one cross-platform suite that includes Flutter screens, the appium-flutter-driver drives the semantics tree rather than the native hierarchy. It works, it is slower than widget tests by orders of magnitude, and it is only worth it when Flutter is one part of a larger Appium estate.

Common questions

Why can standard mobile automation tools not drive a Flutter app?
Flutter paints everything onto a canvas rather than composing native views, so there is no native view hierarchy for XCUITest or UIAutomator to walk. Flutter exposes a semantics tree for accessibility, and Appium's Flutter driver uses it, but the native tools see one large canvas.
What is the difference between a widget test and an integration test in Flutter?
A widget test runs in a headless Flutter environment on your machine in milliseconds, with a fake window and no platform channels. An integration test runs the real app on a device or emulator and takes seconds. Widget tests cover far more than component tests usually can, so most Flutter suites are widget tests.
Should I use pumpAndSettle?
Usually, but not with anything that animates forever — a looping progress indicator makes pumpAndSettle time out. In that case pump a fixed number of frames instead, or pump with an explicit duration.

Runnable samples for this page

last test results ↗
  • Dartflutter/test/platforms/flutter-testing

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?