Week 17: Integration Testing with integration_test

Week 16's widget tests verify pieces in isolation, in a simulated test environment. This week verifies the whole thing — a real, compiled app running on a real device or emulator, driven exactly the way a user would drive it, with the integration_test package that ships as part of the Flutter SDK.

Phase 7 of 8 Week 17 of 20 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Set up integration_test and run a test against a real device or emulator
  • Write an end-to-end flow driving multiple screens
  • Use pumpAndSettle correctly for animated and asynchronous UI
  • Run an integration test in CI

1. Setting Up integration_test

terminal
flutter pub add --dev integration_test --sdk=flutter
integration_test/app_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:myapp/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('full add-to-cart flow', (tester) async {
    app.main();
    await tester.pumpAndSettle();

    // the rest of the flow goes here
  });
}
terminal — running it
flutter test integration_test/app_test.dart

Unlike Week 16's widget tests, this runs the real main() entry point on a real device or emulator — genuinely exercising platform channels, real animations, and real navigation, not a simulated test-only environment.

2. A Multi-Screen Flow

a real end-to-end flow
testWidgets('browse, add to cart, and see the updated badge', (tester) async {
    app.main();
    await tester.pumpAndSettle();

    // tap the first product
    await tester.tap(find.byKey(const Key('product-0')));
    await tester.pumpAndSettle();

    // add it to the cart from the detail screen
    await tester.tap(find.text('Add to Cart'));
    await tester.pumpAndSettle();

    // navigate back
    await tester.tap(find.byTooltip('Back'));
    await tester.pumpAndSettle();

    // confirm the badge updated
    expect(find.text('1'), findsOneWidget);
});

find.byKey is worth using deliberately for anything an integration test needs to target reliably — a stable Key('product-0') survives copy changes and localization the way matching by visible text does not, the same reasoning behind testID in this site's React Native course.

3. pumpAndSettle for Async & Animated UI

pumpAndSettle() repeatedly pumps frames until no more are scheduled — the right tool once an animation, a network call, or any delayed state change is involved, which is most of a real app.

waiting for a network-backed screen to finish loading
await tester.tap(find.text('Load Products'));
await tester.pumpAndSettle(const Duration(seconds: 5)); // a genuine timeout ceiling

expect(find.byType(ListTile), findsWidgets);

A plain pump() only advances one frame — insufficient for anything still animating or awaiting a future. But pumpAndSettle itself times out and throws if something never stops scheduling frames (a repeating animation, like Week 14's pulsing icon, that legitimately never "settles") — worth knowing before spending time debugging what looks like a hang.

4. Running Integration Tests in CI

.github/workflows/integration_test.yml — Android emulator
jobs:
  integration-test:
    runs-on: macos-latest # needed for hardware-accelerated emulation
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
      - uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 33
          script: flutter test integration_test/app_test.dart

Like Week 16's widget tests, an integration test needs a genuinely running device or emulator to execute against — meaningfully more CI setup and time than a unit or widget test suite, which is why most teams run the fast suite (Weeks 16's unit/widget tests) on every push and reserve integration tests for merges into a main branch or a nightly run, the same tradeoff this site's React Native course makes with Detox.

5. Hands-on Exercise

Hands-on

Write an end-to-end integration test

Drive the real app through a multi-screen flow, end to end.

Requirements:

  1. Set up integration_test and confirm a minimal test (just launching the app and asserting one widget is present) runs successfully on a real device or emulator.
  2. Add stable Keys to at least 3 elements the test needs to target reliably.
  3. A full flow test: browse to a product, add it to the cart, navigate back, and assert the cart badge updated correctly.
  4. Confirm the test correctly uses pumpAndSettle after every navigation and async step, with no flaky failures across at least 3 consecutive runs.
  5. Document in a short comment what CI setup (a macOS runner for iOS, an Android emulator action for Android) would be needed to run this in CI, even if you don't wire up the actual CI job.
Hint

If pumpAndSettle() throws claiming it timed out, check whether anything on screen is a genuinely infinite/repeating animation (like a looping spinner or Week 14's pulsing icon) — pumpAndSettle waits for frames to stop being scheduled entirely, which a truly indefinite animation never does; in that case, use a bounded plain pump(duration) instead of pumpAndSettle around that specific screen.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What does an integration test exercise that a widget test (Week 16) does not?

The real, compiled app running on an actual device or emulator — real platform channels, real native rendering, real navigation transitions, and the actual main() entry point — rather than a simulated in-memory test environment that stubs out platform-level behavior. It's a genuinely different, higher-fidelity kind of test, at the cost of being much slower to run.

Q2

Why is targeting elements with find.byKey often preferred over find.text in an integration test meant to be stable over time?

A Key is a deliberate, stable identifier that doesn't change with copy edits, localization, or minor UI rewording — matching by visible text ties the test to exact wording, which breaks the test on changes that have nothing to do with whether the actual flow still works correctly.

Q3

Why can pumpAndSettle() throw a timeout error on a screen with a legitimately infinite animation, and what should you do about it?

pumpAndSettle keeps pumping frames until none are scheduled — an indefinitely looping animation (like Week 14's pulsing icon) never reaches that state on its own, so pumpAndSettle eventually gives up and throws. The fix is to use a bounded plain pump(duration) for a fixed wait around that specific screen instead of expecting the animation to ever "settle."

Q4

Why do most teams run integration tests less frequently (e.g. only on merge to main) than the unit/widget test suite?

An integration test needs an actual device or emulator running, which takes meaningfully longer to set up and execute than an in-memory widget test — running the full integration suite on every single push would slow down every contributor's feedback loop for a cost that isn't worth paying that often. The fast unit/widget suite catches most regressions cheaply on every push; integration tests are reserved for checkpoints where that higher-fidelity, real-device check is worth the wait.