effidevFlutter · Edge de Cloudflare · Optimización de costes en la nube

Por qué tus golden tests pasan en macOS local pero siguen fallando en CI Linux

Por qué tus golden tests pasan en macOS local pero siguen fallando en CI Linux

Casi todos los equipos que adoptan golden tests por primera vez se frustran en el mismo orden. Ejecutas flutter test en local y todo sale en verde, pero subes exactamente el mismo commit a CI y solo los golden tests fallan, en rojo. Abres la imagen de diff y ves que apenas unos pocos píxeles están desalineados, así que empiezas a pensar “seguro que si reintento, pasa”, y al final todo el job de golden tests pierde la confianza del equipo.

Este artículo no es una introducción de “qué es un golden test”. Parte de que ya los has adoptado, y se centra en por qué matchesGoldenFile produce resultados distintos entre tu macOS local y el contenedor Linux de CI, y en cómo arreglarlo de forma estructural.

Resumen clave

Por qué el “en mi máquina funciona” aparece tan a menudo justo en los golden tests

Un test unitario normal compara valores lógicos de entrada y salida, así que el resultado no cambia entre plataformas. Los golden tests, en cambio, comparan una imagen en píxeles obtenida al rasterizar realmente el widget. Es decir, lo que se valida no es “¿la lógica es correcta?” sino “¿el dibujo es idéntico?”, así que basta una pequeña diferencia en el motor de dibujo, en las fuentes o en la densidad de pantalla para que el test falle.

matchesGoldenFile de Flutter realiza, por defecto, una comparación exacta byte a byte. La propia documentación oficial de Flutter advierte que “las fuentes personalizadas pueden renderizarse de forma distinta según la plataforma o la versión de Flutter”, y que un golden generado en Windows fallará casi con toda seguridad en otro sistema operativo. En otras palabras, esto no es un bug: es una limitación documentada.

Causa raíz 1: las fuentes son distintas

Por defecto, el test binding de Flutter solo carga la fuente Ahem. Ahem es una fuente exclusiva para tests que dibuja un cuadrado negro (en realidad, la caja del glifo) por cada carácter, así que si no cargas una fuente real, el golden de cualquier widget con texto acaba lleno de cuadraditos.

Aquí los equipos suelen cometer uno de estos dos errores:

En ambos casos se produce exactamente el mismo síntoma: “en local funciona, en CI no”.

Causa raíz 2: el DPR (device pixel ratio) y la escala de texto son distintos

El tamaño real en píxeles de la imagen golden que dibuja WidgetTester se calcula como píxeles lógicos (logical pixel) × DPR. Si la configuración de pantalla Retina de tu máquina de desarrollo macOS, o los valores por defecto del test runner de tu IDE, difieren de los valores por defecto de CI, la resolución de la imagen cambia aunque el árbol de widgets sea idéntico, y aparece el diff. Lo mismo pasa con el factor de escala de texto (textScaleFactor): una diferencia mínima en la configuración de accesibilidad del sistema o en el valor inicial del entorno de test puede desplazar los saltos de línea y cambiar la imagen por completo.

Causa raíz 3: diferencias del rasterizador según la plataforma

Hay casos en los que el test sigue fallando incluso fijando perfectamente la fuente y el DPR. Esto sucede porque el antialiasing de texto, el subpixel rendering y los algoritmos de hinting varían según el backend de renderizado. En el issue tracker de GitHub de Flutter hay reportes de que “usando la misma imagen Docker, el número de píxeles distintos en los golden tests varía entre 1 y 90 según la plataforma host (Docker en Windows vs. Docker en macOS)”. Es decir, el problema está enterrado en lo más profundo del stack de text shaping y rasterización del motor de Flutter. Hay que asumir que fijar el contenedor con Docker por sí solo puede no resolver el 100% del problema.

Por qué macOS local y el contenedor Linux de CI dibujan estructuralmente cosas distintas

