De laatste golden-suite die we overnamen bevatte 214 afbeeldingen. Alles groen, ongeveer acht maanden lang, over een paar honderd pull requests. Tot iemand een van die PNG's opende om te zien wat hij beschermde.

Elk glyph was een zwarte rechthoek.

De suite was geschreven door de eerste de beste tutorial te kopiëren, en dat is geen verwijt: de tutorial werkt, in de zin dat flutter test --update-goldens bestanden oplevert en de volgende run die ermee vergelijkt. Alleen vergelijkt hij het verkeerde: een layout van gevulde blokken, gerenderd met schaduwen platgeslagen tot harde blokken, op een machine die niemand had vastgepind. Verwissel een woord voor een ander van dezelfde lengte, verander de fontfamilie, en de pixels bewegen niet. De suite mat niet de interface. Hij mat dat er niets was verschoven.

Dat is in één voorbeeld het hele probleem met : ze zijn tegelijk het goedkoopste regressienet in Flutter en de overtuigendste vervalsing. Een groen vinkje ziet er in beide gevallen identiek uit.

In het kort

  • Een golden is een momentopname van de render tree op één logische maat, in één thema, één taal en één tekstschaal — en niets meer dan dat. Geen screenshot van je app op een telefoon.
  • De testomgeving levert geen app-fonts mee. Tekst zonder beschikbare familie wordt als gevulde blokken getekend, bewust, zodat de layout deterministisch blijft. Zonder fonts te laden golden-test je geometrie en noem je dat een visuele test.
  • Schaduwen worden platgeslagen tot harde blokken (debugDisableShadows): het framework laat de blur weg. Elevatie staat nog in het beeld, maar blurradius, spread en gradient niet — dus schaduwregressies zijn onzichtbaar.
  • Pixels hangen af van de engine, het besturingssysteem van de host en de Flutter-versie. Een suite die op macOS is gegenereerd faalt op Linux in CI. Pin de toolchain en genereer op het platform waar CI draait.
  • tester.view is het oppervlak, niet het toestel. Het standaard testoppervlak is 800×600 logische pixels bij een pixelratio van 3; zonder expliciete maat wordt elk telefoonscherm gerenderd op een resolutie die geen gebruiker heeft.
  • pumpAndSettle loopt vast op een spinner — een onbepaalde voortgangsindicator plant eindeloos frames in. Laadtoestanden hebben pump(duration) nodig.
  • De matrix vermenigvuldigt. 12 schermen × 2 thema's × 3 breedtes × 2 tekstschalen × 2 talen is 288 afbeeldingen, allemaal opnieuw gegenereerd bij elke Flutter-update. Kies de assen die je echt gaat reviewen.
  • De waarde van een golden-suite is de kans dat iemand naar de diff kijkt. Al het andere is leidingwerk. Een --update-goldens-commit die niemand bekeek verandert een test in een lockfile.

Wat een golden test echt doet

Eén test, één afbeelding, één vergelijking:

testWidgets('cart is empty', (tester) async {
  await pumpScreen(tester, const CartScreen(items: []));

  await expectLater(
    find.byType(CartScreen),
    matchesGoldenFile('goldens/cart_empty.light.390.ts1.0.en.png'),
  );
});

flutter test --update-goldens schrijft die PNG en slaagt. Elke volgende run decodeert hem en vergelijkt hem pixel voor pixel met wat de engine zojuist heeft gerenderd. Geen tolerantie, geen perceptuele hash — exacte gelijkheid, tot je een eigen goldenFileComparator installeert. Bij een fout schrijft de standaardcomparator vier artefacten in een map failures/ naast het testbestand: de referentie, de nieuwe render, een geïsoleerde diff en een gemaskeerde diff. (Bij een maatverschil schrijft hij alleen de eerste twee.) Dat is jouw diffreview, en die horen in .gitignore.

Twee details in dat fragment zijn belangrijker dan ze lijken.

