訂正(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-goldensとflutter test widget_golden_test.dartが完了しない。 - コンソールにはエラーは出でない。
- アセットパスの設定(
pubspec.yamlのassets:)は正しく、PNGの表示は問題ない。
原因調査
flutter test widget_golden_test.dart実行時デバッガーを止めながら確認するとprecacheImageのawait Future.wait(images);で待ち続けている。- Alchemistの
precacheImages実装を確認。
pumpBeforeTest: precacheImagesがデフォルトで使われ、Image/FadeInImage/DecoratedBoxの画像をまとめてprecacheImageしている。await Future.wait(images);で読み込み完了を待つため、GIFのアニメーションが続く限り処理が進まないみたい。
- テスト実行時検証。
- 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でのテストも成功。- フレームを指定してのテスト成功
参考
- サンプルGitリポジトリ:
github.com/kabikira/golden_test_sample/tree/feature/fix_image - その前に調査したGIF→PDF変換のサンプル(imageパッケージ使用/不使用の両方を記載)
https://github.com/kabikira/gif_convert
ヒント得たページ
- GIFをフレームごとに抽出
https://stackoverflow.com/questions/74924357/in-flutter-how-to-extract-frame-by-frame-from-an-animated-gif - asUint8ListとかImage.memoryの説明
https://qiita.com/ling350181/items/916ab3174c3e0dfadb00 - ImageとImageProvider
https://zenn.dev/koji_1009/articles/97a3eab8e0f7fb - Alchemist で Golden Test の導入参照
https://zenn.dev/greendrop/articles/2024-09-29-a1fa614645ba96 - DefaultAssetBundleリファレンス
https://api.flutter.dev/flutter/widgets/DefaultAssetBundle-class.html
変更履歴
- 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));
}![[Flutter] Golden Test でGIFのフレームを指定してPNGに変換してテスト可能にする](https://kabikira.com/wp-content/uploads/2025/10/flutter.png)