Resumiendo, se acumulan las siguientes diferencias estructurales:

Cuando estas cuatro cosas se combinan, se produce el clásico caso de flakiness: “el código no cambió, pero el golden se rompe solo”.

Receta 1: empaquetar y fijar las fuentes en el entorno de test

Lo primero que hay que hacer es forzar a que el test se dibuje siempre con la misma fuente, sin importar en qué entorno se ejecute.

Cargar las fuentes de la app con flutter_test_config.dart

El framework de test de Flutter busca flutter_test_config.dart subiendo desde el directorio donde está el archivo de test hacia los directorios superiores, y lo aplica antes de ejecutar. Si cargas las fuentes ahí, se aplican a todos los tests de golpe.

// 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(); // Carga Roboto y las fuentes personalizadas registradas en pubspec
  return testMain();
}

loadAppFonts() lee automáticamente la sección fonts: del pubspec.yaml y las fuentes de los paquetes de los que depende, y las inyecta en el test binding. Aun así, hay algunas trampas:

Sacarle partido a Ahem en vez de evitarlo

Paradójicamente, existe también la estrategia de renunciar a “renderizar bonito con la fuente real” y usar Ahem de forma deliberada como mecanismo de verificación exclusivo para CI. Si lo que quieres validar no es el contenido del texto sino el layout (alineación, tamaño, si hay salto de línea o no), Ahem —que siempre dibuja el mismo cuadrado— es en realidad más estable que una fuente real, cuya forma de glifo puede variar entre plataformas. Alchemist, del que hablaremos más adelante, da soporte oficial a esta estrategia bajo el concepto de “CI golden”.

golden_bricks: el punto intermedio entre Ahem y una fuente real

El problema de Ahem es que todos los caracteres son el mismo cuadrado, así que no se pueden testear casos donde el ancho real del carácter importa, como la posición del caret, la selección de texto o los saltos de línea. golden_bricks es un paquete que llena ese hueco: dibuja cuadrados de tamaño distinto por carácter, así que renderiza “cuadrados, pero con anchos que varían como en un texto real”.

# pubspec.yaml (dev_dependencies)
golden_bricks: ^1.0.0
MaterialApp(
  theme: ThemeData(fontFamily: goldenBricks),
  home: const MyWidget(),
)

Si adoptas como estándar una fuente determinista como esta en lugar de una fuente real dependiente de la plataforma, puedes eliminar de raíz cualquier margen para que las diferencias entre CoreText y FreeType afecten al resultado.

Receta 2: fijar de forma explícita en el código el DPR y la escala de texto

A través de TestFlutterView (tester.view), que expone WidgetTester, puedes fijar explícitamente la densidad y el tamaño de pantalla. Si no especificas estos valores, terminas dependiendo silenciosamente de los valores por defecto de la máquina donde se ejecuta el test.

testWidgets('Golden test de la tarjeta de producto', (tester) async {
  tester.view.physicalSize = const Size(1080, 2400);
  tester.view.devicePixelRatio = 3.0;

  // Resetea siempre al final para que no afecte a los siguientes tests
  addTearDown(tester.view.reset);

  await tester.pumpWidget(const MyApp(home: ProductCard()));
  await tester.pumpAndSettle();

  await expectLater(
    find.byType(ProductCard),
    matchesGoldenFile('goldens/product_card.png'),
  );
});

Los puntos clave son:

En lugar de repetir este patrón en cada golden test, se recomienda envolverlo en una función helper común (por ejemplo, pumpGolden) para forzar a que todo el equipo use los mismos valores de referencia. Si el valor de referencia cambia de un archivo a otro, aparece otra inconsistencia del tipo “este archivo usa DPR 2.0 pero aquel usa 3.0”.

Receta 3: usar una herramienta de diff con tolerancia