find.byType(CartScreen) legt CartScreen niet vast. De matcher loopt vanaf het gevonden element omhoog naar de dichtstbijzijnde RepaintBoundary-voorouder en legt die laag vast. Meestal is die voorouder de hele app, en daarom zijn goldens zo vaak een afbeelding van het hele venster met de debugbanner in een hoek. Wikkel het onderwerp in een RepaintBoundary en je legt precies vast wat je bedoelde.

De gerenderde afbeelding is je app niet. Het is flutter_tester op je host: geen systeemfonts, geen systeem-UI, geen toetsenbord, een gemockt netwerk dat alleen 400's teruggeeft, schaduwen teruggebracht tot harde blokken, en het oppervlak dat de test toevallig had. Een golden die slaagt zegt dat deze tree, op deze maat, met deze invoer, niet is veranderd. Dat is echt waardevol — en veel smaller dan "de interface werkt".

De vijf redenen waarom je eerste golden tegen je liegt

1. Er zijn geen fonts, dus elk glyph is een blok

flutter test draait zonder de fonts uit je pubspec.yaml. Tekst zonder beschikbare familie valt terug op een testfont waarvan elk glyph een gevulde rechthoek is: een ontwerp dat de layout deterministisch maakt en fontbestanden overbodig, en dat betekent dat de PNG in je repository geen enkele leesbare letter bevat.

De oplossing is test/flutter_test_config.dart, dat Flutter uitvoert vóór de suites in die map. Alles wat je daar declareert — fonts laden, een eigen comparator, de klok instellen — geldt voor elke test eronder:

// test/flutter_test_config.dart
import 'dart:async';
import 'package:flutter/services.dart';

Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  await _loadFonts();
  await testMain();
}

Future<void> _loadFonts() async {
  // The family name must match what the theme asks for. If `fontFamily` is
  // null, register the same faces as 'Roboto' — that is what Material falls
  // back to, and a mismatch loads nothing and fails silently.
  // Both faces are declared under `assets:` in pubspec.yaml, and `flutter test`
  // can load them from there — a pure `dart test` cannot.
  final text = FontLoader('Roboto')
    ..addFont(rootBundle.load('assets/fonts/Roboto-Regular.ttf'))
    ..addFont(rootBundle.load('assets/fonts/Roboto-Medium.ttf'));

  // Icons are fonts too. Skip this and every `Icon` in every golden is a box.
  final icons = FontLoader('MaterialIcons')
    ..addFont(rootBundle.load('assets/fonts/MaterialIcons-Regular.otf'));

  await Future.wait([text.load(), icons.load()]);
}

Twee dingen falen hier stil in plaats van luid. Een verkeerde familienaam laadt een font dat geen enkel widget opvraagt, dus er verandert niets. En google_fonts haalt zijn snitten tijdens runtime op, wat de testomgeving niet doet: zet de .ttf-bestanden die je echt meelevert in de repository, anders wachten je goldens voor altijd op een netwerkoproep die nooit komt.

Er is een subtieler gevolg, en dat is degene die teams raakt die deze sectie overslaan: met blokglyphs kun je niet zien wat de tekst zegt, en elke eigenschap die het testfont normaliseert verdwijnt. Familie, gewicht, kerning, glyphvorm en de echte ascender en descender van het font worden vervangen door identieke vierkanten, dus een overstap van Roboto naar Inter tekent dezelfde blokken, en een copywijziging met evenveel tekens verzet helemaal niets. Het aantal tekens en de fontgrootte verzetten de blokken wél — een langere Russische string levert een bredere balk op — dus overloop en formaatwijzigingen zijn niet onzichtbaar. Alleen de dingen die tekst in productie leesbaar maken zijn dat.

2. Schaduwen worden platgeslagen, dus blur bestaat niet

debugDisableShadows verwijdert geen schaduwen; het vervangt ze door massieve blokken. BoxShadow.toPaint() laat zijn maskfilter vallen, en een Material-oppervlak tekent zijn elevatie als een harde contour (strokeWidth: elevation * 2), dus wat in de golden belandt is een niet-vervaagde vorm in de schaduwkleur. De testbinding zet hem aan omdat schaduwrendering niet gegarandeerd pixel-identiek is van versie tot versie, of zelfs van run tot run.

