effidevFlutter · Cloudflare-Edge · Cloud-Kostenoptimierung

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

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

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.

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.

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.

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.

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.

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

Checkliste, falls der Test trotzdem fehlschlägt

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.