Testare i widget in Flutter con widget test e flutter_test
Best practiceIntermedio35 min Flutter 3.x

Testare i widget in Flutter con widget test e flutter_test

Perché testare i widget?

Un'app Flutter cresce rapidamente e con essa il rischio di introdurre regressioni. I widget test offrono un compromesso ideale tra velocità e affidabilità: sono più veloci dei test di integrazione ma verificano davvero l'interfaccia, a differenza degli unit test puri.

In questo tutorial imparerai a usare il pacchetto flutter_test (incluso di default in ogni progetto) per:

  • montare un widget in un ambiente di test con pumpWidget
  • trovare elementi nella UI con i Finder
  • verificare lo stato con i Matcher
  • simulare tap, scroll e inserimento di testo
  • gestire animazioni e stati asincroni

Al termine avrai una suite di test pronta da eseguire con flutter test.

  1. 1

    Preparare il progetto e il widget da testare

    Il pacchetto flutter_test è già presente in ogni progetto Flutter sotto dev_dependencies. Verifica il pubspec.yaml:

    dev_dependencies:
      flutter_test:
        sdk: flutter
    

    Creiamo un semplice widget Counter che testeremo. Salvalo in lib/counter.dart. È un classico contatore con un titolo, un valore e un pulsante di incremento.

    import 'package:flutter/material.dart';
    
    class Counter extends StatefulWidget {
      const Counter({super.key});
    
      @override
      State<Counter> createState() => _CounterState();
    }
    
    class _CounterState extends State<Counter> {
      int _value = 0;
    
      void _increment() => setState(() => _value++);
    
      @override
      Widget build(BuildContext context) {
        return Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('Contatore'),
            Text('$_value', key: const Key('value')),
            ElevatedButton(
              onPressed: _increment,
              child: const Text('Incrementa'),
            ),
          ],
        );
      }
    }

    Risultato atteso

    Il progetto contiene un widget Counter riutilizzabile pronto per essere testato.

  2. 2

    Scrivere il primo widget test con testWidgets e pumpWidget

    I file di test vanno nella cartella test/ e terminano con _test.dart. Usiamo testWidgets che fornisce un WidgetTester, l'oggetto con cui pilotiamo il test.

    Il passaggio chiave è pumpWidget: monta il widget nell'albero di test. Poiché quasi ogni widget richiede un contesto Material (tema, direzionalità del testo, ecc.), avvolgiamo Counter in un MaterialApp con Scaffold.

    Crea test/counter_test.dart.

    import 'package:flutter/material.dart';
    import 'package:flutter_test/flutter_test.dart';
    import 'package:mia_app/counter.dart';
    
    void main() {
      testWidgets('Il contatore parte da 0', (WidgetTester tester) async {
        await tester.pumpWidget(
          const MaterialApp(
            home: Scaffold(body: Counter()),
          ),
        );
    
        // Il valore iniziale deve essere 0
        expect(find.text('0'), findsOneWidget);
        expect(find.text('1'), findsNothing);
      });
    }

    Risultato atteso

    Eseguendo `flutter test` il test passa, confermando che il valore iniziale è 0.

  3. 3

    Trovare i widget con i Finder

    I Finder individuano widget nell'albero. I più usati sono:

    • find.text('...') — cerca per testo visualizzato
    • find.byKey(const Key('...')) — cerca per Key (il modo più robusto)
    • find.byType(ElevatedButton) — cerca per tipo di widget
    • find.byIcon(Icons.add) — cerca per icona
    • find.widgetWithText(ElevatedButton, 'Incrementa') — combinazione tipo + testo

    Usare una Key è la scelta migliore quando più widget mostrano lo stesso testo. Nel nostro Counter abbiamo assegnato Key('value') al testo del valore.

    testWidgets('I finder individuano gli elementi giusti', (tester) async {
      await tester.pumpWidget(
        const MaterialApp(home: Scaffold(body: Counter())),
      );
    
      // Per Key
      expect(find.byKey(const Key('value')), findsOneWidget);
    
      // Per tipo
      expect(find.byType(ElevatedButton), findsOneWidget);
    
      // Per tipo + testo
      expect(
        find.widgetWithText(ElevatedButton, 'Incrementa'),
        findsOneWidget,
      );
    });

    Risultato atteso

    Tutti i finder localizzano correttamente gli elementi previsti nell'interfaccia.

  4. 4

    Verificare lo stato con i Matcher

    I Matcher definiscono cosa ci aspettiamo da un Finder. I più comuni:

    • findsOneWidget — esattamente un widget corrispondente
    • findsNothing — nessun widget
    • findsNWidgets(n) — esattamente n widget
    • findsWidgets — almeno uno

    Puoi anche estrarre il widget con tester.widget<T>(finder) per verificarne le proprietà interne, utile per controllare valori non visibili come onPressed.

    testWidgets('Il pulsante ha una callback attiva', (tester) async {
      await tester.pumpWidget(
        const MaterialApp(home: Scaffold(body: Counter())),
      );
    
      final button = tester.widget<ElevatedButton>(
        find.byType(ElevatedButton),
      );
    
      // La callback non deve essere null (pulsante abilitato)
      expect(button.onPressed, isNotNull);
    });

    Risultato atteso

    Il test conferma che il pulsante è abilitato perché onPressed non è null.

  5. 5

    Simulare le interazioni utente e ricostruire con pump

    Per simulare un tap usiamo tester.tap(finder). Attenzione: dopo un'interazione lo stato cambia ma l'albero NON viene ricostruito automaticamente. Devi chiamare await tester.pump() per innescare un nuovo frame e riflettere le modifiche.

    Altre azioni utili:

    • tester.enterText(finder, 'testo') — inserisce testo in un campo
    • tester.drag(finder, offset) — trascina
    • tester.longPress(finder) — pressione prolungata

    Testiamo l'incremento del contatore.

    testWidgets('Il tap incrementa il contatore', (tester) async {
      await tester.pumpWidget(
        const MaterialApp(home: Scaffold(body: Counter())),
      );
    
      expect(find.text('0'), findsOneWidget);
    
      // Simula il tap sul pulsante
      await tester.tap(find.byType(ElevatedButton));
      // Ricostruisce l'albero per riflettere lo stato aggiornato
      await tester.pump();
    
      expect(find.text('0'), findsNothing);
      expect(find.text('1'), findsOneWidget);
    
      // Un secondo tap porta a 2
      await tester.tap(find.byType(ElevatedButton));
      await tester.pump();
      expect(find.text('2'), findsOneWidget);
    });

    Risultato atteso

    Dopo ogni tap il valore mostrato aumenta correttamente: 0 → 1 → 2.

  6. 6

    Gestire animazioni e stati asincroni con pumpAndSettle

    Quando la UI contiene animazioni (es. transizioni, SnackBar, showDialog) un singolo pump() avanza di un solo frame e potrebbe non bastare. In questi casi usa await tester.pumpAndSettle(), che continua a generare frame finché non ci sono più animazioni in corso.

    Supponiamo che il tap mostri una SnackBar. Ecco come testarla. Nota che pumpAndSettle aspetta il completamento dell'animazione di comparsa della SnackBar.

    Suggerimento: non usare pumpAndSettle con animazioni infinite (es. spinner di caricamento), altrimenti il test resterà bloccato in timeout. In quei casi usa pump(Duration(...)).

    testWidgets('Mostra una SnackBar al tap', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            body: Builder(
              builder: (context) => ElevatedButton(
                onPressed: () {
                  ScaffoldMessenger.of(context).showSnackBar(
                    const SnackBar(content: Text('Fatto!')),
                  );
                },
                child: const Text('Mostra'),
              ),
            ),
          ),
        ),
      );
    
      await tester.tap(find.text('Mostra'));
      await tester.pumpAndSettle(); // attende l'animazione della SnackBar
    
      expect(find.text('Fatto!'), findsOneWidget);
    });

    Risultato atteso

    Dopo il tap la SnackBar appare e il test trova il testo 'Fatto!'.

  7. 7

    Organizzare i test con group e setUp ed eseguirli

    Per suite più grandi conviene raggruppare i test correlati con group e condividere la preparazione con setUp. Questo rende i test più leggibili e riduce la duplicazione.

    Per eseguire tutti i test:

    flutter test
    

    Per un singolo file:

    flutter test test/counter_test.dart
    

    Per generare un report di copertura:

    flutter test --coverage
    

    Il file coverage/lcov.info può essere visualizzato con strumenti come genhtml o estensioni dell'IDE. Ecco la struttura consigliata per i test del contatore.

    void main() {
      group('Counter', () {
        // Helper per montare il widget in ogni test
        Future<void> montaCounter(WidgetTester tester) {
          return tester.pumpWidget(
            const MaterialApp(home: Scaffold(body: Counter())),
          );
        }
    
        testWidgets('parte da 0', (tester) async {
          await montaCounter(tester);
          expect(find.text('0'), findsOneWidget);
        });
    
        testWidgets('incrementa al tap', (tester) async {
          await montaCounter(tester);
          await tester.tap(find.byType(ElevatedButton));
          await tester.pump();
          expect(find.text('1'), findsOneWidget);
        });
      });
    }

    Risultato atteso

    La suite è organizzata in gruppi leggibili e tutti i test passano eseguendo `flutter test`.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!