Het gevolg is smaller dan "geen schaduwen" en misleidender: de kaart staat er nog, maar zijn blurradius, spread en gradient niet, dus een schaduwregressie — een blur die verdubbelde, een spread die de layout brak — rendert identiek in de golden. Flutters eigen frameworktests zetten debugDisableShadows = false als ze specifiek schaduwen testen, en zetten hem aan het eind van de test terug; de binding controleert na elke test dat de flag weer op zijn standaardwaarde staat (true), de waarde die de rest van de suite gebruikt. Dat is de juiste afweging: zet echte schaduwen alleen aan waar de schaduw het onderwerp van de test is, accepteer daar de instabiliteit en laat de flag verder met rust.

3. Elke netwerkafbeelding is een placeholder

Image.network komt in een test bij een gemockte HttpClient die een lege 400 teruggeeft. Wat er daarna gebeurt hangt van het widget af: zonder errorBuilder gooit het Image-widget de laadfout in debug opnieuw en faalt de test met een NetworkImageLoadException; met een errorBuilder legt de golden stil jouw placeholder vast. cached_network_image en soortgenoten hebben hun eigen dubbelganger nodig. Of je wikkelt de aanroepen in HttpOverrides (zo doen oudere recepten het), of je doet wat een refactor overleeft: neem een ImageProvider op in het widget en geef die in de test een MemoryImage van een PNG van 1×1.

Avatars en productfoto's vormen het grootste deel van het visuele gewicht van een echt scherm, dus een suite waarin ze allemaal placeholders zijn test de layout rond de plaatjes en niets van de plaatjes zelf.

4. De klok, de taal en het toeval zijn niet van jou

DateTime.now() in een widget, intl's DateFormat zonder vaste datum, een Random zonder seed, een uuid die tijdens de build wordt gegenereerd — elk ervan levert een golden op die vandaag slaagt en op de tweede dinsdag van een of andere maand faalt, of alleen in CI faalt waar de taal van de machine anders is.

De oplossing zit niet in de test maar in het widget: neem een now-functie of een Clock, neem een Locale, neem een seed. Dat is sowieso de moeite waard — een widget die rechtstreeks de systeemklok leest kun je op geen enkele laag testen, golden of anders. Zet Intl.defaultLocale expliciet in flutter_test_config.dart en je haalt een hele klasse van "werkt op mijn machine, verkeerde taal in CI"-fouten weg; voor datums die niet in het Engels zijn, roep je ook initializeDateFormatting(locale) aan, want DateFormat gooit een fout voor een taal waarvoor het geen data heeft.

5. pumpAndSettle komt nooit terug bij een laadtoestand

pumpAndSettle pompt tot er geen frame meer gepland staat. Een onbepaalde CircularProgressIndicator plant frames zolang hij draait, dus de aanroep loopt in een timeout met het beroemde "pumpAndSettle timed out", en de golden van de laadtoestand — de toestand waar QA nooit naar kijkt omdat je de app er niet in krijgt — is precies degene die je niet kunt schrijven.

// Not this: it waits for an animation that never ends.
// await tester.pumpAndSettle();

await pumpScreen(tester, const CartScreen.loading());
await tester.pump(const Duration(milliseconds: 100)); // one fixed phase of the spinner
await expectLater(find.byType(CartScreen), matchesGoldenFile('goldens/cart_loading.light.390.ts1.0.en.png'));

De golden legt de spinner nu op 100 ms vast, consistent, voor altijd. Dat is prima: je test dat de layout eromheen overeind blijft terwijl er iets onbepaalds draait, niet de spinner zelf.

Een groene suite met 214 bestanden is geen sterke suite

