effidevFlutter · Cloudflare 엣지 · 클라우드 비용 최적화

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

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

골든 테스트를 처음 도입한 팀은 거의 예외 없이 같은 순서로 좌절합니다. 로컬에서 flutter test를 돌리면 초록불이 뜨는데, 똑같은 커밋을 CI에 올리면 골든 테스트만 빨갛게 실패합니다. diff 이미지를 열어보면 픽셀이 몇 개 어긋난 수준이라 “그냥 재시도하면 통과하겠지” 하고 넘기기 시작하고, 결국 골든 테스트 잡 전체가 팀에서 신뢰를 잃습니다.

이 글은 “골든 테스트란 무엇인가” 수준의 입문 글이 아닙니다. 이미 골든 테스트를 도입했고, matchesGoldenFile이 로컬 macOS와 CI Linux 컨테이너 사이에서 왜 다른 결과를 내는지, 그리고 그걸 어떻게 구조적으로 고치는지를 다룹니다.

핵심 요약

왜 골든 테스트에서 유독 “내 컴퓨터에서는 되는데”가 자주 나올까

일반적인 단위 테스트는 입력과 출력이 논리 값이라 플랫폼이 달라도 결과가 같습니다. 하지만 골든 테스트는 위젯을 실제로 래스터화(rasterize)한 픽셀 이미지를 비교합니다. 즉 테스트 대상이 “로직이 맞는가”가 아니라 “그림이 똑같은가”이기 때문에, 그림을 그리는 엔진과 폰트, 화면 밀도가 조금만 달라도 실패합니다.

Flutter의 matchesGoldenFile은 기본적으로 정확한 바이트 단위 비교를 수행합니다. Flutter 공식 문서도 “커스텀 폰트는 플랫폼이나 Flutter 버전에 따라 다르게 렌더링될 수 있다”고 명시하고 있고, Windows에서 생성한 골든 파일은 다른 OS에서 거의 확실히 실패한다고 경고합니다. 즉 이건 버그가 아니라 문서화된 한계입니다.

근본 원인 1: 폰트가 다르다

Flutter 테스트 바인딩은 기본적으로 Ahem 폰트만 로드합니다. Ahem은 글자마다 검은 네모(정확히는 글리프 박스)를 그리는 테스트 전용 폰트로, 실제 폰트를 로드하지 않으면 텍스트가 있는 위젯의 골든 이미지는 전부 네모 칸으로 채워집니다.

여기서 팀들이 흔히 하는 실수는 다음 둘 중 하나입니다.

두 경우 모두 “로컬에서는 되는데 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 리눅스 컨테이너가 구조적으로 다른 그림을 그리는 이유

정리하면 다음과 같은 구조적 차이가 쌓입니다.

이 네 가지가 겹치면 “코드는 안 바뀌었는데 골든만 깨지는” 전형적인 플레이키니스가 만들어집니다.

해결 레시피 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: 섹션과 의존하는 패키지들의 폰트를 자동으로 읽어 테스트 바인딩에 주입합니다. 다만 몇 가지 함정이 있습니다.

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'),
  );
});

핵심 포인트는 다음과 같습니다.

이 패턴을 모든 골든 테스트에 반복하는 대신, 공통 헬퍼 함수(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 파이프라인 설계 자체가 허술하면 플레이키니스는 재발합니다. 다음 원칙을 권장합니다.

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

그래도 실패한다면 확인할 체크리스트

마무리

골든 테스트의 플레이키니스는 대부분 “테스트 코드가 틀려서”가 아니라 테스트가 실행되는 렌더링 환경이 통제되지 않아서 발생합니다. 폰트를 번들링해 렌더러 차이를 제거하고, DPR·텍스트 스케일을 코드에서 명시적으로 고정하고, 그래도 남는 미세한 차이는 허용 오차 기반 diff로 흡수하고, 마지막으로 CI 파이프라인 자체를 재현 가능하게 설계하는 것. 이 네 단계를 순서대로 밟으면 “내 컴퓨터에서는 되는데 CI에서만 깨지는” 골든 테스트는 대부분 사라집니다.