Perché i golden test
Unit test e widget test verificano la logica e la presenza dei widget nell'albero, ma non dicono nulla su come appare davvero la UI. Un padding cambiato per sbaglio, un ThemeExtension modificato, un aggiornamento di Flutter che altera il rendering del testo: sono tutte regressioni che passano indenni attraverso una suite di test tradizionale.
I golden test (o snapshot test) risolvono esattamente questo problema: renderizzano un widget, ne catturano un'immagine PNG e la confrontano byte per byte con un file di riferimento (il golden) versionato nel repository. Se l'immagine cambia, il test fallisce e la differenza viene salvata su disco per l'ispezione visiva.
Sono particolarmente utili per:
- design system e librerie di componenti condivisi;
- schermate con layout complessi e stati multipli (loading, errore, lista vuota);
- verifica di temi chiaro/scuro e di più lingue;
- controllo delle rotture dopo un upgrade di Flutter o di un package UI.
Il primo golden test
Non serve alcuna dipendenza esterna: il matcher matchesGoldenFile fa parte di flutter_test.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/ui/price_badge.dart';
void main() {
testWidgets('PriceBadge - stato scontato', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(useMaterial3: true),
home: const Scaffold(
body: Center(
child: PriceBadge(price: 49.90, discount: 0.30),
),
),
),
);
await expectLater(
find.byType(PriceBadge),
matchesGoldenFile('goldens/price_badge_discount.png'),
);
});
}
Al primo avvio il file non esiste e il test fallisce. Si generano (o rigenerano) i golden con:
flutter test --update-goldens
Il PNG prodotto va committato insieme al codice: è a tutti gli effetti parte della definizione del comportamento atteso. Nelle pull request diventa anche un ottimo strumento di review, perché il diff mostra visivamente come cambia la UI.
Suggerimento: passa a
matchesGoldenFileunFinderspecifico (non l'interoMaterialApp) per ottenere immagini piccole, mirate e meno fragili.
Caricare i font reali
Problema classico: il golden generato mostra il testo come una serie di rettangoli neri. È il comportamento voluto di flutter test, che sostituisce i font non caricati con il font di test Ahem. Per avere screenshot realistici bisogna caricare i font dichiarati nel pubspec.yaml prima dell'esecuzione dei test.
Il punto giusto in cui farlo è il file speciale test/flutter_test_config.dart, che Flutter esegue automaticamente prima di ogni file di test della cartella.
// test/flutter_test_config.dart
import 'dart:async';
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
TestWidgetsFlutterBinding.ensureInitialized();
await _loadAppFonts();
await testMain();
}
Future<void> _loadAppFonts() async {
final manifest = await rootBundle.loadStructuredData<List<dynamic>>(
'FontManifest.json',
(data) async => json.decode(data) as List<dynamic>,
);
for (final family in manifest) {
final loader = FontLoader(family['family'] as String);
for (final font in family['fonts'] as List<dynamic>) {
loader.addFont(rootBundle.load(font['asset'] as String));
}
await loader.load();
}
}
Attenzione: FontManifest.json contiene solo i font dichiarati come asset. Se usi google_fonts, in ambiente di test il package tenta il download via rete (bloccato) e ricade sul font di sistema: la soluzione robusta è includere i file .ttf tra gli asset dell'app e disabilitare il fetch runtime.
Testare più dimensioni di schermo
Un componente può rompersi solo su schermi stretti o con testo ingrandito. Con tester.view puoi controllare dimensioni e densità di pixel, ricordandoti sempre di ripristinare lo stato.
Future<void> pumpAt(
WidgetTester tester,
Widget child, {
Size size = const Size(390, 844),
double textScale = 1.0,
}) async {
tester.view.physicalSize = size * tester.view.devicePixelRatio;
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MediaQuery(
data: MediaQueryData(textScaler: TextScaler.linear(textScale)),
child: MaterialApp(home: child),
),
);
}
E poi si generano più varianti in modo tabellare:
void main() {
const devices = <String, Size>{
'phone': Size(390, 844),
'tablet': Size(834, 1112),
};
for (final entry in devices.entries) {
testWidgets('ProductCard - ${entry.key}', (tester) async {
await pumpAt(tester, const ProductCard(), size: entry.value);
await expectLater(
find.byType(ProductCard),
matchesGoldenFile('goldens/product_card_${entry.key}.png'),
);
});
}
}
Lo stesso pattern funziona benissimo per il ciclo su Brightness.light / Brightness.dark e su textScale 1.0 / 1.5 (test di accessibilità visiva).
Attese, immagini e stati asincroni
Un golden catturato mentre è in corso un'animazione è la ricetta perfetta per un test flaky. Alcune regole:
- usa
await tester.pumpAndSettle()per far concludere le animazioni, oppureawait tester.pump(const Duration(milliseconds: 300))se vuoi fotografare un frame preciso; - non caricare immagini di rete: sostituiscile con un
NetworkImagefittizio o inietta unImageProviderdi test (MemoryImagecon un PNG minimale); - rendi deterministico tutto ciò che dipende dal tempo o dal caso: date, id generati, shuffle di liste.
Tolleranza: convivere con differenze di un pixel
Il confronto di default è esatto: basta un pixel diverso per far fallire il test. Su motori di rendering leggermente diversi (o dopo un aggiornamento del framework) questo diventa ingestibile. Si può registrare un comparator con una soglia di tolleranza.
// test/flutter_test_config.dart (estratto)
class TolerantComparator extends LocalFileComparator {
TolerantComparator(super.testFile, {this.threshold = 0.005});
final double threshold;
@override
Future<bool> compare(Uint8List imageBytes, Uri golden) async {
final result = await GoldenFileComparator.compareLists(
imageBytes,
await getGoldenBytes(golden),
);
if (result.passed || result.diffPercent <= threshold) return true;
final error = await generateFailureOutput(result, golden, basedir);
throw FlutterError(error);
}
}
void _installComparator() {
final current = goldenFileComparator as LocalFileComparator;
goldenFileComparator = TolerantComparator(
Uri.parse('${current.basedir}test.dart'),
threshold: 0.005, // 0,5% di pixel differenti
);
}
Usa soglie basse (0,1–0,5%): una tolleranza troppo generosa rende i test inutili.
Golden test in CI
Il rendering di Flutter non è identico su tutte le piattaforme: font di sistema, antialiasing e sottosistemi grafici producono immagini leggermente diverse tra macOS, Windows e Linux. Se generi i golden sul Mac di uno sviluppatore e li verifichi su un runner Linux, avrai fallimenti sistematici.
Strategie consolidate:
- Una sola piattaforma di riferimento. Genera e verifica i golden sempre nello stesso ambiente (tipicamente un container Linux con versione di Flutter fissata).
- Taggare i test. Metti
@Tags(['golden'])in cima ai file di golden test e neldart_test.yamlescludili dalla run standard, eseguendoli in un job dedicato conflutter test --tags golden. - Pubblicare i diff. In caso di fallimento Flutter scrive le immagini in
test/failures/: caricale come artifact della pipeline per poterle ispezionare.
Esempio di step in GitHub Actions:
- name: Golden tests
run: flutter test --tags golden
- name: Upload golden failures
if: failure()
uses: actions/upload-artifact@v4
with:
name: golden-failures
path: '**/test/failures/**'
Package di supporto
Storicamente il riferimento era golden_toolkit, oggi non più mantenuto attivamente. Le alternative attuali sono:
- alchemist: pensato per la CI, distingue tra golden "CI" (senza font di sistema, deterministici) e golden "platform", e offre le golden scene con più stati in una sola immagine;
- golden_screenshot o setup custom come quello mostrato sopra, spesso sufficiente per progetti medi.
In ogni caso il consiglio è di incapsulare la logica di pump in una funzione helper del progetto: cambiare libreria domani costerà una sola modifica.
Best practice riassunte
- Un golden per componente e stato, non per intere schermate popolate di dati reali.
- Nomi di file espliciti e organizzati in cartelle (
test/goldens/<feature>/...). - Rigenera i golden solo con un
--update-goldensintenzionale, mai "per far passare la CI". - Nella code review guarda sempre l'immagine, non solo il fatto che il test sia verde.
- Mantieni fissata la versione di Flutter nella pipeline: è la variabile che più spesso invalida i golden.
Conclusioni
I golden test sono il livello di test che manca a molte app Flutter: costano poco da scrivere, si integrano con la suite esistente e offrono una rete di sicurezza sull'aspetto della UI, la parte del prodotto che gli utenti giudicano per prima. Parti dai componenti del tuo design system, aggiungi le varianti tema/dimensione/scala del testo e collega tutto a un job CI dedicato: in poche ore avrai una difesa concreta contro le regressioni visive.