Alles hierboven geeft hetzelfde symptoom: tests die slagen terwijl ze minder controleren dan je denkt. Niets ervan komt naar voren als een fout, een waarschuwing of een dalende dekking. Een golden-suite is ongewoon omdat zijn faalmodus stilte is, dus de enige manier om hem te vertrouwen is een PNG openen en controleren of er in staat wat je denkt dat erin staat. Doe dat één keer, op dag één, voor elk apart soort screenshot in de suite. Het kost tien minuten en het zijn de meest waardevolle tien minuten van de hele opzet.

De opzet die goldens reproduceerbaar maakt

Vier beslissingen, allemaal saai, allemaal één keer genomen:

Pin de toolchain. Een .fvmrc in de repository, en CI leest hetzelfde bestand. Een Flutter-update verandert tekstrendering, standaardthema's, de hernoemingen van CardTheme naar CardThemeData en het tekenen van schaduwen, dus elke golden in de repository verandert tegelijk. Dat is geen reden om niet te updaten; het is een reden om van de update een bewuste commit te maken die niets anders bevat dan opnieuw gegenereerde goldens, en dat in het bericht zegt.

Genereer op het platform waar CI draait. Antialiasing en hinting van tekst verschillen tussen macOS en Linux: hetzelfde widget op dezelfde maat levert andere pixels op. Een suite die op de Mac van een ontwikkelaar is gegenereerd faalt op een Linux-runner, en de gebruikelijke "oplossing" is een --update-goldens op een plek waar hij niet hoort. Draai goldens lokaal ook op Linux, in dezelfde container als CI als het team gemengd is.

Laat CI nooit regenereren. De bewaking is één regel:

- uses: subosito/flutter-action@v2
  with:
    flutter-version-file: .fvmrc   # one version, repo and CI
    cache: true
- run: flutter test
# A "helpful" --update-goldens in CI or a stale golden written by a bad merge
# would make the suite green while comparing nothing. Fail instead. `git diff`
# alone misses untracked files, which is exactly what a regenerated golden is.
- run: test -z "$(git status --porcelain -- '*.png')"

Die laatste regel is geen paranoia. De meest voorkomende manier waarop een golden-suite ophoudt iets te controleren is een CI-stap, om twee uur 's nachts toegevoegd tijdens een rode build, die goldens bijwerkt in plaats van te falen.

Zet het oppervlak expliciet. Het standaard testoppervlak is 800×600 logische pixels bij een pixelratio van 3. Test daar een telefoonscherm op en je krijgt de layout van een kleine tablet, terwijl de MediaQuery van de app het tegendeel beweert. Eén helper lost dat voor elke test op, en als je de naam van een golden niet kunt lezen en de vijf assen er niet uit kunt halen, is de naam fout:

// test/helpers/golden.dart
Future<void> pumpScreen(
  WidgetTester tester,
  Widget child, {
  Size size = const Size(390, 844), // iPhone 14 — pick your own baselines
  double textScale = 1,
  Locale locale = const Locale('en'),
  Brightness brightness = Brightness.light,
}) async {
  tester.view.physicalSize = size * 3;   // logical size × device pixel ratio
  tester.view.devicePixelRatio = 3;
  addTearDown(tester.view.reset);        // never leak a view into the next test

  await tester.pumpWidget(
    MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: brightness == Brightness.dark ? AppTheme.dark : AppTheme.light,
      locale: locale,
      // Without the app's own delegates a locale it does not support resolves
      // back to English.
      localizationsDelegates: AppLocalizations.delegates,
      supportedLocales: AppLocalizations.supportedLocales,
      home: RepaintBoundary(
        child: Builder(
          builder: (context) => MediaQuery(
            // Merge, don't replace: a fresh MediaQueryData has size zero,
            // devicePixelRatio 1 and no padding.
            data: MediaQuery.of(context).copyWith(
              textScaler: TextScaler.linear(textScale),
            ),
            child: child,
          ),
        ),
      ),
    ),
  );
  await tester.pump();
}

