Warum Golden Tests lokal auf macOS grün sind, in der CI unter Linux aber ständig fehlschlagen

Teams, die Golden Tests zum ersten Mal einführen, durchlaufen fast ausnahmslos denselben Frustrationszyklus. Lokal läuft flutter test grün durch, doch sobald genau derselbe Commit in der CI landet, schlagen ausgerechnet die Golden Tests rot fehl. Öffnet man das Diff-Bild, sind es oft nur ein paar verschobene Pixel – also fängt man an, den Job einfach neu zu starten, in der Hoffnung, dass er beim nächsten Mal durchläuft. Am Ende verliert der gesamte Golden-Test-Job das Vertrauen des Teams.
Dieser Artikel ist kein Einsteigertext nach dem Motto „Was sind Golden Tests überhaupt“. Er richtet sich an Teams, die Golden Tests bereits im Einsatz haben, und geht der Frage nach, warum matchesGoldenFile zwischen lokalem macOS und dem Linux-Container der CI unterschiedliche Ergebnisse liefert – und wie man das strukturell behebt.
Das Wichtigste in Kürze
- Die Ursache für Flakiness bei Golden Tests liegt so gut wie immer in einem von drei Bereichen: Schriftarten, DPR (Device Pixel Ratio) oder plattformspezifische Rasterizer. Es handelt sich nicht um ein Problem der Code-Logik, sondern der Render-Umgebung.
- Lokal zeichnet macOS Text über CoreText, der Linux-Container der CI dagegen über FreeType/fontconfig. Selbst bei identischer Font-Datei unterscheiden sich Antialiasing und Hinting-Algorithmen, wodurch auf Pixelebene ein anderes Bild entsteht.
- Die Lösung besteht im Wesentlichen aus drei Schritten: (1) Schriftarten in die Testumgebung bündeln, um die Abhängigkeit vom Renderer zu eliminieren, (2) DPR und Text-Skalierung im Testcode fest verankern, und (3) statt eines exakten Byte-Vergleichs auf einen toleranzbasierten Diff umsteigen.
- Aus Sicht des CI-Designs braucht es eine Pipeline, die Golden Tests in einen eigenen Job auslagert, die Render-Umgebung (Docker-Image) versionsfest fixiert und bei Fehlschlägen das Diff-Bild als Artefakt sichert.
Warum bei Golden Tests so oft „Bei mir läuft’s doch“ zu hören ist
Bei gewöhnlichen Unit-Tests sind Ein- und Ausgabe logische Werte, weshalb das Ergebnis plattformunabhängig identisch bleibt. Golden Tests dagegen vergleichen tatsächlich gerasterte Pixelbilder von Widgets. Geprüft wird also nicht „Ist die Logik korrekt?“, sondern „Sieht das Bild exakt gleich aus?” – und schon kleinste Abweichungen bei Render-Engine, Schriftart oder Bildschirmdichte lassen den Test fehlschlagen.
Flutters matchesGoldenFile führt standardmäßig einen exakten Byte-für-Byte-Vergleich durch. Auch die offizielle Flutter-Dokumentation weist ausdrücklich darauf hin, dass benutzerdefinierte Schriftarten je nach Plattform oder Flutter-Version unterschiedlich gerendert werden können, und warnt davor, dass unter Windows erzeugte Golden-Dateien auf anderen Betriebssystemen so gut wie sicher fehlschlagen. Das ist also kein Bug, sondern eine dokumentierte Einschränkung.
Ursache 1: Unterschiedliche Schriftarten
Das Flutter-Test-Binding lädt standardmäßig nur die Ahem-Schrift. Ahem ist eine reine Test-Schriftart, die für jedes Zeichen ein schwarzes Quadrat (genauer: eine Glyph-Box) zeichnet. Wird keine echte Schriftart geladen, bestehen die Golden-Bilder von Widgets mit Text also vollständig aus solchen Kästchen.
Teams machen an dieser Stelle typischerweise einen von zwei Fehlern.
- Nur lokal ist zufällig eine Systemschrift installiert (etwa San Francisco unter macOS), sodass ein plausibel aussehendes Golden-Bild entsteht – und genau dieses wird committet.
- Die App-Schriftart wird zwar geladen, aber im CI-Container fehlt die passende Font-Datei – sei es, weil sie nicht mitgeliefert wird, sei es aus Lizenzgründen –, wodurch stattdessen mit einer Fallback-Schrift gerendert wird.
Beide Fälle erzeugen exakt das Symptom „läuft lokal, schlägt in der CI fehl“.
Ursache 2: Unterschiedliche DPR (Device Pixel Ratio) und Text-Skalierung
Die tatsächliche Pixelgröße des von WidgetTester gezeichneten Golden-Bilds ergibt sich aus logischen Pixeln × DPR. Weichen die Retina-Einstellungen der lokalen macOS-Entwicklungsmaschine oder die Standardkonfiguration des Test-Runners in der IDE von den CI-Standardwerten ab, unterscheidet sich schon die Bildauflösung selbst, obwohl derselbe Widget-Baum gerendert wird – und der Diff schlägt fehl. Für den Text-Skalierungsfaktor (textScaleFactor) gilt dasselbe: Schon minimale Unterschiede bei den Bedienungshilfen-Einstellungen des Systems oder den Startwerten der Testumgebung können Zeilenumbrüche verschieben und das Bild dadurch vollständig verändern.
Ursache 3: Plattformspezifische Unterschiede im Rasterizer
Selbst wenn Schriftart und DPR vollständig fixiert sind, kann der Test trotzdem fehlschlagen. Der Grund: Antialiasing, Subpixel-Rendering und Hinting-Algorithmen unterscheiden sich je nach Render-Backend. Im Issue-Tracker von Flutter auf GitHub finden sich sogar Berichte, wonach bei identischem Docker-Image je nach Host-Plattform (Windows-Docker vs. macOS-Docker) zwischen 1 und 90 Pixel im Golden Test abweichen – das Problem sitzt also tief im Text-Shaping- und Rasterisierungs-Stack der Flutter-Engine selbst. Man sollte also von vornherein akzeptieren, dass allein das Fixieren des Containers per Docker das Problem nicht zu 100 % lösen muss.
Warum lokales macOS und der Linux-Container der CI strukturell unterschiedliche Bilder erzeugen
Zusammengefasst summieren sich folgende strukturelle Unterschiede.
- Text-Rendering-Stack: macOS nutzt CoreText, Linux in der Regel die Kombination FreeType + fontconfig. Selbst bei derselben TTF-Datei unterscheiden sich Hinting und Antialiasing, wodurch andere Pixelwerte entstehen.
- Verfügbarkeit von Schriftarten: Lokal unter macOS ist eine reiche Auswahl an Systemschriften vorinstalliert, während im minimalen Linux-Container der CI (etwa dem
ubuntu-latest-Runner von GitHub Actions oder einem Flutter-CI-Docker-Image) nicht explizit angegebene Schriftarten schlicht fehlen – die Fallback-Kette unterscheidet sich entsprechend. - GPU- vs. Software-Renderer: Da CI-Umgebungen meist headless laufen, kommt dort ein Software-Rasterizer zum Einsatz, während lokales macOS den hardwarebeschleunigten Pfad nutzen kann.
- Versionsabweichungen bei Flutter/Skia: Unterscheiden sich die lokale SDK-Version und die in der CI gecachte SDK-Version, ändert sich das Rendering-Ergebnis von Skia selbst.
Kommen diese vier Faktoren zusammen, entsteht die klassische Flakiness nach dem Muster „der Code hat sich nicht geändert, und trotzdem bricht der Golden Test“.
Lösung 1: Schriftarten in der Testumgebung bündeln und fixieren
Der erste Schritt besteht darin, zu erzwingen, dass der Test unabhängig von der Ausführungsumgebung immer mit derselben Schriftart gerendert wird.
App-Schriftarten über flutter_test_config.dart laden
Das Flutter-Test-Framework sucht ausgehend vom Verzeichnis der Testdatei die Verzeichnishierarchie aufwärts nach einer flutter_test_config.dart und wendet sie vor der Ausführung an. Lädt man dort die Schriftarten, gilt das automatisch für alle Tests.
// test/flutter_test_config.dart
import 'dart:async';
import 'package:golden_toolkit/golden_toolkit.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
await loadAppFonts(); // Lädt Roboto + alle in der pubspec registrierten benutzerdefinierten Schriftarten
return testMain();
}
loadAppFonts() liest automatisch den fonts:-Abschnitt aus der pubspec.yaml sowie die Schriftarten abhängiger Pakete ein und injiziert sie in das Test-Binding. Allerdings gibt es einige Fallstricke.
- Die Flutter-Testumgebung unterstützt pro Font-Familie nur einen einzigen Schriftschnitt (.ttf), weshalb Widgets, die Bold und Regular mischen, im Test leicht anders aussehen können als in der echten App.
- Um Material-Icons zu verwenden, muss in der pubspec unbedingt
uses-material-design: truegesetzt sein, damit die Icon-Schriftart überhaupt geladen wird. - Interner Framework-Text wie das Debug-Banner kann weiterhin mit Ahem gezeichnet werden – ein Bereich, der sich nicht vollständig kontrollieren lässt.
Ahem bewusst als Vorteil nutzen
Paradoxerweise gibt es auch die Strategie, auf ein „schön gerendertes“ Ergebnis mit echten Schriftarten zu verzichten und Ahem gezielt als CI-Verifikationswerkzeug einzusetzen. Will man nur das Layout prüfen – Ausrichtung, Größe, Zeilenumbrüche – und nicht den Textinhalt selbst, ist Ahem, das immer dasselbe Quadrat zeichnet, sogar stabiler als eine echte Schriftart, deren Glyphenform sich je nach Plattform unterscheiden kann. Das später vorgestellte Paket alchemist unterstützt diese Strategie offiziell über das Konzept der „CI Goldens“.
golden_bricks: der Mittelweg zwischen Ahem und echter Schrift
Das Problem bei Ahem: Da jedes Zeichen dasselbe Quadrat ist, lassen sich Fälle wie Caret-Position, Textauswahl oder Zeilenumbrüche, bei denen die tatsächliche Zeichenbreite eine Rolle spielt, nicht sinnvoll testen. golden_bricks schließt genau diese Lücke: Das Paket zeichnet für jedes Zeichen ein unterschiedlich breites Rechteck – also „Kästchen, aber mit Breiten wie bei echtem Text“.
# pubspec.yaml (dev_dependencies)
golden_bricks: ^1.0.0
MaterialApp(
theme: ThemeData(fontFamily: goldenBricks),
home: const MyWidget(),
)
Setzt man statt einer plattformabhängigen echten Schriftart standardmäßig auf eine solche deterministische Schriftart, lässt sich die Möglichkeit, dass CoreText/FreeType-Unterschiede das Ergebnis überhaupt beeinflussen, von vornherein ausschließen.
Lösung 2: DPR und Text-Skalierung im Testcode fest verankern
Über die von WidgetTester bereitgestellte TestFlutterView (tester.view) lassen sich Bildschirmdichte und -größe explizit fixieren. Verzichtet man darauf, hängt der Test unbemerkt von den Standardwerten der ausführenden Maschine ab.
testWidgets('Golden Test für die Produktkarte', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3.0;
// Nach dem Test unbedingt zurücksetzen, damit der nächste Test nicht beeinflusst wird
addTearDown(tester.view.reset);
await tester.pumpWidget(const MyApp(home: ProductCard()));
await tester.pumpAndSettle();
await expectLater(
find.byType(ProductCard),
matchesGoldenFile('goldens/product_card.png'),
);
});
Die wichtigsten Punkte dabei.
- Werden
physicalSizeunddevicePixelRatioexplizit gesetzt, wirken sich die Display-Einstellungen der lokalen Maschine oder die Standardauflösung des CI-Runners nicht mehr auf das Ergebnis aus. addTearDown(tester.view.reset)muss unbedingt aufgerufen werden. Andernfalls übernehmen nachfolgende Tests in derselben Testdatei den DPR-Wert des vorherigen Tests unverändert – eine weitere, von der Ausführungsreihenfolge abhängige Form von Flakiness.- Nach demselben Prinzip lässt sich auch die Text-Skalierung über
tester.platformDispatcher.textScaleFactorTestValuefixieren, oder man umschließt den Widget-Baum mitMediaQueryund überschreibt sie explizit mittextScaler: TextScaler.noScaling– beides ist sicherer.
Statt dieses Muster in jedem einzelnen Golden Test zu wiederholen, empfiehlt es sich, es in eine gemeinsame Hilfsfunktion (etwa pumpGolden) zu kapseln, sodass das gesamte Team dieselben Referenzwerte verwendet. Unterscheiden sich die Referenzwerte von Datei zu Datei – „diese Datei nutzt DPR 2.0, jene 3.0“ –, entsteht dadurch nur eine weitere Form von Inkonsistenz.
Lösung 3: Toleranzbasierte Diff-Tools einsetzen
Selbst wenn Schriftart und DPR vollständig fixiert sind, bleibt aufgrund der bereits erwähnten Rasterizer-Unterschiede in der Praxis der Fall bestehen, dass keine hundertprozentig identischen Bytes erzeugt werden. Hier ist der pragmatische Ausweg, statt eines exakten Byte-Vergleichs (Exact Match) auf einen toleranzbasierten Vergleich umzusteigen.
golden_toolkit im Vergleich zu alchemist
| Kriterium | golden_toolkit | alchemist |
|---|---|---|
| Wartungsstatus | discontinued (laut pub.dev, aktuelle Version 0.15.0, seit Jahren kein Update) | Aktiv gepflegt (Very Good Ventures + Betterment, aktuelle Version 0.14.0) |
| Font-Loading | Bietet loadAppFonts() |
Unterstützt dasselbe Muster über flutter_test_config.dart |
| Pixel-Toleranz | Nicht standardmäßig verfügbar (eigener Comparator muss selbst implementiert werden) | Über den Parameter diffThreshold lässt sich eine Toleranzquote zwischen 0,0 und 1,0 festlegen |
| Trennung nach Plattform-/CI-Goldens | Nicht unterstützt (eigene Skip-Logik nötig) | Trennt platform- und ci-Goldens automatisch in eigene Ordner — CI-Goldens basieren auf Ahem und sind daher plattformunabhängig |
| Test mehrerer Bildschirmgrößen gleichzeitig | DeviceBuilder, multiScreenGolden() |
Szenario-Gruppierung über GoldenTestGroup + GoldenTestScenario |
Bei neuen Projekten ist es sinnvoll, zuerst alchemist statt golden_toolkit in Betracht zu ziehen. golden_toolkit ist auf pub.dev als discontinued markiert, während alchemist gezielt für genau das Problem entwickelt wurde, um das es in diesem Artikel geht: Rendering-Unterschiede zwischen Plattformen.
Beispiel für diffThreshold in alchemist
void main() {
setUpAll(() {
AlchemistConfig.current = AlchemistConfig(
platformGoldensConfig: const PlatformGoldensConfig(
enabled: true,
),
ciGoldensConfig: const CiGoldensConfig(
enabled: true,
),
);
});
goldenTest(
'Produktkarte',
fileName: 'product_card',
pixelDiffConfig: const GoldenTestPixelDiffConfig(threshold: 0.01),
widget: const GoldenTestGroup(
children: [ProductCard()],
),
);
}
threshold: 0.01 bedeutet: Abweichungen von unter 1 % aller Pixel führen nicht zu einem Fehlschlag. Wählt man diesen Wert zu großzügig, besteht die Gefahr, dass echte UI-Regressionen ebenfalls durchgewinkt werden. Es empfiehlt sich daher, zunächst mit einem kleinen Wert zwischen 0,005 und 0,01 zu starten und ihn anhand der tatsächlichen Flakiness-Häufigkeit im Team anzupassen.
Die Option eines eigenen Comparators
Wer keine zusätzliche Abhängigkeit einführen möchte, kann auch LocalFileComparator erweitern, in der compare()-Methode selbst den prozentualen Pixelunterschied berechnen und den eigenen Comparator in flutter_test_config.dart registrieren. Dieser Weg erfordert allerdings, Bilddekodierung, Resizing und die Behandlung von Antialiasing-Grenzfällen selbst zu implementieren – für kleinere Teams ist die Einführung von alchemist deutlich kosteneffizienter.
Lösung 4: Golden Tests in der CI-Pipeline als eigenen Job auslagern
Auch wenn Schriftart, DPR und Toleranz vollständig im Griff sind, kehrt die Flakiness zurück, wenn das CI-Pipeline-Design selbst mangelhaft ist. Folgende Prinzipien werden empfohlen.
- Golden Tests in einen eigenen Job auslagern, getrennt von den regulären Unit-Tests. Da Golden Tests empfindlich auf die Render-Umgebung reagieren, sollten Retry-Policy und Artefakt-Upload-Konfiguration anders gehandhabt werden als bei Unit-Tests.
- Flutter-SDK-Version und Docker-Image exakt fixieren (etwa durch explizite Angabe von
flutter --versionoder ein per SHA fixiertes Docker-Image). Ändert sich die Minor-Version des SDK, kann sich das Skia-Rendering-Ergebnis ändern, sodass sich eine komplette Neuerzeugung der Goldens nicht immer vermeiden lässt – aber zumindest die Ursache „mein lokales SDK unterscheidet sich von dem der CI“ lässt sich damit ausschließen. - Bei Fehlschlägen das Diff-Bild als Artefakt hochladen, damit Reviewer nicht anhand der Logs raten müssen, wie viele Pixel abweichen, sondern die Bilder direkt visuell vergleichen können.
- Golden-Aktualisierungen müssen zwingend in derselben Umgebung (Docker) wie die CI erzeugt werden. Committet man Dateien, die lokal unter macOS mit
flutter test --update-goldensaktualisiert wurden, direkt so, reproduziert man genau das Problem, das dieser gesamte Artikel beschreibt.
Beispiel für GitHub Actions
name: golden-tests
on:
pull_request:
paths:
- 'lib/**'
- 'test/**'
jobs:
golden:
runs-on: ubuntu-latest
container:
image: ghcr.io/your-org/flutter-ci:3.35.0 # Custom-Image mit fixierter SDK-Version
steps:
- uses: actions/checkout@v4
- name: Cache pub dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
.dart_tool
key: pub-${{ hashFiles('pubspec.lock') }}
- run: flutter pub get
- name: Run golden tests
run: flutter test --tags golden
- name: Upload golden diff on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: golden-failures
path: |
test/**/failures/*.png
- Mit
--tags goldenwerden nur die Golden Tests separat getaggt ausgeführt, was die Laufzeit reduziert und die Fehlerursache eingrenzt (dazu wird die Annotation@Tags(['golden'])am Anfang der Testdatei ergänzt). - Der pub-Cache wird über einen Key referenziert, um die Installationszeit der Abhängigkeiten zu verkürzen – das SDK-Image selbst, das das Rendering-Ergebnis beeinflusst, sollte dagegen nicht gecacht, sondern explizit versionsfest angegeben werden. Wird das gecachte Image unbemerkt aktualisiert, entsteht eine neue Form von Flakiness nach dem Muster „der Golden Test, der gestern noch funktioniert hat, schlägt heute plötzlich fehl“.
- Um lokal exakt dieselben Bedingungen wie in der CI zu reproduzieren, ist es am zuverlässigsten, dasselbe Docker-Image, das auch die CI verwendet, lokal auszuführen und die Goldens darin zu aktualisieren. Man muss dabei jedoch berücksichtigen, dass, wie im eingangs erwähnten GitHub-Issue beschrieben, bei unterschiedlichem Host-Betriebssystem selbst dasselbe Image nicht zwingend identische Pixel garantiert. In diesem Fall bleibt als letztes Mittel nur, den
diffThresholdleicht anzuheben.
Checkliste, falls der Test trotzdem fehlschlägt
- Befindet sich die
flutter_test_config.darttatsächlich im Geltungsbereich des ausgeführten Verzeichnisses? (In Monorepos fehlt sie häufig in einzelnen Paketen.) - Stimmt die Flutter-SDK-Version, mit der die Golden-Dateien erzeugt wurden, exakt mit der SDK-Version der CI überein? (Vergleich per
flutter --version.) - Wurde
tester.view.reset()bzw.addTearDownvergessen, sodass DPR- oder Text-Skalierungswerte aus einem vorherigen Test durchsickern? - Ist die in der pubspec referenzierte Font-Lizenzdatei tatsächlich im Repository committet und damit auch für die CI zugänglich? (Manche kommerziellen Schriftarten sind nur auf der lokalen Entwicklermaschine installiert, aber nicht Teil des Repos.)
- Ist bei Verwendung von alchemist korrekt aktiviert, welche der beiden Konfigurationen –
ciGoldensConfigoderplatformGoldensConfig– tatsächlich in der CI verifiziert werden soll?
Fazit
Flakiness bei Golden Tests entsteht in den meisten Fällen nicht, weil „der Testcode falsch ist“, sondern weil die Render-Umgebung, in der der Test läuft, nicht unter Kontrolle ist. Schriftarten bündeln, um Renderer-Unterschiede zu eliminieren; DPR und Text-Skalierung explizit im Code fixieren; verbleibende minimale Abweichungen über einen toleranzbasierten Diff abfangen; und schließlich die CI-Pipeline selbst reproduzierbar gestalten. Wer diese vier Schritte der Reihe nach umsetzt, wird die meisten Golden-Test-Fälle nach dem Muster „läuft bei mir, bricht nur in der CI“ los.