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
// 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.
// 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
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:
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'),
);
});flutter test --update-goldensFont 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
// 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);
});
}flutter test integration_test --device-id emulator-5554These 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
// 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 ↗- Dart
flutter/test/platforms/flutter-testing
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- iOS App TestingXCTest, Swift Testing, XCUITest and the accessibility identifiers that make UI automation possible — plus what to run on a simulator and what needs a device.
- Android App TestingJUnit and Robolectric for local tests, Espresso and Compose for instrumented UI, and the emulator strategy that keeps an Android suite affordable in CI.
- AppiumOne WebDriver-based API across iOS and Android — how the drivers map onto XCUITest and UIAutomator, locator strategies, and the cost of cross-platform tests.
- Snapshot TestingRecording output and comparing it on every run — where snapshots earn their place, the approval reflex that destroys their value, and better alternatives.
- The Testing PyramidWhy cheap tests must outnumber expensive ones, what the pyramid actually claims, the trophy and honeycomb alternatives, and how to tell which shape you really have.
- PlaywrightThe default browser automation tool in 2026 — auto-waiting locators, tracing, sharding and fixtures — with the configuration that matters and the mistakes that still cause flakiness.