Twee details zijn hier makkelijk fout te doen. MediaQueryData(...) in plaats van copyWith bouwt een nieuw object met size: Size.zero en devicePixelRatio: 1, dus MediaQuery.sizeOf liegt tegen alles eronder. En een MaterialApp die je in de helper opnieuw bouwt, zet elke taal die hij niet ondersteunt terug naar Engels tenzij je hem de delegates van de app geeft — waar AppLocalizations hier voor staat — anders test de taal-as stil Engels.

addTearDown(tester.view.reset) is niet optioneel. tester.view is globaal voor het testbestand, dus één test die een breedte van 320pt zet lekt door naar elke volgende, en de fout duikt op bij een onschuldige test verderop in hetzelfde bestand.

Wat je golden moet maken, en wat de matrix kost

De valkuil is dat de matrix gratis lijkt. Dat is hij niet: elke afbeelding is een bestand om opnieuw te genereren, een bestand om te reviewen, en een bestand dat een regel toevoegt aan de diff van je volgende losstaande pull request.

LaagWat het dektAfbeeldingen
Componenten van het designsysteem — knop, veld, badge, kaart, list tileElke staat × licht/donker, op één breedte20–40
De vijf schermen die ertoe doen3 breedtes (320/390/768) × licht/donker30
Lege, fout- en laadtoestandenDe toestanden waar niemand de app voor opent6–10
Tekstschaal 2.0, donker, 320ptDe twee dichtste componenten van het designsysteem4
TaalDe taal met de langste strings, één breedte, de twee schermen met het grootste overlooprisico4

Dat is een suite van ongeveer 60–90 afbeeldingen: een seconde of twintig CI en een pull request waarvan een mens de visuele diff echt kan bekijken. De versie die alles met elke as vermenigvuldigt — 12 schermen × 2 thema's × 3 breedtes × 2 tekstschalen × 2 talen — is 288 afbeeldingen, en 288 afbeeldingen reviewt niemand.

Drie dingen zijn het helemaal niet waard om te golden:

  • Animaties en overgangen. Een golden legt één frame op één vast moment vast. Hij zegt dat het frame op 100 ms hetzelfde uitziet, niet dat de animatie soepel is, en de marginale waarde boven de begin- en eindtoestand is bijna nul.
  • Alles met echte inhoud erin. Lange productnamen, prijzen, datums, gegenereerde id's — elk ervan is een reden voor de golden om te veranderen om een niet-visuele reden. Golden de schil met vaste fixtures, en test het renderen van inhoud met een widget test die op tekst assert, niet op pixels.
  • Hele flows. Drie schermen op één afbeelding zijn drie keer zo slecht te reviewen als één scherm per afbeelding.

En het scherpste om te golden is in onze ervaring geen scherm: het is het designsysteem op 320pt, in donkere modus, met tekst op 200%. Die combinatie is waar echte apps breken, is met de hand bijna niet per component te controleren, en is precies waar een golden goed in is.

Wat goldens niet testen

Een golden is een plaatje. Hij weet niets van betekenis, en een paar gangbare aannames zijn het expliciet corrigeren waard.

Gedrag. Een knop die aan de verkeerde callback hangt rendert identiek. Zo ook een formulier dat niet meer valideert, een lijst die zijn scrollcontroller kwijt is, een dialoog die niet sluit. Dat zijn widget tests — tap, pump, controleren wat er gebeurde — en geen hoeveelheid pixels vervangt ze.

Toegankelijkheid. De semantiekboom is onzichtbaar voor een screenshot: ontbrekende labels, verkeerde rollen, een tikdoel teruggebracht tot 24pt, contrast onder 4.5:1 voor lopende tekst (3:1 voor grote tekst). Flutter levert de assertions, en ze zijn goedkoop:

