Test end-to-end in Flutter con integration_test: setup, robot pattern e CI

Foto di Lewis Kang'ethe Ngugi su Unsplash

Best practiceAvanzato45 min Flutter 3.x

Test end-to-end in Flutter con integration_test: setup, robot pattern e CI

I widget test verificano singoli componenti, ma non dicono nulla su come l'app si comporta davvero su un dispositivo, con il suo motore di rendering, i plugin nativi e la navigazione reale.

Il pacchetto integration_test, mantenuto dal team Flutter, permette di scrivere test end-to-end che girano sull'app completa, su emulatore, device fisico o browser.

In questo tutorial avanzato vedremo:

  • come configurare integration_test in un progetto esistente;
  • come strutturare i test con il robot pattern per renderli leggibili e manutenibili;
  • come gestire attese, animazioni infinite e chiamate di rete mockate;
  • come raccogliere screenshot e metriche di performance;
  • come eseguire la suite in CI con GitHub Actions.

Requisiti: conoscenza di flutter_test, WidgetTester e di un gestore di stato (useremo Riverpod, ma il concetto è identico con Provider o BLoC).

  1. 1

    Configurare integration_test nel progetto

    Aggiungi le dipendenze di sviluppo nel pubspec.yaml. integration_test è distribuito con l'SDK, quindi si usa sdk: flutter.

    Crea poi la cartella integration_test/ nella root del progetto (allo stesso livello di lib/ e test/).

    Un dettaglio spesso dimenticato: IntegrationTestWidgetsFlutterBinding.ensureInitialized() va chiamato come prima riga di main(), altrimenti il binding di default impedisce l'esecuzione su device reale.

    # pubspec.yaml
    dev_dependencies:
      flutter_test:
        sdk: flutter
      integration_test:
        sdk: flutter
      mocktail: ^1.0.0
    
    # integration_test/app_test.dart
    # (scheletro iniziale)
    

    Risultato atteso

    `flutter pub get` completa senza errori e la cartella integration_test è pronta.

  2. 2

    Rendere l'app testabile: entry point parametrizzato

    Un'app E2E va avviata dal test con le stesse dipendenze della produzione, ma con la possibilità di sostituirne alcune (client HTTP, storage, servizi nativi).

    Estrai da main.dart una funzione bootstrap() che accetta gli override. Con Riverpod si usa la lista overrides di ProviderScope; con get_it si registrano le istanze fake prima di runApp.

    Questo passo è la chiave dell'intera architettura di test: senza un entry point iniettabile finirai per testare la rete reale, con test lenti e instabili.

    // lib/bootstrap.dart
    import 'package:flutter/material.dart';
    import 'package:flutter_riverpod/flutter_riverpod.dart';
    import 'app.dart';
    
    Future<void> bootstrap({List<Override> overrides = const []}) async {
      WidgetsFlutterBinding.ensureInitialized();
      runApp(
        ProviderScope(
          overrides: overrides,
          child: const MyApp(),
        ),
      );
    }
    
    // lib/main.dart
    Future<void> main() => bootstrap();

    Risultato atteso

    L'app si avvia normalmente e può essere lanciata anche dai test con dipendenze sostituite.

  3. 3

    Il primo test end-to-end

    Crea integration_test/login_test.dart. La struttura è quella di un widget test, ma con il binding di integrazione e l'app reale.

    Punti di attenzione:

    • usa tester.pumpAndSettle() dopo azioni che avviano animazioni o navigazione;
    • se l'app ha animazioni infinite (spinner, shimmer) pumpAndSettle va in timeout: usa tester.pump(const Duration(milliseconds: 300)) in un ciclo, oppure disattiva le animazioni in modalità test;
    • preferisci find.byKey ai finder testuali: i testi cambiano con la localizzazione.
    import 'package:flutter/material.dart';
    import 'package:flutter_test/flutter_test.dart';
    import 'package:integration_test/integration_test.dart';
    import 'package:myapp/bootstrap.dart';
    
    void main() {
      IntegrationTestWidgetsFlutterBinding.ensureInitialized();
    
      testWidgets('login con credenziali valide porta alla home', (tester) async {
        await bootstrap(overrides: [/* fake repository */]);
        await tester.pumpAndSettle();
    
        await tester.enterText(find.byKey(const Key('email_field')), 'mario@test.it');
        await tester.enterText(find.byKey(const Key('password_field')), 'password123');
        await tester.testTextInput.receiveAction(TextInputAction.done);
        await tester.pump();
    
        await tester.tap(find.byKey(const Key('login_button')));
        await tester.pumpAndSettle(const Duration(seconds: 3));
    
        expect(find.byKey(const Key('home_screen')), findsOneWidget);
      });
    }

    Risultato atteso

    Con `flutter test integration_test/login_test.dart -d <device>` l'app si avvia sul device e il test passa.

  4. 4

    Organizzare i test con il robot pattern

    Man mano che i flussi crescono, i test diventano muri di find.byKey illeggibili. Il robot pattern incapsula le interazioni con una schermata in una classe dedicata, esponendo metodi che parlano il linguaggio del dominio.

    Vantaggi: se cambia una Key, si aggiorna un solo file; i test diventano documentazione leggibile anche dal product owner.

    Convenzione: un robot per schermata, con metodi di azione (inserisciEmail) e di verifica (verificaErrore).

    class LoginRobot {
      LoginRobot(this.tester);
      final WidgetTester tester;
    
      Future<void> inserisciCredenziali(String email, String pw) async {
        await tester.enterText(find.byKey(const Key('email_field')), email);
        await tester.enterText(find.byKey(const Key('password_field')), pw);
        await tester.pump();
      }
    
      Future<void> premiAccedi() async {
        await tester.tap(find.byKey(const Key('login_button')));
        await tester.pumpAndSettle();
      }
    
      void verificaErrore(String messaggio) {
        expect(find.text(messaggio), findsOneWidget);
      }
    }
    
    // Uso nel test
    testWidgets('credenziali errate mostrano errore', (tester) async {
      await bootstrap(overrides: [authOverrideChefallisce]);
      await tester.pumpAndSettle();
    
      final login = LoginRobot(tester);
      await login.inserisciCredenziali('x@y.it', 'sbagliata');
      await login.premiAccedi();
      login.verificaErrore('Credenziali non valide');
    });

    Risultato atteso

    I test diventano una sequenza di frasi leggibili; le Key sono centralizzate nei robot.

  5. 5

    Gestire attese, scroll e stati asincroni instabili

    I test E2E falliscono soprattutto per problemi di timing. Alcune tecniche consolidate:

    1. Attesa condizionale: invece di pumpAndSettle cieco, cicla il pump finché un finder non compare o scade un timeout.
    2. Scroll fino all'elemento: tester.scrollUntilVisible per liste lunghe con ListView.builder.
    3. Animazioni disattivate: esponi un flag di configurazione che imposta durate a Duration.zero in modalità test.
    4. Evita Future.delayed arbitrari nel codice di produzione: rendono i test lenti e fragili.
    Future<void> waitFor(
      WidgetTester tester,
      Finder finder, {
      Duration timeout = const Duration(seconds: 10),
    }) async {
      final fine = DateTime.now().add(timeout);
      while (DateTime.now().isBefore(fine)) {
        await tester.pump(const Duration(milliseconds: 100));
        if (finder.evaluate().isNotEmpty) return;
      }
      throw TestFailure('Timeout: $finder non trovato entro $timeout');
    }
    
    // Scroll in una lista lunga
    await tester.scrollUntilVisible(
      find.byKey(const Key('item_87')),
      300,
      scrollable: find.byType(Scrollable).first,
    );

    Risultato atteso

    I test non vanno più in timeout su spinner o liste lunghe e diventano deterministici.

  6. 6

    Screenshot e metriche di performance

    IntegrationTestWidgetsFlutterBinding permette di catturare screenshot (utili per la verifica visiva e per gli store) e di misurare il tempo di rendering dei frame con traceAction.

    Su Android serve convertFlutterSurfaceToImage() prima dello screenshot. Su iOS la chiamata non è necessaria (e va evitata).

    Per le performance, reportData viene scritto su file quando si usa flutter drive, permettendo analisi di jank su build profile.

    late IntegrationTestWidgetsFlutterBinding binding;
    
    void main() {
      binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
    
      testWidgets('screenshot della home', (tester) async {
        await bootstrap();
        await tester.pumpAndSettle();
    
        if (Platform.isAndroid) {
          await binding.convertFlutterSurfaceToImage();
          await tester.pumpAndSettle();
        }
        await binding.takeScreenshot('home');
      });
    
      testWidgets('scroll fluido della lista', (tester) async {
        await bootstrap();
        await tester.pumpAndSettle();
    
        await binding.traceAction(() async {
          await tester.fling(find.byType(ListView), const Offset(0, -500), 3000);
          await tester.pumpAndSettle();
        }, reportKey: 'scroll_timeline');
      });
    }

    Risultato atteso

    Gli screenshot vengono salvati dal driver e il timeline dello scroll è disponibile per l'analisi del jank.

  7. 7

    Eseguire la suite in CI con GitHub Actions

    L'ultimo tassello è l'automazione. Su GitHub Actions si usa un emulatore Android headless (reactivecircus/android-emulator-runner) e si lancia l'intera cartella integration_test.

    Consigli pratici:

    • crea integration_test/all_tests.dart che importa e invoca i main() dei singoli file: riduce i riavvii dell'app e accorcia molto i tempi;
    • fissa una versione di API level stabile (33/34) e abilita la cache dell'AVD;
    • marca il job come required sulla PR solo quando la suite è stabile, altrimenti diventa rumore.
    name: e2e
    on: [pull_request]
    
    jobs:
      android:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-java@v4
            with:
              distribution: 'zulu'
              java-version: '17'
          - uses: subosito/flutter-action@v2
            with:
              flutter-version: '3.24.0'
          - run: flutter pub get
          - name: Run integration tests
            uses: reactivecircus/android-emulator-runner@v2
            with:
              api-level: 34
              arch: x86_64
              profile: pixel_6
              script: flutter test integration_test/all_tests.dart

    Risultato atteso

    Ad ogni pull request la suite E2E gira su emulatore e blocca il merge in caso di regressioni sui flussi critici.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!