Golden test e regressione visiva in Flutter: pixel-perfect in CI

Foto di Kofi Abban Davis su Unsplash

Best practiceAvanzato45 min Flutter 3.x

Golden test e regressione visiva in Flutter: pixel-perfect in CI

I widget test verificano la logica della UI, ma non il suo aspetto. I golden test (o snapshot test) rendono un widget in un'immagine PNG e la confrontano con un file di riferimento versionato nel repository: qualsiasi variazione di spaziatura, colore o tipografia fa fallire la build.

In questo tutorial avanzato costruiremo un'infrastruttura di regressione visiva robusta:

  • caricamento dei font reali per evitare i rettangoli neri di Ahem;
  • un helper per rendere lo stesso widget su più dimensioni di schermo e temi;
  • gestione della tolleranza di confronto per le differenze di rendering tra macchine;
  • esecuzione in CI con artefatti di diff scaricabili.

Si presuppone familiarità con flutter_test, pumpWidget e la struttura di un progetto Flutter modulare.

  1. 1

    Preparare il progetto e un widget da testare

    I golden test girano con flutter_test, già presente. Isoliamo il componente da fotografare in un widget puro, senza dipendenze da rete o plugin: è la regola d'oro della regressione visiva.

    Crea lib/ui/price_card.dart. Nota l'uso di Theme.of(context): testeremo lo stesso widget in light e dark mode.

    import 'package:flutter/material.dart';
    
    class PriceCard extends StatelessWidget {
      const PriceCard({super.key, required this.title, required this.price, this.badge});
    
      final String title;
      final double price;
      final String? badge;
    
      @override
      Widget build(BuildContext context) {
        final scheme = Theme.of(context).colorScheme;
        return Card(
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              mainAxisSize: MainAxisSize.min,
              children: [
                if (badge != null)
                  Container(
                    padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
                    decoration: BoxDecoration(
                      color: scheme.primaryContainer,
                      borderRadius: BorderRadius.circular(999),
                    ),
                    child: Text(badge!, style: Theme.of(context).textTheme.labelSmall),
                  ),
                const SizedBox(height: 8),
                Text(title, style: Theme.of(context).textTheme.titleMedium),
                Text('€ ${price.toStringAsFixed(2)}',
                    style: Theme.of(context).textTheme.headlineSmall),
              ],
            ),
          ),
        );
      }
    }

    Risultato atteso

    Un widget deterministico, senza stato asincrono, pronto per essere renderizzato in test.

  2. 2

    Caricare i font reali con flutter_test_config.dart

    Per default i test usano il font Ahem, che disegna blocchi neri. La soluzione è caricare i font dal bundle degli asset di test tramite un file test/flutter_test_config.dart, eseguito automaticamente prima di tutti i test.

    Aggiungi al pubspec.yaml il font che usi in app (es. assets/fonts/Inter-Regular.ttf) e poi registralo nel FontLoader. Nello stesso file impostiamo anche la tolleranza globale di confronto.

    // test/flutter_test_config.dart
    import 'dart:async';
    import 'dart:io';
    import 'package:flutter/services.dart';
    import 'package:flutter_test/flutter_test.dart';
    
    Future<void> testExecutable(FutureOr<void> Function() testMain) async {
      TestWidgetsFlutterBinding.ensureInitialized();
      await _loadFont('Inter', ['assets/fonts/Inter-Regular.ttf', 'assets/fonts/Inter-Bold.ttf']);
    
      // Tolleranza: differenze minime di anti-aliasing tra macchine non fanno fallire.
      goldenFileComparator = TolerantGoldenComparator(
        Uri.parse('${Directory.current.path}/test/'),
        tolerance: 0.005, // 0.5% di pixel diversi
      );
    
      await testMain();
    }
    
    Future<void> _loadFont(String family, List<String> paths) async {
      final loader = FontLoader(family);
      for (final p in paths) {
        loader.addFont(File(p).readAsBytes().then((b) => ByteData.view(b.buffer)));
      }
      await loader.load();
    }

    Risultato atteso

    Il testo nei golden viene disegnato con il font reale dell'app invece dei blocchi Ahem.

  3. 3

    Implementare un comparatore con tolleranza

    LocalFileComparator fallisce a fronte di un solo pixel diverso: troppo fragile quando i golden sono generati su macOS e verificati su Linux in CI. Estendiamolo per accettare una percentuale di differenza, continuando a scrivere il file di diff in failures/.

    // test/tolerant_golden_comparator.dart
    import 'dart:typed_data';
    import 'package:flutter_test/flutter_test.dart';
    
    class TolerantGoldenComparator extends LocalFileComparator {
      TolerantGoldenComparator(Uri testFile, {this.tolerance = 0.005})
          : super(testFile);
    
      final double tolerance;
    
      @override
      Future<bool> compare(Uint8List imageBytes, Uri golden) async {
        final result = await GoldenFileComparator.compareLists(
          imageBytes,
          await getGoldenBytes(golden),
        );
        if (result.passed || result.diffPercent <= tolerance) return true;
    
        final error = await generateFailureOutput(result, golden, basedir);
        throw FlutterError(error);
      }
    }

    Risultato atteso

    Le micro-differenze di rendering vengono ignorate, mentre una regressione reale produce un PNG di diff.

  4. 4

    Helper multi-device e multi-tema

    Un buon golden test copre più viewport e più temi senza duplicare codice. Creiamo un helper che imposta physicalSize, devicePixelRatio e il tema, e genera un nome file parlante.

    matchesGoldenFile accetta un path relativo alla cartella del test: organizziamo i file in test/goldens/.

    // test/golden_helper.dart
    import 'package:flutter/material.dart';
    import 'package:flutter_test/flutter_test.dart';
    
    class Device {
      const Device(this.name, this.size, [this.dpr = 1.0]);
      final String name;
      final Size size;
      final double dpr;
    
      static const phone = Device('phone', Size(390, 844));
      static const tablet = Device('tablet', Size(834, 1112));
    }
    
    Future<void> goldenMatrix(
      WidgetTester tester, {
      required String name,
      required Widget child,
      List<Device> devices = const [Device.phone, Device.tablet],
      List<ThemeMode> modes = const [ThemeMode.light, ThemeMode.dark],
    }) async {
      for (final device in devices) {
        for (final mode in modes) {
          tester.view
            ..physicalSize = device.size * device.dpr
            ..devicePixelRatio = device.dpr;
          addTearDown(tester.view.reset);
    
          await tester.pumpWidget(MaterialApp(
            debugShowCheckedModeBanner: false,
            theme: ThemeData(useMaterial3: true, fontFamily: 'Inter'),
            darkTheme: ThemeData.dark(useMaterial3: true).copyWith(),
            themeMode: mode,
            home: Scaffold(body: Center(child: Padding(padding: const EdgeInsets.all(24), child: child))),
          ));
          await tester.pumpAndSettle();
    
          final suffix = mode == ThemeMode.dark ? 'dark' : 'light';
          await expectLater(
            find.byType(MaterialApp),
            matchesGoldenFile('goldens/${name}_${device.name}_$suffix.png'),
          );
        }
      }
    }

    Risultato atteso

    Una sola chiamata produce 4 golden (2 device × 2 temi) con nomi coerenti.

  5. 5

    Scrivere il test e generare i golden

    Ora il test è dichiarativo. Usa --update-goldens per generare i riferimenti la prima volta e committali nel repository: sono la fonte di verità.

    flutter test --update-goldens test/price_card_golden_test.dart
    flutter test   # verifica
    

    Se modifichi il padding della card e rilanci senza --update-goldens, il test fallisce e in test/failures/ trovi *_masterImage.png, *_testImage.png, *_isolatedDiff.png.

    // test/price_card_golden_test.dart
    import 'package:flutter_test/flutter_test.dart';
    import 'package:myapp/ui/price_card.dart';
    import 'golden_helper.dart';
    
    void main() {
      testWidgets('PriceCard - stato base', (tester) async {
        await goldenMatrix(
          tester,
          name: 'price_card_base',
          child: const PriceCard(title: 'Piano Pro', price: 19.9),
        );
      });
    
      testWidgets('PriceCard - con badge e testo lungo', (tester) async {
        await goldenMatrix(
          tester,
          name: 'price_card_badge',
          child: const PriceCard(
            title: 'Piano Business annuale con fatturazione elettronica',
            price: 199,
            badge: 'PIÙ SCELTO',
          ),
        );
      });
    }

    Risultato atteso

    8 file PNG in test/goldens/ e un fallimento immediato al minimo cambio visivo non intenzionale.

  6. 6

    Eseguire i golden test in CI e pubblicare i diff

    I golden vanno verificati sempre sullo stesso sistema operativo con cui sono stati generati: fissa Linux in CI e genera i riferimenti con lo stesso ambiente (anche tramite un container). Carica la cartella failures/ come artefatto per ispezionare le differenze dalla pull request.

    Consiglio operativo: escludi i golden dal flutter test generico dei PR di sola logica usando un tag, e aggiungi in dart_test.yaml o via --tags per separare le suite.

    # .github/workflows/goldens.yml
    name: Golden tests
    on: [pull_request]
    
    jobs:
      goldens:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: subosito/flutter-action@v2
            with:
              flutter-version: '3.24.0'
              channel: stable
          - run: flutter pub get
          - run: flutter test --reporter expanded
          - name: Upload diff
            if: failure()
            uses: actions/upload-artifact@v4
            with:
              name: golden-failures
              path: '**/test/failures/**'

    Risultato atteso

    La pipeline blocca le regressioni visive e allega le immagini di diff scaricabili dalla PR.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!