Aunque fijes tanto la fuente como el DPR, en la práctica sigue quedando el caso de que no se obtienen bytes exactamente idénticos por las diferencias de rasterizador ya mencionadas. En ese punto, cambiar de una comparación exacta (exact match) a una comparación con tolerancia (tolerance) es la opción más práctica.

Comparativa golden_toolkit vs alchemist

Aspecto golden_toolkit alchemist
Estado de mantenimiento discontinued (según pub.dev; última versión 0.15.0, sin actualizar desde hace años) Mantenimiento activo (Very Good Ventures + Betterment, última 0.14.0)
Carga de fuentes Ofrece loadAppFonts() Soporta el mismo patrón desde flutter_test_config.dart
Tolerancia a nivel de píxel No incluida por defecto (hay que implementar un comparator personalizado) Permite indicar el ratio de tolerancia mediante el parámetro diffThreshold (entre 0.0 y 1.0)
Separación de goldens por plataforma/CI No soportada (hay que escribir la lógica de skip a mano) Separa automáticamente en carpetas los goldens de platform y de ci — los goldens de CI se basan en Ahem, así que no se ven afectados por la plataforma
Test simultáneo de varios tamaños de pantalla DeviceBuilder, multiScreenGolden() Agrupación de escenarios mediante GoldenTestGroup + GoldenTestScenario

Si el proyecto es nuevo, tiene sentido evaluar alchemist antes que golden_toolkit. golden_toolkit aparece marcado como discontinued en pub.dev, mientras que alchemist está diseñado específicamente para atacar de frente el problema que trata este artículo: las diferencias de renderizado entre plataformas.

Ejemplo de uso de diffThreshold en alchemist

void main() {
  setUpAll(() {
    AlchemistConfig.current = AlchemistConfig(
      platformGoldensConfig: const PlatformGoldensConfig(
        enabled: true,
      ),
      ciGoldensConfig: const CiGoldensConfig(
        enabled: true,
      ),
    );
  });

  goldenTest(
    'Tarjeta de producto',
    fileName: 'product_card',
    pixelDiffConfig: const GoldenTestPixelDiffConfig(threshold: 0.01),
    widget: const GoldenTestGroup(
      children: [ProductCard()],
    ),
  );
}

threshold: 0.01 significa que una diferencia de menos del 1% del total de píxeles no se considerará un fallo. Si fijas este valor demasiado alto, corres el riesgo de dejar pasar regresiones reales de la UI, así que se recomienda empezar con un valor pequeño, entre 0.005 y 0.01, y ajustarlo según la frecuencia real de flakiness que observe el equipo.

La opción del comparator personalizado

Si no quieres añadir un paquete más, también puedes heredar de LocalFileComparator e implementar tú mismo el cálculo del porcentaje de diferencia de píxeles en el método compare(), registrando ese comparator en flutter_test_config.dart. Sin embargo, este camino te obliga a implementar a mano la decodificación de imágenes, el redimensionado y el manejo de los bordes de antialiasing, así que si el equipo es pequeño, adoptar alchemist resulta mucho más rentable.

Receta 4: separar los golden tests en un job propio dentro del pipeline de CI

Aunque resuelvas fuentes, DPR y tolerancia, si el diseño del propio pipeline de CI es descuidado, la flakiness reaparece. Se recomiendan los siguientes principios:

Ejemplo con 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  # Imagen personalizada con la versión del SDK fijada
    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

Checklist si aun así sigue fallando

Para cerrar

La flakiness de los golden tests casi nunca se debe a que “el código de test esté mal”, sino a que el entorno de renderizado en el que se ejecuta el test no está bajo control. Empaquetar las fuentes para eliminar las diferencias de renderizador, fijar explícitamente en el código el DPR y la escala de texto, absorber con un diff con tolerancia las diferencias mínimas que aun así queden, y por último diseñar el propio pipeline de CI para que sea reproducible: siguiendo estos cuatro pasos en orden, la mayoría de los golden tests que “en mi máquina funcionan pero en CI se rompen” dejan de dar problemas.