Test end-to-end in Flutter su device farm: integration_test, screenshot e Firebase Test Lab

Foto di Darío Bonzi su Unsplash

Best practiceAvanzato55 min Flutter 3.x

Test end-to-end in Flutter su device farm: integration_test, screenshot e Firebase Test Lab

I test integration_test eseguiti sull'emulatore locale coprono solo una frazione dei dispositivi reali. Jank, permessi, densità di schermo e versioni Android differenti emergono solo su hardware vero.

In questo tutorial avanzato vedremo come:

  • strutturare una suite e2e deterministica con IntegrationTestWidgetsFlutterBinding;
  • compilare l'androidTest APK senza avviare un device;
  • eseguire la suite su Firebase Test Lab su una matrice di dispositivi;
  • catturare screenshot reali e scaricare i risultati;
  • orchestrare il tutto in CI con shard e report.

Prerequisiti: conoscenza di integration_test, Gradle, gcloud CLI e un progetto Firebase con Blaze o quota gratuita Test Lab.

  1. 1

    Preparare una suite e2e deterministica

    Crea integration_test/app_test.dart. Su device reali il tempo è variabile: evita pumpAndSettle() infiniti e usa attese condizionate su un finder.

    Punti chiave:

    • IntegrationTestWidgetsFlutterBinding.ensureInitialized() abilita il canale verso il driver;
    • binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive evita che le animazioni infinite blocchino pumpAndSettle;
    • disattiva animazioni non necessarie tramite flag di build (--dart-define).
    import 'package:flutter_test/flutter_test.dart';
    import 'package:integration_test/integration_test.dart';
    import 'package:myapp/main.dart' as app;
    
    Future<void> waitFor(WidgetTester t, Finder f,
        {Duration timeout = const Duration(seconds: 20)}) async {
      final end = DateTime.now().add(timeout);
      while (DateTime.now().isBefore(end)) {
        await t.pump(const Duration(milliseconds: 100));
        if (f.evaluate().isNotEmpty) return;
      }
      throw TestFailure('Timeout su: $f');
    }
    
    void main() {
      final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
      binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
    
      testWidgets('login e apertura dashboard', (tester) async {
        app.main();
        await waitFor(tester, find.byKey(const Key('email')));
    
        await tester.enterText(find.byKey(const Key('email')), 'demo@test.it');
        await tester.enterText(find.byKey(const Key('password')), 'secret123');
        await tester.tap(find.byKey(const Key('submit')));
    
        await waitFor(tester, find.byKey(const Key('dashboard')));
        expect(find.text('Benvenuto'), findsOneWidget);
      });
    }

    Risultato atteso

    `flutter test integration_test/app_test.dart -d <emulatore>` passa senza timeout casuali.

  2. 2

    Catturare screenshot reali dal device

    Su Android servono due passaggi: convertFlutterSurfaceToImage() prima dello screenshot e il salvataggio lato host nel testExecutable del driver.

    Crea test_driver/integration_test.dart per scrivere i PNG su disco quando esegui con flutter drive. Su Test Lab, invece, gli screenshot vanno salvati nella directory pubblica del device e raccolti dal runner.

    // integration_test/app_test.dart (estratto)
    Future<void> shot(IntegrationTestWidgetsFlutterBinding b,
        WidgetTester t, String name) async {
      if (!kIsWeb && Platform.isAndroid) {
        await b.convertFlutterSurfaceToImage();
        await t.pumpAndSettle();
      }
      await b.takeScreenshot(name);
    }
    
    // test_driver/integration_test.dart
    import 'dart:io';
    import 'package:integration_test/integration_test_driver_extended.dart';
    
    Future<void> main() => integrationDriver(
          onScreenshot: (name, bytes, [_]) async {
            final f = File('screenshots/$name.png')..createSync(recursive: true);
            f.writeAsBytesSync(bytes);
            return true;
          },
        );

    Risultato atteso

    `flutter drive --driver=test_driver/integration_test.dart --target=integration_test/app_test.dart` produce i PNG in `screenshots/`.

  3. 3

    Compilare APK app e APK di test senza device

    Firebase Test Lab richiede due artefatti: l'APK dell'app (debug) e l'androidTest APK che contiene la suite.

    Assicurati che android/app/build.gradle includa la dipendenza del runner e il testInstrumentationRunner. Poi compila con Gradle.

    // android/app/build.gradle
    android {
      defaultConfig {
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
      }
    }
    dependencies {
      androidTestImplementation 'androidx.test:runner:1.5.2'
      androidTestImplementation 'androidx.test:rules:1.5.0'
    }

    Risultato atteso

    I comandi `flutter build apk --debug --dart-define=E2E=true` `pushd android && ./gradlew app:assembleDebugAndroidTest && popd` generano `app-debug.apk` e `app-debug-androidTest.apk`.

  4. 4

    Eseguire la matrice su Firebase Test Lab

    Con gcloud lanci la suite su più dispositivi reali in parallelo. Usa --directories-to-pull per recuperare gli screenshot e --timeout per evitare costi fuori controllo.

    Suggerimenti:

    • --num-flaky-test-attempts 1 distingue i flake dai fallimenti reali;
    • --results-bucket centralizza i report in un bucket GCS;
    • includi almeno un device low-end per far emergere il jank.
    gcloud firebase test android run \
      --type instrumentation \
      --app build/app/outputs/flutter-apk/app-debug.apk \
      --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
      --device model=redfin,version=30,locale=it,orientation=portrait \
      --device model=Pixel2.arm,version=28,locale=it,orientation=portrait \
      --timeout 10m \
      --num-flaky-test-attempts 1 \
      --directories-to-pull /sdcard/Download \
      --results-bucket gs://my-e2e-results \
      --results-dir run-$(date +%s)

    Risultato atteso

    La console mostra l'esito per ciascun device e un link ai report con video, logcat e performance metrics.

  5. 5

    Sharding e integrazione in GitHub Actions

    Con suite lunghe conviene dividere i test in shard: ogni job compila una sola volta e lancia Test Lab con un filtro diverso (--test-targets), riducendo il tempo di wall-clock.

    Il workflow autentica l'account di servizio, compila gli APK e lancia il run. Il exit code di gcloud fa fallire la pipeline in caso di test rossi.

    name: e2e
    on: [workflow_dispatch, pull_request]
    jobs:
      testlab:
        runs-on: ubuntu-latest
        strategy:
          matrix:
            shard: [login, checkout]
        steps:
          - uses: actions/checkout@v4
          - uses: subosito/flutter-action@v2
            with: { flutter-version: '3.24.0' }
          - run: flutter build apk --debug --dart-define=E2E=true
          - run: cd android && ./gradlew app:assembleDebugAndroidTest
          - uses: google-github-actions/auth@v2
            with:
              credentials_json: ${{ secrets.GCP_SA_KEY }}
          - uses: google-github-actions/setup-gcloud@v2
          - run: |
              gcloud firebase test android run \
                --type instrumentation \
                --app build/app/outputs/flutter-apk/app-debug.apk \
                --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
                --device model=redfin,version=30 \
                --test-targets "class com.example.${{ matrix.shard }}E2E" \
                --timeout 10m

    Risultato atteso

    Due job paralleli, uno per shard, con fallimento della PR se un test e2e è rosso.

  6. 6

    Stabilizzare la suite: flake budget e dati di test

    Una suite e2e è utile solo se affidabile. Regole pratiche:

    1. Backend isolato: punta a un ambiente di staging o a un mock server avviato via --dart-define=API_BASE_URL, mai alla produzione.
    2. Seed deterministico: ricrea l'utente di test all'inizio di ogni run tramite un endpoint dedicato; evita test che dipendono dall'ordine.
    3. Niente sleep fissi: sostituiscili con il waitFor del passo 1.
    4. Flake budget: traccia i retry; se un test supera il 2% di flakiness in una settimana, va riscritto o messo in quarantena, non ritentato all'infinito.
    5. Piramide: mantieni poche decine di scenari e2e sui flussi critici (login, pagamento, onboarding); il resto va coperto da widget e golden test, molto più veloci.
    const baseUrl = String.fromEnvironment('API_BASE_URL',
        defaultValue: 'https://api.example.com');
    const isE2E = bool.fromEnvironment('E2E');
    
    void configureForTests() {
      if (isE2E) {
        timeDilation = 1.0; // niente animazioni rallentate
        // disabilita analytics, crash reporting e banner promozionali
      }
    }

    Risultato atteso

    Suite e2e ripetibile: tre run consecutivi sulla stessa matrice danno lo stesso esito.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!