Test end-to-end deterministici in Flutter: integration_test con backend finto e tempo controllato

Foto di Artur Shamsutdinov su Unsplash

Best practiceAvanzato45 min Flutter 3.x

Test end-to-end deterministici in Flutter: integration_test con backend finto e tempo controllato

I test end-to-end falliscono raramente per un bug reale: molto più spesso per non determinismo. Rete lenta, animazioni infinite, date che cambiano, token scaduti.

In questo tutorial avanzato costruiamo una suite integration_test ermetica: l'app gira sul device reale ma parla con un server HTTP avviato nel processo di test, usa un orologio fisso e attese basate su condizioni invece che su Future.delayed.

Prerequisiti: conoscenza di integration_test, dependency injection e HttpServer di dart:io.

  1. 1

    Rendere l'app configurabile dall'esterno

    Il primo requisito di un test ermetico è poter iniettare baseUrl, client HTTP e sorgente del tempo. Definiamo un AppConfig immutabile e un entry point parametrico, separato da main().

    Così il test non modifica il codice di produzione: lo compone diversamente.

    // lib/app/app_config.dart
    class AppConfig {
      final Uri baseUrl;
      final DateTime Function() now;
      final Duration animationScale;
    
      const AppConfig({
        required this.baseUrl,
        required this.now,
        this.animationScale = const Duration(milliseconds: 300),
      });
    }
    
    // lib/app/bootstrap.dart
    Widget buildApp(AppConfig config) {
      return ProviderScope(
        overrides: [appConfigProvider.overrideWithValue(config)],
        child: const MyApp(),
      );
    }
    
    // lib/main.dart
    void main() => runApp(buildApp(AppConfig(
          baseUrl: Uri.parse('https://api.example.com'),
          now: DateTime.now,
        )));

    Risultato atteso

    L'app in produzione funziona come prima, ma è ora istanziabile con una configurazione arbitraria.

  2. 2

    Avviare un backend finto in-process

    Invece di mockare il client HTTP (che non testa serializzazione e header reali), avviamo un vero HttpServer su 127.0.0.1:0. Il device e il test girano nello stesso processo con integration_test, quindi il loopback funziona su Android, iOS e desktop.

    Il server espone un router minimale e permette di programmare risposte per scenario (200, 401, 500).

    // integration_test/support/fake_backend.dart
    import 'dart:convert';
    import 'dart:io';
    
    class FakeBackend {
      late HttpServer _server;
      final Map<String, dynamic Function(HttpRequest)> routes = {};
      final List<String> calls = [];
    
      Uri get baseUrl => Uri.parse('http://127.0.0.1:${_server.port}');
    
      Future<void> start() async {
        _server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
        _server.listen((req) async {
          final key = '${req.method} ${req.uri.path}';
          calls.add(key);
          final handler = routes[key];
          if (handler == null) {
            req.response.statusCode = 404;
          } else {
            final body = handler(req);
            req.response
              ..statusCode = 200
              ..headers.contentType = ContentType.json
              ..write(jsonEncode(body));
          }
          await req.response.close();
        });
      }
    
      Future<void> stop() => _server.close(force: true);
    }

    Risultato atteso

    Un server locale su porta effimera, con registro delle chiamate ricevute per le asserzioni.

  3. 3

    Congelare il tempo e disattivare le animazioni infinite

    Due classiche cause di flakiness:

    1. Date relative ("2 ore fa") che cambiano tra un run e l'altro.
    2. Animazioni che non si fermano mai (shimmer, spinner), che fanno andare pumpAndSettle() in timeout.

    Usiamo un clock fisso e, nei test, sostituiamo i loader infiniti con un widget statico tramite il flag di configurazione.

    // integration_test/support/test_clock.dart
    class TestClock {
      DateTime _now = DateTime.utc(2024, 3, 15, 10, 0);
      DateTime call() => _now;
      void advance(Duration d) => _now = _now.add(d);
    }
    
    // Nel widget di caricamento
    class AppLoader extends ConsumerWidget {
      const AppLoader({super.key});
      @override
      Widget build(BuildContext context, WidgetRef ref) {
        final scale = ref.watch(appConfigProvider).animationScale;
        if (scale == Duration.zero) {
          return const SizedBox(key: Key('loader'), height: 24, width: 24);
        }
        return const CircularProgressIndicator(key: Key('loader'));
      }
    }

    Risultato atteso

    Con `animationScale: Duration.zero` nessuna animazione perpetua blocca `pumpAndSettle`.

  4. 4

    Attese robuste: pumpUntilFound invece di delay fissi

    await tester.pump(const Duration(seconds: 2)) è una scommessa. Meglio una helper che pompa frame finché una condizione non è vera, con timeout esplicito e messaggio d'errore utile.

    Aggiungiamo anche un piccolo robot per leggibilità, senza esagerare con l'astrazione.

    // integration_test/support/pump.dart
    extension PumpX on WidgetTester {
      Future<void> pumpUntilFound(
        Finder finder, {
        Duration timeout = const Duration(seconds: 10),
        Duration step = const Duration(milliseconds: 50),
      }) async {
        final deadline = DateTime.now().add(timeout);
        while (DateTime.now().isBefore(deadline)) {
          await pump(step);
          if (finder.evaluate().isNotEmpty) return;
        }
        throw TestFailure('Widget non trovato entro $timeout: $finder');
      }
    }
    
    class LoginRobot {
      LoginRobot(this.tester);
      final WidgetTester tester;
    
      Future<void> login(String email, String password) async {
        await tester.enterText(find.byKey(const Key('email')), email);
        await tester.enterText(find.byKey(const Key('password')), password);
        await tester.tap(find.byKey(const Key('submit')));
        await tester.pump();
      }
    }

    Risultato atteso

    Le attese si adattano alla velocità reale del device ed emettono errori diagnostici chiari.

  5. 5

    Scrivere lo scenario end-to-end completo

    Mettiamo tutto insieme: avvio del backend, configurazione dell'app, percorso login → lista → dettaglio, e verifica delle chiamate HTTP effettivamente ricevute.

    Nota il tearDown che chiude il server: senza di esso le porte restano occupate tra i test.

    // integration_test/login_flow_test.dart
    void main() {
      IntegrationTestWidgetsFlutterBinding.ensureInitialized();
    
      late FakeBackend backend;
      late TestClock clock;
    
      setUp(() async {
        backend = FakeBackend();
        clock = TestClock();
        await backend.start();
        backend.routes['POST /login'] = (_) => {'token': 'abc123'};
        backend.routes['GET /orders'] = (_) => [
              {'id': 1, 'title': 'Ordine A', 'createdAt': '2024-03-15T08:00:00Z'},
            ];
      });
    
      tearDown(() => backend.stop());
    
      testWidgets('login e apertura dettaglio ordine', (tester) async {
        await tester.pumpWidget(buildApp(AppConfig(
          baseUrl: backend.baseUrl,
          now: clock.call,
          animationScale: Duration.zero,
        )));
        await tester.pumpAndSettle();
    
        await LoginRobot(tester).login('mario@test.it', 'segreta');
        await tester.pumpUntilFound(find.text('Ordine A'));
    
        expect(find.text('2 ore fa'), findsOneWidget);
    
        await tester.tap(find.text('Ordine A'));
        await tester.pumpUntilFound(find.byKey(const Key('order-detail')));
    
        expect(backend.calls, containsAllInOrder(['POST /login', 'GET /orders']));
      });
    }

    Risultato atteso

    Il test passa in modo ripetibile su emulatore e device, senza dipendere dal backend reale né dall'orario.

  6. 6

    Eseguire la suite in CI senza flakiness residua

    Ultimo passo: integrazione in CI. Alcune regole che fanno la differenza:

    • Esegui con flutter test integration_test --dart-define=CI=true.
    • Niente retry automatici mascheranti: un test che fallisce a intermittenza va corretto, non ripetuto.
    • Disabilita le animazioni di sistema sull'emulatore Android.
    • Salva screenshot sui fallimenti con binding.takeScreenshot.

    Aggiungi anche un controllo: se il FakeBackend riceve una rotta non registrata (404), il test deve fallire esplicitamente, così nessuna chiamata di rete sfugge al controllo.

    # .github/workflows/e2e.yml (estratto)
    - name: Avvia emulatore
      uses: reactivecircus/android-emulator-runner@v2
      with:
        api-level: 34
        disable-animations: true
        script: flutter test integration_test --reporter expanded

    Risultato atteso

    Suite E2E verde e stabile in CI, con fallimenti sempre riconducibili a regressioni reali.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!