Il limite dei test di integrazione "standard"

Il pacchetto integration_test incluso nell'SDK Flutter è ottimo per verificare flussi completi dell'app: si avvia il widget tree reale, si simulano tap e digitazioni, si controlla il risultato. Il problema arriva nel momento in cui il flusso esce dal framework:

  • il dialog di sistema che chiede il permesso per fotocamera, posizione o notifiche;
  • la notifica push che l'utente deve toccare nella shade di Android;
  • il selettore di file o la galleria;
  • il login con WebView di un provider OAuth;
  • lo spegnimento del Wi-Fi per testare la modalità offline.

Tutto questo è UI nativa, renderizzata fuori dal FlutterView: i finder di flutter_test non la vedono nemmeno. Il risultato classico è un test che si blocca su un permesso o, peggio, un flusso critico che resta non coperto.

Patrol, sviluppato da LeanCode, risolve esattamente questo: estende integration_test con un layer di automazione nativa (UIAutomator su Android, XCUITest su iOS) e aggiunge una sintassi di finder molto più compatta.

Cosa offre Patrol

Patrol si compone di tre pezzi:

  1. patrol: il pacchetto Dart da usare nei test, con l'oggetto $ (un PatrolIntegrationTester) e l'API $.native.
  2. patrol_finders: la libreria di finder, utilizzabile anche da sola nei normali widget test.
  3. patrol_cli: la CLI che costruisce ed esegue i test sui dispositivi, con tanto di hot restart durante lo sviluppo.

Installazione

Aggiungi la dipendenza di sviluppo e la sezione di configurazione nel pubspec.yaml:

dev_dependencies:
  patrol: ^3.15.0

patrol:
  app_name: La Mia App
  android:
    package_name: com.example.myapp
  ios:
    bundle_id: com.example.myapp

Poi installa la CLI e verifica l'ambiente:

dart pub global activate patrol_cli
patrol doctor

Setup nativo (una tantum)

Su Android serve il runner di Patrol in android/app/build.gradle:

android {
  defaultConfig {
    testInstrumentationRunner "pl.leancode.patrol.PatrolJUnitRunner"
    testInstrumentationRunnerArguments clearPackageData: "true"
  }
  testOptions {
    execution "ANDROIDX_TEST_ORCHESTRATOR"
  }
}

dependencies {
  androidTestUtil "androidx.test:orchestrator:1.5.1"
}

e una classe di test in android/app/src/androidTest/java/<package>/MainActivityTest.java che registra i test Dart nel runner JUnit (il boilerplate è riportato nella documentazione ufficiale e non va modificato).

Su iOS occorre aggiungere un target UI Testing Bundle chiamato RunnerUITests con il file RunnerUITests.swift fornito da Patrol, e dichiarare il pod patrol nel Podfile. È la parte più noiosa, ma si fa una volta sola per progetto.

Il primo test

I file vanno in integration_test/. Al posto di testWidgets si usa patrolTest, e al posto di tester si riceve $:

import 'package:flutter/material.dart';
import 'package:patrol/patrol.dart';
import 'package:myapp/main.dart' as app;

