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
- La causa raíz de la inestabilidad (flakiness) de los golden tests casi siempre es una de estas tres: fuentes, DPR (device pixel ratio) o el rasterizador específico de la plataforma. No es un problema de lógica de código, sino del entorno de renderizado.
- Tu macOS local dibuja el texto con CoreText, mientras que el contenedor Linux de CI usa FreeType/fontconfig. Aunque el archivo de fuente sea idéntico, los algoritmos de antialiasing y hinting difieren, y el resultado es una imagen distinta a nivel de píxel.
- Hay básicamente tres soluciones. (1) Empaquetar las fuentes en el entorno de test para eliminar la dependencia del renderizador, (2) fijar de forma explícita en el código el DPR y la escala de texto, y (3) usar un diff con tolerancia en vez de una comparación exacta byte a byte.
- Desde el punto de vista del diseño de CI, hace falta separar los golden tests en un job propio, fijar la versión del entorno de renderizado (imagen Docker) y subir las imágenes de diff como artefactos cuando falla el test.
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 local, por pura casualidad, tienen instaladas fuentes del sistema (por ejemplo San Francisco en macOS), así que sale un golden con buena pinta y lo commitean tal cual.
- Sí cargan la fuente de la app, pero el contenedor de CI no tiene ese archivo de fuente, o no se puede empaquetar por un problema de licencia, y termina dibujándose con una fuente de fallback.
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:
- Stack de renderizado de texto: macOS usa CoreText, mientras que Linux usa mayoritariamente la combinación FreeType + fontconfig. Aunque se pase el mismo archivo TTF, los algoritmos de hinting y antialiasing difieren y el resultado en píxeles no coincide.
- Disponibilidad de fuentes: tu macOS local tiene un buen surtido de fuentes de sistema instaladas, pero el contenedor Linux mínimo de CI (por ejemplo, el runner
ubuntu-latestde GitHub Actions o la imagen Docker de CI de Flutter) directamente no tiene las fuentes que no hayas especificado, así que la cadena de fallback cambia. - Diferencias de renderizador GPU/software: CI suele ser un entorno headless que usa un rasterizador por software, mientras que tu macOS local puede aprovechar una ruta de aceleración por hardware.
- Desajuste de versión del motor Flutter/Skia: si el SDK local y el SDK cacheado en CI difieren, el propio resultado de renderizado de Skia cambia.
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:
- El entorno de test de Flutter solo soporta un peso (.ttf) por familia de fuente, así que un widget que mezcla Bold y Regular puede verse ligeramente distinto respecto a la app real.
- Para usar iconos de Material, el pubspec debe tener
uses-material-design: true; de lo contrario, la fuente de iconos no se carga. - Textos internos del framework, como el banner de debug, pueden seguir dibujándose con Ahem, así que queda un área que no controlas por completo.
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:
- Especificar explícitamente
physicalSizeydevicePixelRatioevita que la configuración de pantalla de la máquina local o la resolución por defecto del runner de CI afecten al resultado. - Es imprescindible llamar a
addTearDown(tester.view.reset). Si no lo haces, los tests posteriores dentro del mismo archivo heredan el valor de DPR que dejó el test anterior, generando otro tipo de flakiness dependiente del orden de ejecución. - La escala de texto sigue el mismo principio: lo más seguro es fijarla mediante
tester.platformDispatcher.textScaleFactorTestValue, o envolver el widget en unMediaQueryy sobreescribirlo explícitamente con algo comotextScaler: TextScaler.noScaling.
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:
- Separar los golden tests de los tests unitarios normales en un job distinto. Los golden tests son sensibles al entorno de renderizado, así que conviene manejar de forma diferente la política de reintentos y la subida de artefactos en caso de fallo.
- Fijar con precisión la versión del SDK de Flutter y la imagen Docker (especificando
flutter --versiono usando una imagen Docker fijada por SHA). Cuando cambia la versión menor del SDK, el resultado de renderizado de Skia puede cambiar y no queda más remedio que regenerar todos los goldens, pero al menos se elimina la causa de “mi SDK local y el SDK de CI son distintos”. - Subir la imagen de diff como artefacto cuando el test falla, para que quien revisa no tenga que adivinar “cuántos píxeles cambiaron” leyendo solo el log, sino comparar las imágenes directamente con sus propios ojos.
- Regenerar siempre los goldens en el mismo entorno que usa CI (Docker). Si actualizas los goldens en tu macOS local con
flutter test --update-goldensy los commiteas tal cual, estás reproduciendo con tus propias manos el problema completo que describe este artículo.
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
- Etiquetando solo los golden tests con
--tags goldenreduces el tiempo de ejecución y acotas mejor la causa de un fallo (añade la anotación@Tags(['golden'])al principio del archivo de test). - Usa la caché de pub para acelerar la instalación de dependencias, pero no caches la propia imagen del SDK que afecta al resultado de renderizado: fija su versión de forma explícita. Si una imagen cacheada se actualiza a escondidas, aparece una nueva flakiness del tipo “el golden que funcionaba ayer se rompe hoy de repente”.
- Para reproducir en local exactamente las mismas condiciones que CI, lo más fiable es ejecutar en tu máquina la misma imagen Docker que usa CI y regenerar ahí los goldens. Aun así, como muestra el issue de GitHub mencionado antes, hay que tener en cuenta que si el SO host es distinto, ni siquiera la misma imagen garantiza exactamente los mismos píxeles. En ese caso, el último recurso es subir ligeramente el
diffThreshold.
Checklist si aun así sigue fallando
- Comprobar que
flutter_test_config.dartestá realmente dentro del alcance de directorios donde se ejecuta el test (es habitual que falte en algún paquete de un monorepo) - Verificar que la versión del SDK de Flutter con la que se generaron los goldens coincide exactamente con la versión del SDK de CI (comparar con
flutter --version) - Revisar que no falte
tester.view.reset()oaddTearDown, dejando que el DPR o la escala de texto de un test anterior se filtren al siguiente - Confirmar que el archivo de licencia de la fuente del pubspec está realmente commiteado en el repositorio y es accesible también desde CI (algunas fuentes comerciales solo están instaladas en la máquina del desarrollador local y no en el repo)
- Si usas alchemist, comprobar que está activada correctamente la configuración —
ciGoldensConfigoplatformGoldensConfig— que realmente quieres validar en CI
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.