testWidgets('cart meets a11y guidelines', (tester) async {
  final semantics = tester.ensureSemantics();
  addTearDown(semantics.dispose);

  await pumpScreen(tester, const CartScreen(items: []));

  // Tap targets (48dp Android / 44pt iOS) and text contrast.
  await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
  await expectLater(tester, meetsGuideline(textContrastGuideline));

  // The label and role the golden cannot see. `isSemantics` checks only the
  // values you pass; `matchesSemantics` would also require every other flag
  // to be false, and a real Material button sets `hasEnabledState`.
  // (`containsSemantics` is the same matcher on Flutter before 3.40.)
  expect(
    tester.getSemantics(find.byType(CheckoutButton)),
    isSemantics(label: 'Checkout', isButton: true, hasTapAction: true),
  );
});

Rendering op een echt toestel. Goldens draaien op de engine van de host, dus ze zeggen niet wat op iOS met dezelfde tree doet, of hoe de tekstshaping van het platform verschilt. Als dat verschil ertoe doet, is dat integration_test op een toestel met een screenshot dat daar is gemaakt: trager, instabieler, en het enige dat de vraag beantwoordt.

Jank. Het framebudget wordt gemeten op een profile-build op een echt toestel, niet afgeleid uit een PNG.

De regels waarvan het afhangt of het de moeite waard was

De waarde van de suite is evenredig met de kans dat er een mens naar de diff kijkt. Dat is het hele rendement, en het is een culturele eigenschap, geen technische.

Dus, concreet:

  1. --update-goldens alleen in een commit waarvan het bericht de visuele verandering noemt. "Update goldens" als commitbericht betekent dat de diff niet is bekeken. "Update goldens: afrekenknop is 48pt en de fouttoestand gebruikt het nieuwe palet" betekent dat hij wel is bekeken.
  2. Een golden-diff in een scherm dat je niet hebt aangeraakt is een bevinding, geen ruis. Dat is de suite die zijn werk doet. Het in een "chore: update goldens" proppen is de suite uitzetten.
  3. Als de reviewer de afbeeldingen niet kan zien, heeft het leidingwerk gefaald. Upload de artefacten uit failures/ vanuit CI, of hang de diff aan de pull request. Een review die bestaat uit "geloof me, de pixels zijn veranderd" is geen review.
  4. Verwijder de goldens die je zou afstempelen. Een suite van veertig afbeeldingen waar allemaal naar gekeken wordt verslaat vierhonderd die met één klik worden goedgekeurd. Dekking is niet de maatstaf; bekeken dekking is dat.
  5. Als goldens veranderen in losstaande pull requests, stop en zoek de oorzaak — een niet-vastgepinde Flutter-versie, een font dat niet geladen wordt, een DateTime.now() die een widget heeft bereikt. Instabiliteit in goldens is bijna nooit het mechanisme; het is een echt non-determinisme dat je nu kent.

De checklist voor een golden-suite

Van een lege repository naar een suite die je kunt vertrouwen, in een dag of twee.

Maak het renderen eerlijk

  • App-fonts geladen in test/flutter_test_config.dart, met de familienaam die het thema echt opvraagt
  • Het icoonfont ook geladen — anders is elke Icon een blok
  • Netwerkafbeeldingen vervangen door in-memory fixtures
  • Klok, taal en random seeds geïnjecteerd in plaats van uit de omgeving gelezen
  • Laadtoestanden gerenderd met pump(duration) in plaats van pumpAndSettle

Maak het reproduceerbaar

  • Flutter-versie vastgepind in de repository en gelezen door CI
  • Goldens gegenereerd op Linux, hetzelfde platform waar CI draait
  • Een CI-stap die faalt bij elke gewijzigde of niet-gevolgde golden, zodat niets stil regenereert
  • failures/ in gitignore, en als build-artefact geüpload voor de review
  • Het testoppervlak expliciet gezet: maat, pixelratio, tekstschaal, taal

Maak het reviewbaar

  • Elke golden in een RepaintBoundary, zodat precies het bedoelde widget wordt vastgelegd
  • De vijf assen in de bestandsnaam: onderwerp, thema, breedte, tekstschaal, taal
  • Onder de ~90 afbeeldingen, of een opgeschreven reden voor meer
  • Een regel dat golden-updates een eigen commit zijn met een genoemde visuele reden
  • Iemand heeft minstens één PNG van elk soort geopend en bevestigd dat er tekst in staat

