골든 테스트가 내 macOS에서는 통과하는데 CI 리눅스에서는 계속 깨지는 이유

골든 테스트를 처음 도입한 팀은 거의 예외 없이 같은 순서로 좌절합니다. 로컬에서 flutter test를 돌리면 초록불이 뜨는데, 똑같은 커밋을 CI에 올리면 골든 테스트만 빨갛게 실패합니다. diff 이미지를 열어보면 픽셀이 몇 개 어긋난 수준이라 “그냥 재시도하면 통과하겠지” 하고 넘기기 시작하고, 결국 골든 테스트 잡 전체가 팀에서 신뢰를 잃습니다.
이 글은 “골든 테스트란 무엇인가” 수준의 입문 글이 아닙니다. 이미 골든 테스트를 도입했고, matchesGoldenFile이 로컬 macOS와 CI Linux 컨테이너 사이에서 왜 다른 결과를 내는지, 그리고 그걸 어떻게 구조적으로 고치는지를 다룹니다.
핵심 요약
- 골든 테스트 플레이키니스의 근본 원인은 거의 항상 세 가지 중 하나입니다: 폰트, DPR(디바이스 픽셀 비율), 플랫폼별 래스터라이저. 코드 로직 문제가 아니라 렌더링 환경 문제입니다.
- 로컬 macOS는 CoreText, CI의 Linux 컨테이너는 FreeType/fontconfig로 텍스트를 그립니다. 같은 폰트 파일이어도 안티앨리어싱과 힌팅 알고리즘이 달라 픽셀 단위로 다른 이미지가 나옵니다.
- 해결책은 크게 세 가지입니다. (1) 테스트 환경에 폰트를 번들링해 렌더러 의존성을 없애고, (2) DPR·텍스트 스케일을 테스트 코드에서 강제로 고정하고, (3) 정확히 일치하는 바이트 비교 대신 허용 오차 기반 diff를 쓰는 것.
- CI 설계 관점에서는 골든 테스트를 별도 잡으로 분리하고, 렌더링 환경(Docker 이미지)을 버전 고정하고, 실패 시 diff 이미지를 아티팩트로 남기는 파이프라인이 필요합니다.
왜 골든 테스트에서 유독 “내 컴퓨터에서는 되는데”가 자주 나올까
일반적인 단위 테스트는 입력과 출력이 논리 값이라 플랫폼이 달라도 결과가 같습니다. 하지만 골든 테스트는 위젯을 실제로 래스터화(rasterize)한 픽셀 이미지를 비교합니다. 즉 테스트 대상이 “로직이 맞는가”가 아니라 “그림이 똑같은가”이기 때문에, 그림을 그리는 엔진과 폰트, 화면 밀도가 조금만 달라도 실패합니다.
Flutter의 matchesGoldenFile은 기본적으로 정확한 바이트 단위 비교를 수행합니다. Flutter 공식 문서도 “커스텀 폰트는 플랫폼이나 Flutter 버전에 따라 다르게 렌더링될 수 있다”고 명시하고 있고, Windows에서 생성한 골든 파일은 다른 OS에서 거의 확실히 실패한다고 경고합니다. 즉 이건 버그가 아니라 문서화된 한계입니다.
근본 원인 1: 폰트가 다르다
Flutter 테스트 바인딩은 기본적으로 Ahem 폰트만 로드합니다. Ahem은 글자마다 검은 네모(정확히는 글리프 박스)를 그리는 테스트 전용 폰트로, 실제 폰트를 로드하지 않으면 텍스트가 있는 위젯의 골든 이미지는 전부 네모 칸으로 채워집니다.
여기서 팀들이 흔히 하는 실수는 다음 둘 중 하나입니다.
- 로컬에서만 시스템 폰트(예: macOS의 산돌고딕, San Francisco)가 우연히 깔려 있어서 그럴듯한 골든 이미지가 나오고, 그걸 그대로 커밋해버린다.
- 앱 폰트를 로드하긴 했는데, CI 컨테이너에 해당 폰트 파일이 없거나 라이선스 문제로 번들링이 안 되어 있어서 fallback 폰트로 그려진다.
두 경우 모두 “로컬에서는 되는데 CI에서는 안 되는” 정확히 그 증상을 만듭니다.
근본 원인 2: DPR(디바이스 픽셀 비율)과 텍스트 스케일이 다르다
WidgetTester가 그리는 골든 이미지의 실제 픽셀 크기는 논리 픽셀(logical pixel) × DPR로 결정됩니다. 로컬 macOS 개발 머신에서 Retina 디스플레이 설정이나 IDE의 기본 테스트 러너 설정이 CI의 기본값과 다르면, 똑같은 위젯 트리를 그려도 이미지 해상도 자체가 달라져 diff가 발생합니다. 텍스트 스케일 팩터(textScaleFactor)도 마찬가지로, 시스템 접근성 설정이나 테스트 환경 초기값이 미세하게 다르면 줄바꿈 위치가 바뀌어 이미지가 완전히 달라질 수 있습니다.
근본 원인 3: 플랫폼별 래스터라이저 차이
폰트와 DPR을 완벽히 고정해도 실패하는 경우가 있습니다. 이는 텍스트 안티앨리어싱, 서브픽셀 렌더링, 힌팅 알고리즘이 렌더링 백엔드마다 다르기 때문입니다. Flutter GitHub 이슈 트래커에도 “동일한 Docker 이미지를 써도 호스트 플랫폼(Windows Docker vs macOS Docker)에 따라 골든 테스트 픽셀이 1개에서 90개까지 달라진다”는 보고가 있을 정도로, 이 문제는 Flutter 엔진의 텍스트 셰이핑·래스터화 스택 깊숙한 곳에서 발생합니다. 즉 Docker로 컨테이너를 고정하는 것만으로는 100% 해결되지 않을 수 있다는 점을 인정하고 접근해야 합니다.
로컬 macOS와 CI 리눅스 컨테이너가 구조적으로 다른 그림을 그리는 이유
정리하면 다음과 같은 구조적 차이가 쌓입니다.
- 텍스트 렌더링 스택: macOS는 CoreText, Linux는 대부분 FreeType + fontconfig 조합을 사용합니다. 같은 TTF 파일을 넘겨도 힌팅과 안티앨리어싱 알고리즘이 달라 픽셀 값이 다르게 나옵니다.
- 폰트 가용성: 로컬 macOS에는 시스템 폰트가 풍부하게 깔려 있지만, CI의 최소 Linux 컨테이너(예:
ubuntu-latestGitHub Actions 러너나 Flutter CI Docker 이미지)에는 지정하지 않은 폰트가 아예 없어서 fallback 체인이 달라집니다. - GPU/소프트웨어 렌더러 차이: CI는 대부분 헤드리스 환경이라 소프트웨어 래스터라이저를 쓰는 반면, 로컬 macOS는 하드웨어 가속 경로를 탈 수 있습니다.
- Flutter/Skia 엔진 버전 불일치: 로컬 SDK와 CI에 캐시된 SDK 버전이 다르면 Skia 자체의 렌더링 결과가 바뀝니다.
이 네 가지가 겹치면 “코드는 안 바뀌었는데 골든만 깨지는” 전형적인 플레이키니스가 만들어집니다.
해결 레시피 1: 테스트 환경에 폰트를 번들링해 고정한다
가장 먼저 해야 할 일은 테스트가 어떤 환경에서 실행되든 항상 같은 폰트로 그려지도록 강제하는 것입니다.
flutter_test_config.dart로 앱 폰트 로드하기
Flutter 테스트 프레임워크는 테스트 파일이 위치한 디렉터리부터 상위로 올라가며 flutter_test_config.dart를 찾아 실행 전에 적용합니다. 여기서 폰트를 로드하면 모든 테스트에 일괄 적용됩니다.
// 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(); // Roboto + pubspec에 등록된 커스텀 폰트를 로드
return testMain();
}
loadAppFonts()는 pubspec.yaml에 등록된 fonts: 섹션과 의존하는 패키지들의 폰트를 자동으로 읽어 테스트 바인딩에 주입합니다. 다만 몇 가지 함정이 있습니다.
- Flutter 테스트 환경은 폰트 패밀리당 하나의 굵기(.ttf)만 지원하므로, Bold/Regular를 섞어 쓰는 위젯은 실제 앱과 미묘하게 다르게 보일 수 있습니다.
- Material 아이콘을 쓰려면 pubspec에
uses-material-design: true가 반드시 있어야 아이콘 폰트가 로드됩니다. - 디버그 배너 같은 프레임워크 내부 텍스트는 여전히 Ahem으로 그려질 수 있어 완전히 통제되지 않는 영역이 남습니다.
Ahem을 오히려 적극적으로 활용하기
역설적으로 “실제 폰트로 예쁘게 렌더링”을 포기하고 Ahem을 CI 전용 검증 수단으로 쓰는 전략도 있습니다. 텍스트 내용이 아니라 레이아웃(정렬, 크기, 줄바꿈 여부)만 검증하고 싶다면, 글리프 모양이 플랫폼마다 달라질 위험이 있는 실제 폰트보다 항상 같은 네모를 그리는 Ahem이 오히려 더 안정적입니다. 뒤에서 다룰 alchemist가 이 전략을 “CI 골든”이라는 개념으로 공식 지원합니다.
golden_bricks: Ahem과 실폰트의 중간 지점
Ahem의 문제는 모든 글자가 똑같은 네모라서 캐럿 위치, 텍스트 선택, 줄바꿈처럼 글자 폭이 실제로 달라야 검증되는 케이스를 테스트할 수 없다는 점입니다. golden_bricks는 이 틈을 메우는 패키지로, 글자마다 크기가 다른 사각형을 그려 “네모인데 폭은 실제 텍스트처럼 다르게” 렌더링합니다.
# pubspec.yaml (dev_dependencies)
golden_bricks: ^1.0.0
MaterialApp(
theme: ThemeData(fontFamily: goldenBricks),
home: const MyWidget(),
)
플랫폼 의존성이 있는 실제 폰트 대신 이런 결정론적(deterministic) 폰트를 표준으로 채택하면, 애초에 CoreText/FreeType 차이가 결과에 영향을 줄 여지 자체를 없앨 수 있습니다.
해결 레시피 2: DPR과 텍스트 스케일을 테스트 코드에서 강제로 고정한다
WidgetTester가 제공하는 TestFlutterView(tester.view)를 통해 화면 밀도와 크기를 명시적으로 고정할 수 있습니다. 이 값을 지정하지 않으면 테스트를 실행하는 머신의 기본값에 은근히 의존하게 됩니다.
testWidgets('상품 카드 골든 테스트', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3.0;
// 테스트가 끝나면 다음 테스트에 영향이 없도록 반드시 리셋
addTearDown(tester.view.reset);
await tester.pumpWidget(const MyApp(home: ProductCard()));
await tester.pumpAndSettle();
await expectLater(
find.byType(ProductCard),
matchesGoldenFile('goldens/product_card.png'),
);
});
핵심 포인트는 다음과 같습니다.
physicalSize와devicePixelRatio를 명시적으로 지정하면, 로컬 머신의 디스플레이 설정이나 CI 러너의 기본 해상도가 결과에 영향을 주지 않습니다.addTearDown(tester.view.reset)을 꼭 호출해야 합니다. 그렇지 않으면 한 테스트 파일 안의 뒤에 오는 테스트가 이전 테스트가 남긴 DPR 값을 그대로 물려받는, 실행 순서에 의존하는 또 다른 플레이키니스가 생깁니다.- 텍스트 스케일도 같은 원리로
tester.platformDispatcher.textScaleFactorTestValue를 통해 고정하거나,MediaQuery를 감싸textScaler: TextScaler.noScaling처럼 명시적으로 오버라이드하는 것이 안전합니다.
이 패턴을 모든 골든 테스트에 반복하는 대신, 공통 헬퍼 함수(pumpGolden 같은 이름)로 감싸서 팀 전체가 같은 기준값을 쓰도록 강제하는 것을 권장합니다. 기준값이 파일마다 다르면 “이 파일은 DPR 2.0인데 저 파일은 3.0”처럼 또 다른 불일치가 생깁니다.
해결 레시피 3: 허용 오차 기반 diff 도구를 쓴다
폰트와 DPR을 다 고정해도, 앞서 언급한 래스터라이저 차이 때문에 완전히 동일한 바이트가 나오지 않는 경우가 현실적으로 남습니다. 이때 정확한 바이트 비교(exact match) 대신 허용 오차(tolerance) 기반 비교로 전환하는 것이 실용적인 선택입니다.
golden_toolkit vs alchemist 비교
| 항목 | golden_toolkit | alchemist |
|---|---|---|
| 유지보수 상태 | discontinued (pub.dev 기준, 최신 버전 0.15.0, 수 년째 미갱신) | 활발히 유지보수 중 (Very Good Ventures + Betterment, 최신 0.14.0) |
| 폰트 로딩 | loadAppFonts() 제공 |
flutter_test_config.dart에서 동일 패턴 지원 |
| 픽셀 허용 오차 | 기본 제공 없음 (커스텀 comparator 직접 구현 필요) | diffThreshold 파라미터로 0.0~1.0 사이 허용 오차 비율 지정 가능 |
| 플랫폼별/CI별 골든 분리 | 지원 안 함 (직접 skip 로직 작성) | platform 골든과 ci 골든을 폴더로 자동 분리 — CI 골든은 Ahem 기반이라 플랫폼 영향을 받지 않음 |
| 여러 화면 크기 동시 테스트 | DeviceBuilder, multiScreenGolden() |
GoldenTestGroup + GoldenTestScenario로 시나리오 그룹핑 |
신규 프로젝트라면 golden_toolkit보다 alchemist를 우선 검토하는 것이 합리적입니다. golden_toolkit은 pub.dev에 discontinued로 표시되어 있고, alchemist는 이 글이 다루는 문제(플랫폼 간 렌더링 차이)를 정면으로 겨냥해 설계되었기 때문입니다.
alchemist의 diffThreshold 사용 예
void main() {
setUpAll(() {
AlchemistConfig.current = AlchemistConfig(
platformGoldensConfig: const PlatformGoldensConfig(
enabled: true,
),
ciGoldensConfig: const CiGoldensConfig(
enabled: true,
),
);
});
goldenTest(
'상품 카드',
fileName: 'product_card',
pixelDiffConfig: const GoldenTestPixelDiffConfig(threshold: 0.01),
widget: const GoldenTestGroup(
children: [ProductCard()],
),
);
}
threshold: 0.01은 전체 픽셀 중 1% 미만의 차이는 실패로 처리하지 않겠다는 의미입니다. 이 값을 너무 크게 잡으면 실제 UI 회귀(regression)도 통과시켜버리는 함정이 있으니, 처음엔 0.005~0.01 사이의 작은 값에서 시작해 팀의 실제 플레이키니스 빈도를 보며 조정하는 것을 권장합니다.
커스텀 comparator라는 선택지
패키지를 추가하고 싶지 않다면, LocalFileComparator를 상속해 compare() 메서드에서 픽셀 차이 비율을 직접 계산하는 커스텀 comparator를 flutter_test_config.dart에 등록하는 방법도 있습니다. 다만 이 경로는 이미지 디코딩, 리사이즈, 안티앨리어싱 경계 처리를 직접 구현해야 하므로, 팀 규모가 작다면 alchemist 도입이 훨씬 비용 효율적입니다.
해결 레시피 4: CI 파이프라인에서 골든 테스트를 별도 잡으로 분리한다
폰트·DPR·허용 오차를 다 잡아도, CI 파이프라인 설계 자체가 허술하면 플레이키니스는 재발합니다. 다음 원칙을 권장합니다.
- 골든 테스트를 일반 단위 테스트와 다른 job으로 분리합니다. 골든 테스트는 렌더링 환경에 민감하므로, 실패 시 재시도 정책이나 아티팩트 업로드 설정을 단위 테스트와 다르게 가져가야 합니다.
- Flutter SDK 버전과 Docker 이미지를 정확히 고정합니다(
flutter --version을 명시하거나 SHA 고정된 Docker 이미지 사용). SDK 마이너 버전이 바뀌면 Skia 렌더링 결과가 바뀔 수 있어 골든을 전부 다시 생성해야 하는 상황을 피할 수 없지만, 최소한 “내 로컬 SDK와 CI SDK가 다르다”는 원인은 제거할 수 있습니다. - 실패 시 diff 이미지를 아티팩트로 업로드해서, 리뷰어가 로그만 보고 “몇 픽셀 차이인지” 추측하지 않고 실제 이미지를 눈으로 비교할 수 있게 합니다.
- 골든 갱신은 반드시 CI와 같은 환경(Docker)에서 생성합니다. 로컬 macOS에서
flutter test --update-goldens로 갱신한 파일을 그대로 커밋하면, 이 글 전체에서 설명한 문제를 스스로 재생산하는 것과 같습니다.
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 # SDK 버전을 고정한 커스텀 이미지
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
--tags golden으로 골든 테스트만 별도 태깅해 실행 시간을 줄이고, 실패 원인을 좁힙니다(@Tags(['golden'])애너테이션을 테스트 파일 상단에 붙입니다).- pub 캐시를 키로 잡아 의존성 설치 시간을 줄이되, 렌더링 결과에 영향을 주는 SDK 이미지 자체는 캐시하지 말고 명시적으로 버전 고정합니다. 캐시된 이미지가 몰래 갱신되면 “어제까지 되던 골든이 오늘 갑자기 깨지는” 새로운 플레이키니스가 생깁니다.
- 로컬에서 CI와 동일한 조건으로 재현하려면, CI에서 쓰는 것과 같은 Docker 이미지를 로컬에서 그대로 실행해 골든을 갱신하는 방식이 가장 신뢰도가 높습니다. 단, 앞서 언급한 GitHub 이슈처럼 호스트 OS가 다르면 동일 이미지에서도 완전히 같은 픽셀이 보장되지 않을 수 있다는 점은 감안해야 합니다. 이 경우 최후의 수단은 diffThreshold를 살짝 올리는 것입니다.
그래도 실패한다면 확인할 체크리스트
flutter_test_config.dart가 실제로 실행되는 디렉터리 범위에 있는지 (모노레포에서 패키지별로 누락되는 경우가 흔합니다)- 골든 파일을 생성한 Flutter SDK 버전과 CI의 SDK 버전이 정확히 일치하는지 (
flutter --version비교) tester.view.reset()또는addTearDown을 빠뜨려 이전 테스트의 DPR/텍스트 스케일이 새어 들어오지 않는지- pubspec의 폰트 라이선스 파일이 실제로 리포지토리에 커밋되어 CI에서도 접근 가능한지 (일부 상업용 폰트는 로컬 개발자 머신에만 설치되어 있고 리포에는 없는 경우가 있습니다)
- alchemist를 쓴다면
ciGoldensConfig와platformGoldensConfig중 CI에서 실제로 검증하려는 쪽이 맞게 활성화되어 있는지
마무리
골든 테스트의 플레이키니스는 대부분 “테스트 코드가 틀려서”가 아니라 테스트가 실행되는 렌더링 환경이 통제되지 않아서 발생합니다. 폰트를 번들링해 렌더러 차이를 제거하고, DPR·텍스트 스케일을 코드에서 명시적으로 고정하고, 그래도 남는 미세한 차이는 허용 오차 기반 diff로 흡수하고, 마지막으로 CI 파이프라인 자체를 재현 가능하게 설계하는 것. 이 네 단계를 순서대로 밟으면 “내 컴퓨터에서는 되는데 CI에서만 깨지는” 골든 테스트는 대부분 사라집니다.