void main() {
  patrolTest('login con credenziali valide', ($) async {
    await $.pumpWidgetAndSettle(const app.MyApp());

    await $(#emailField).enterText('mario@example.com');
    await $(#passwordField).enterText('super-segreta');
    await $(FilledButton).tap();

    await $('Benvenuto, Mario').waitUntilVisible();
  });
}

Due dettagli importanti:

  • $(...) è una funzione "universale": accetta una Key, una stringa (match parziale sul testo), un Type di widget, un IconData o un Finder di flutter_test.
  • ogni azione (tap, enterText) esegue automaticamente un pumpAndSettle e attende che il widget sia visibile prima di agire. Addio a sequenze infinite di await tester.pumpAndSettle().

Finder espressivi

La vera comodità arriva nella composizione:

// Il bottone dentro la Card che contiene il testo 'Abbonamento Pro'
await $(Card).containing('Abbonamento Pro').$(ElevatedButton).tap();

// Il terzo elemento della lista
await $(ListTile).at(2).tap();

// Scroll automatico fino a trovare l'elemento
await $('Impostazioni avanzate').scrollTo().tap();

// Predicati tipizzati
expect($(Text).which<Text>((w) => w.data!.startsWith('Totale')), findsOneWidget);

// Verifiche
expect($('Errore di rete').exists, isFalse);
expect($(#avatar).visible, isTrue);

// Attese con timeout personalizzato
await $('Pagamento completato')
    .waitUntilVisible(timeout: const Duration(seconds: 20));

exists indica che il widget è nell'albero, visible che è effettivamente renderizzato sullo schermo: una distinzione che evita falsi positivi con Offstage o liste virtualizzate.

Automatizzare la UI nativa

Qui Patrol fa la differenza. L'oggetto $.native espone comandi eseguiti dal driver nativo:

patrolTest('scatta una foto concedendo il permesso', ($) async {
  await $.pumpWidgetAndSettle(const app.MyApp());

  await $(#openCamera).tap();

  if (await $.native.isPermissionDialogVisible()) {
    await $.native.grantPermissionWhenInUse();
  }

  await $('Anteprima').waitUntilVisible();
});

Altri comandi utili:

// Permessi
await $.native.grantPermissionOnlyThisTime();
await $.native.denyPermission();
await $.native.selectFineLocation();

// Notifiche
await $.native.openNotifications();
await $.native.tapOnNotificationBySubtitle('Nuovo messaggio');

// Ciclo di vita e sistema
await $.native.pressHome();
await $.native.openApp();
await $.native.pressBack();
await $.native.disableWifi();
await $.native.enableWifi();

// Interazione generica con selettori nativi
await $.native.tap(Selector(text: 'Consenti'));
await $.native.enterTextByIndex('mario@example.com', index: 0);

Un caso tipico: verificare che l'app gestisca correttamente l'assenza di rete.

patrolTest('mostra la banner offline quando manca la rete', ($) async {
  await $.pumpWidgetAndSettle(const app.MyApp());

  await $.native.disableWifi();
  await $.native.disableCellular();

  await $(#refreshButton).tap();
  await $('Sei offline').waitUntilVisible();

  await $.native.enableWifi();
  await $.native.enableCellular();
});

Configurazione globale e setup condiviso

patrolSetUp e patrolTearDown permettono di eseguire codice asincrono (anche nativo) prima e dopo ogni test, mentre NativeAutomatorConfig consente di impostare timeout e nomi dei pulsanti di sistema per lingue diverse dall'inglese:

void main() {
  final config = NativeAutomatorConfig(
    findTimeout: const Duration(seconds: 20),
    packageName: 'com.example.myapp',
    bundleId: 'com.example.myapp',
  );

  patrolSetUp(() async {
    await SharedPreferences.getInstance().then((p) => p.clear());
  });

  patrolTest('flusso onboarding', nativeAutomatorConfig: config, ($) async {
    // ...
  });
}

Eseguire i test

Con un emulatore o un dispositivo collegato:

# Esegue tutti i test in integration_test/
patrol test

# Un singolo file
patrol test --target integration_test/login_test.dart

# Modalità sviluppo con hot restart del test (premi 'r')
patrol develop --target integration_test/login_test.dart

patrol develop è la funzionalità che cambia davvero la produttività: invece di ricostruire l'APK a ogni modifica del test (operazione da minuti), si riavvia il test in pochi secondi.

Patrol in CI

In pipeline conviene separare build ed esecuzione, così da poter usare servizi come Firebase Test Lab o device farm commerciali:

patrol build android --target integration_test/login_test.dart --verbose

gcloud firebase test android run \
  --type instrumentation \
  --app build/app/outputs/apk/debug/app-debug.apk \
  --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
  --device model=MediumPhone.arm,version=33 \
  --use-orchestrator \
  --timeout 10m

Per iOS la logica è analoga con patrol build ios e l'esecuzione dello .xctestrun prodotto. Essendo test reali su dispositivo, conviene tenerli in un job separato dalla pipeline di unit/widget test, che deve restare veloce.

Solo i finder, senza automazione nativa

Se la sintassi $ ti piace ma non ti serve il layer nativo, puoi usare patrol_finders nei normali widget test, senza alcun setup di progetto:

import 'package:patrol_finders/patrol_finders.dart';

void main() {
  patrolWidgetTest('il contatore incrementa', ($) async {
    await $.pumpWidgetAndSettle(const MyApp());
    await $(FloatingActionButton).tap();
    expect($('1'), findsOneWidget);
  });
}

Funziona ovunque giri flutter test, web e desktop compresi, ed è un ottimo modo per adottare Patrol in modo graduale.

Best practice e avvertenze

  • Usa le Key: $(#loginButton) è infinitamente più stabile di $('Accedi'), che si rompe al primo cambio di copy o con l'app tradotta.
  • Non trasformare Patrol nella tua suite principale: un test di integrazione costa decine di secondi, un widget test millisecondi. Copri con Patrol i 5-10 flussi critici (onboarding, login, acquisto, permessi), il resto a livelli inferiori.
  • Attenzione alla lingua del dispositivo: i pulsanti dei dialog di sistema cambiano testo. Fissa la locale dell'emulatore in CI o usa i metodi dedicati (grantPermissionWhenInUse) invece di Selector(text: ...).
  • Isola lo stato: clearPackageData: "true" su Android e un patrolSetUp che resetta preferenze e database evitano test dipendenti dall'ordine di esecuzione.
  • Mock del backend: puntare a un ambiente di staging reale rende i test fragili. Un server locale o un'istanza dedicata con dati seminati è la scelta più solida.
  • L'automazione nativa copre Android e iOS. Per web e desktop restano i finder più integration_test classico.

Conclusioni

Patrol non sostituisce integration_test: lo potenzia. Con un setup iniziale di mezz'ora ottieni finder molto più leggibili, attese implicite che eliminano gran parte della flakiness e, soprattutto, la possibilità di testare i punti in cui la tua app incontra il sistema operativo — proprio quelli dove gli utenti incontrano più bug. Se hai flussi che oggi verifichi a mano prima di ogni release, è lì che conviene iniziare.