Wat het na een half jaar kost

De suite opzetten is een dag, misschien twee, het meeste in fonts en CI-leidingwerk. Elke golden schrijven is minuten. De terugkerende kosten zijn degene die niemand begroot: elke Flutter-update genereert elke afbeelding opnieuw. Met de matrix hierboven is dat een commit van 60–90 PNGs, twee tot vier keer per jaar, elk met een eigen onderbouwing. Kun je die review niet betalen, dan kun je de suite niet betalen — want een niet-bekeken regeneratie is niet te onderscheiden van geen test, behalve dat het langer duurt.

In ruil daarvoor krijg je de regressies die geen enkel ander testtype vangt: de lege toestand die zijn padding kwijtraakte in donkere modus, de Russische vertaling die uit een knop van 320pt liep, het designsysteem dat afdreef omdat iemand één TextStyle in het thema veranderde. En het geval waarmee het voor ons begon: een agent of een collega die een gedeeld component verandert en oprecht niet kan zien welke vier schermen hij brak. Een golden is het dichtst dat Flutter komt bij het geven van ogen aan een codewijziging, en dat is precies het argument dat we maakten in waarom AI-agents worstelen met Flutter.

De korte versie

Een golden test is een pixelvergelijking met een PNG, en alles wat hem nuttig maakt gaat over de omstandigheden van die vergelijking. Laad je fonts, laad je icoonfont, zet het oppervlak, pin de toolchain, genereer opnieuw op het platform dat CI gebruikt, en laat nooit een machine de diff goedkeuren.

En houd de suite daarna klein genoeg dat er nog iemand naar kijkt. De faalmodus van golden testing is niet een instabiele test — het zijn 214 afbeeldingen die allemaal slagen omdat ze blokken met blokken vergelijken, en een groen vinkje dat acht maanden lang hetzelfde geruststellende verhaal vertelt.

Bronnen

  • matchesGoldenFile — wat de matcher vastlegt, en waar goldenFileComparator aangrijpt: api.flutter.dev
  • FontLoader — een fontsnit registreren op familienaam binnen een test: api.flutter.dev
  • debugDisableShadows — gedeclareerd in painting/debug.dart, gezet door de testbinding en weer aangezet door Flutters eigen schaduwtests: github.com/flutter/flutter
  • TestFlutterView — physicalSize, devicePixelRatio en reset(): api.flutter.dev
  • WidgetTester.pumpAndSettle — "repeatedly calls pump with the given duration until there are no longer any frames scheduled": api.flutter.dev
  • AccessibilityGuideline — androidTapTargetGuideline, iOSTapTargetGuideline, textContrastGuideline: api.flutter.dev
  • Framework-goldens getrieerd op Skia Gold, en de flutter/goldens-repo met de gevallen die het niet kunnen gebruiken: skia.org, github.com/flutter/goldens
  • alchemist — golden-mappen per platform, gegroepeerde goldens van meerdere widgets, CI-bewuste configuratie: pub.dev
  • golden_screenshot — screenshottests met toestelpreviews en platformspecifieke goldens: pub.dev
  • golden_toolkit — het pakket dat de meeste golden-tutorials nog aanraden; stopgezet, laatste release februari 2023: pub.dev
  • spot — golden-achtige widget tests die een geserialiseerde widget-tree vergelijken in plaats van pixels, wanneer pixelinstabiliteit het grotere probleem is: pub.dev
  • integration_test — screenshots van een echt toestel (roep op Android eerst convertFlutterSurfaceToImage() aan), het enige antwoord op "hoe ziet het er op een telefoon uit": integration_test.dart

Veelgestelde vragen

