タグ: GoldenTest

  • [Flutter] Golden Test でGIFのフレームを指定してPNGに変換してテスト可能にする

    [Flutter] Golden Test でGIFのフレームを指定してPNGに変換してテスト可能にする

    訂正(2025-10-08)
    当初は precacheImages をカスタムして GIF を PNG に変換していましたが、
    テスト時に AssetBundle を差し替える方法(下記コード)に修正しました。
    理由:GIFのフレームを進めてもゴールデンテストで生成される画像には最初のフレームしか反映されていなかったため

    FlutterのゴールデンテストでGIFを含む画面のとき、テストが終了しなくなる問題に遭遇しました。
    GIFのフレームを指定してPNGに変換して解決したのでまとめます。

    環境

    • Flutter 3.35.5, on macOS 15.4
    • alchemistパッケージを使用しGoldenTest実施

    サンプルリポジトリ:
    https://github.com/kabikira/golden_test_sample/tree/feature/add_find

    ビルドするとPNG(上)とGIF(下)が表示されるプロジェクト

    問題の発端

    • GIF画像の Image.asset('assets/images/Tesseract.gif') を含むViewをAlchemistでゴールデンテストすると、flutter test widget_golden_test.dart --update-goldensflutter test widget_golden_test.dart が完了しない。
    • コンソールにはエラーは出でない。
    • アセットパスの設定(pubspec.yamlassets:)は正しく、PNGの表示は問題ない。

    原因調査

    1. flutter test widget_golden_test.dart 実行時デバッガーを止めながら確認すると precacheImageawait Future.wait(images); で待ち続けている。
    2. Alchemistの precacheImages 実装を確認。
    • pumpBeforeTest: precacheImages がデフォルトで使われ、Image / FadeInImage / DecoratedBox の画像をまとめて precacheImage している。
    • await Future.wait(images); で読み込み完了を待つため、GIFのアニメーションが続く限り処理が進まないみたい。
    1. テスト実行時検証。
    • GIFを含まない、画像生成とテストは即終了する。
    • GIFがある場合のみ Future.wait が解決しないため10分経つとタイムアウト(command timed out)が発生。

    解決策(正しい実装):テスト時に AssetBundle を差し替えて GIF→PNG 化

    AssetBundle ラッパー

    import 'dart:typed_data';
    import 'package:flutter/services.dart';
    import 'package:image/image.dart' as img;
    
    /// GIFアセットを単一フレームPNGへ変換して返すAssetBundleラッパー。
    class TestGifToPngAssetBundle extends CachingAssetBundle {
      TestGifToPngAssetBundle(this._parent);
    
      final AssetBundle _parent;
    
      @override
      Future<ByteData> load(String key) async {
        // GIF以外はそのまま親に委譲
        if (!key.toLowerCase().endsWith('.gif')) {
          return _parent.load(key);
        }
    
        // GIFの最初のフレームを指定して変換
        final gifData = await _parent.load(key);
        final frame = img.decodeGif(gifData.buffer.asUint8List(), frame: 3);
    
        if (frame == null) {
          return gifData;
        }
    
        final pngBytes = Uint8List.fromList(img.encodePng(frame));
        return ByteData.sublistView(pngBytes);
      }
    }
    

    ゴールデンテストで差し替える

    import 'package:alchemist/alchemist.dart';
    import 'package:flutter/material.dart';
    import 'package:flutter/services.dart';
    import 'package:flutter_test/flutter_test.dart';
    import 'package:golden_test_sample/main.dart';
    
    import 'support/alchemist/golden_test_device_scenario.dart';
    import 'support/test_gif_to_png_asset_bundle.dart';
    
    void main() {
      group('MyApp Golden Test', () {
        Widget buildMyApp() {
          return const MainApp();
        }
    
        final device = Device.phonePortrait;
    
        goldenTest(
          'Default',
          fileName: 'my_app_default',
          pumpBeforeTest: precacheImages,
          builder: () {
            final children = <Widget>[];
    
            children.add(
              GoldenTestDeviceScenario(
                name: device.name,
                device: device,
                builder: () => buildMyApp(),
              ),
            );
    
            return DefaultAssetBundle(
              bundle: TestGifToPngAssetBundle(rootBundle),
              child: GoldenTestGroup(children: children),
            );
          },
        );
      });
    }
    

    これで 画像読み込み段階指定したフレームで GIF → PNG 化され、
    precacheImage / Future.wait の待機問題に依存せずテストが完了します。

    fram指定0のとき

    fram指定3のとき


    検証

    • flutter test test/widget_golden_test.dart --update-goldens を実行すると、GIFを含む画面も画像生成完了。
    • CIゴールデン(test/goldens/ci/my_app_default.png)とmacOSゴールデン(test/goldens/macos/my_app_default.png)が生成され、差分も期待通り。
    • flutter test test/widget_golden_test.dart でのテストも成功。
    • フレームを指定してのテスト成功


    参考

    ヒント得たページ


    変更履歴

    • 2025-10-08:テスト時の GIF → PNG 変換を AssetBundle ラッパー方式に訂正。
    • 2025-10-05:リンクと例を更新。

    学習ログ(旧アプローチ:precacheImages をカスタムする方式)

    クリックして展開(旧実装の考え方とコード断片)

    当初は pumpBeforeTest: precacheImages をカスタムして、precacheImage に渡していました。

    Future<void> customPrecacheImages(WidgetTester tester) async {
      await tester.runAsync(() async {
        final images = <Future<void>>[];
        for (final element in find.byType(Image).evaluate()) {
          final widget = element.widget as Image;
          images.add(_precacheWithGifFrame(widget.image, element));
        }
        for (final element in find.byType(FadeInImage).evaluate()) {
          final widget = element.widget as FadeInImage;
          images.add(_precacheWithGifFrame(widget.image, element));
        }
        for (final element in find.byType(DecoratedBox).evaluate()) {
          final widget = element.widget as DecoratedBox;
          final decoration = widget.decoration;
          if (decoration is BoxDecoration && decoration.image != null) {
            images.add(_precacheWithGifFrame(decoration.image!.image, element));
          }
        }
        await Future.wait(images);
      });
      await tester.pumpAndSettle();
    }
    
    Future<void> _precacheWithGifFrame(
      ImageProvider<Object> provider,
      Element element,
    ) async {
      if (provider is AssetImage &&
          provider.assetName.toLowerCase().endsWith('.gif')) {
        final configuration = createLocalImageConfiguration(element);
        final key = await provider.obtainKey(configuration);
        final pngBytes = await _gifFrameToPng(key, frameIndex: 0);
        final memoryImage = MemoryImage(pngBytes, scale: key.scale);
        await precacheImage(memoryImage, element);
        return;
      }
      await precacheImage(provider, element);
    }
    
    Future<Uint8List> _gifFrameToPng(
      AssetBundleImageKey key, {
      required int frameIndex,
    }) async {
      final bundle = key.bundle;
      final data = await bundle.load(key.name);
      final frame = img.decodeGif(data.buffer.asUint8List(), frame: frameIndex);
      if (frame == null) {
        throw StateError('フレームを取得できませんでした: ${key.name} (frame: $frameIndex)');
      }
      return Uint8List.fromList(img.encodePng(frame, singleFrame: true));
    }