Een golden test rendert een widget en vergelijkt het resultaat pixel voor pixel met een PNG die in de repository staat. De PNG wordt gemaakt door flutter test met de update-goldens-flag te draaien, en elke volgende run faalt als de rendering afwijkt. In Flutter is het de goedkoopste manier om visuele regressies te vangen, omdat het in hetzelfde testproces draait als gewone widget tests en seconden kost.
Omdat de testomgeving de fonts uit je pubspec niet laadt. Tekst zonder beschikbare familie valt terug op een testfont waarvan elk glyph een gevulde rechthoek is, wat de layout deterministisch houdt en fontbestanden overbodig maakt. Registreer de echte fonts met FontLoader in test/flutter_test_config.dart, inclusief het icoonfont, en de goldens tekenen echte tekst.
De pixels hangen af van het besturingssysteem van de host en de Flutter-versie, niet alleen van je widget-tree. Antialiasing en hinting van tekst verschillen tussen macOS en Linux, dus een suite die op de laptop van een ontwikkelaar is gegenereerd komt niet overeen met een Linux-runner. Genereer goldens op hetzelfde platform en met dezelfde vastgepinde Flutter-versie die CI gebruikt, en behandel een Flutter-update als een commit die elke afbeelding bewust opnieuw genereert.
Ja. De hele waarde van een golden is dat de wijziging zichtbaar is in de review, en dat vereist dat de afbeelding in de repository naast de code staat, op dezelfde branch, en verandert in dezelfde commit als alles wat de rendering veranderde. De faalartefacten die de comparator schrijft zijn de uitzondering: de referentie, de nieuwe render en de twee diff-afbeeldingen horen in gitignore en in de artefacten van continuous integration.
Nee. pumpAndSettle pompt tot er geen frame meer gepland staat, en een onbepaalde CircularProgressIndicator plant eindeloos frames in, dus de aanroep loopt in een timeout. Gebruik tester.pump met een expliciete duur om één vaste fase van de spinner vast te leggen. Je test dat de layout rond de spinner overeind blijft; de spinner zelf is niet wat verandert.
Nee, ze dekken verschillende dingen. Een golden assert op pixels, dus hij kan je niet vertellen dat een knop de verkeerde callback aanroept, dat validatie niet meer loopt of dat een dialoog niet sluit, want dat rendert allemaal identiek. Gedrag dek je met widget tests, en semantiek en tikdoelen met assertions als meetsGuideline, die een screenshot helemaal niet ziet.
Genoeg dat er nog iemand naar de diffs kijkt, wat in de praktijk neerkomt op ongeveer 60 tot 90 afbeeldingen: de componenten van het designsysteem in hun staten en beide thema's, de paar schermen die ertoe doen op drie breedtes, en de lege, fout- en laadtoestanden. Elk scherm vermenigvuldigen met elk thema, elke breedte, tekstschaal en taal levert honderden afbeeldingen op die met één klik worden goedgekeurd, en een suite die met één klik wordt goedgekeurd test niets.
Begin met wat flutter_test meelevert: matchesGoldenFile, een flutter_test_config.dart voor het laden van fonts, en een kleine helper die de oppervlaktemaat zet. Pakketten als alchemist, golden_screenshot en spot voegen golden-mappen per platform, toestelpreviews of widget-tree-gebaseerde vergelijking toe, en zijn de moeite waard zodra je het probleem raakt dat elk oplost. golden_toolkit, dat de meeste tutorials nog aanraden, is in 2023 stopgezet.

Als je de testlaag vanaf nul opzet, staan de omliggende beslissingen in onze uiteenzetting over het proces van Flutter-appontwikkeling, en het staatsmodel-probleem dat de helft van een interface ontestbaar maakt in vijf toestanden die niet kunnen bestaan. Het thema is in alle drie hetzelfde: een check die slaagt is niet hetzelfde als een check die kijkt.


Maxim is senior software engineer bij Nerdy Production, een Flutter-first bureau dat apps bouwt en onderhoudt in fintech, zorg en retail. We doen ook Flutter-appontwikkeling voor teams die liever vóór de volgende release weten wat hun testsuite vergelijkt dan erna.