🚀

Image.networkとCachedNetworkImageの違いとtest時のポイント

に公開

概要

Flutter開発において、ネットワーク画像の表示は頻繁に使用される機能です。しかし、本番環境ではCachedNetworkImageを使用したいがテスト環境では問題が発生することがあります。今回は、これらの違いと、テスト時に発生する課題とその解決方法について解説します。

技術スタック

  • Flutter 3.19.5+
  • cached_network_image 3.3.1
  • flutter_test(標準テストフレームワーク)
  • golden_toolkit 0.15.0

Image.networkとCachedNetworkImageの違い

機能比較表

機能 Image.network CachedNetworkImage
パッケージ Flutter標準 cached_network_image
キャッシュ機能 ❌ なし ✅ あり(永続化対応)
プレースホルダー ❌ なし ✅ あり
プログレス表示 ❌ なし ✅ あり
エラーハンドリング ✅ 基本的 ✅ 詳細
カスタムキャッシュ ❌ なし ✅ 対応
オフライン対応 ❌ なし ✅ キャッシュから表示
メモリ使用量 少ない 多い
初期表示速度 速い(シンプル) やや遅い(機能が多い)
テストの容易さ ✅ 簡単 ❌ 困難

Image.network

Flutterの標準的なネットワーク画像ウィジェットです。

Image.network(
  'https://example.com/image.jpg',
  fit: BoxFit.cover,
  errorBuilder: (context, error, stackTrace) {
    return Icon(Icons.error);
  },
)

CachedNetworkImage

サードパーティパッケージによる高機能なネットワーク画像ウィジェットです。

CachedNetworkImage(
  imageUrl: 'https://example.com/image.jpg',
  fit: BoxFit.cover,
  placeholder: (context, url) => CircularProgressIndicator(),
  errorWidget: (context, url, error) => Icon(Icons.error),
  cacheManager: customCacheManager,
)

使い分けの指針

ユースケース 推奨 理由
一時的な画像表示 Image.network キャッシュ不要でシンプル
頻繁に表示される画像 CachedNetworkImage キャッシュによる高速化
大量の画像リスト CachedNetworkImage メモリ効率とパフォーマンス
オフライン対応必須 CachedNetworkImage キャッシュからの表示
テスト環境 Image.network モック化が容易
プロトタイプ開発 Image.network 実装がシンプル

実装内容

1. テスト環境判定ユーティリティ

// lib/common/test_environment.dart
import 'dart:io';

bool get isTest => Platform.environment.containsKey('FLUTTER_TEST');

2. 条件分岐による画像ウィジェット選択

import 'package:starboost_influencer/common/test_environment.dart';

class SamplePage extends HookConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    // ...
    
    child: isTest
        ? Image.network(
            banner.imageUrl,
            fit: BoxFit.fitWidth,
            errorBuilder: (context, error, stackTrace) =>
                Assets.images.topHead.image(fit: BoxFit.fitWidth),
          )
        : CachedNetworkImage(
            imageUrl: banner.imageUrl,
            fit: BoxFit.fitWidth,
            errorWidget: (context, url, dynamic error) =>
                Assets.images.topHead.image(fit: BoxFit.fitWidth),
            cacheManager: cacheManagerService.getCacheManager(),
          ),
  }
}

3. コンポーネントでの適用

class SampleView extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    return Container(
      child: isTest
          ? Image.network(
              state.imageUrl,
              fit: BoxFit.cover,
              alignment: Alignment.center,
              errorBuilder: (context, error, stackTrace) =>
                  Assets.images.sample.image(),
            )
          : CachedNetworkImage(
              imageUrl: state.imageUrl,
              fit: BoxFit.cover,
              alignment: Alignment.center,
              placeholder: (context, url) =>
                  Assets.images.sample.image(),
            ),
    );
  }
}

4. テストコードでの活用

import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';

void main() {
  group('CampaignSearchPage', () {
    testGoldens('should render correctly', (tester) async {
      await mockNetworkImagesFor(() async {
        final builder = DeviceBuilder()
          ..overrideDevicesForAllScenarios(devices: [TestDevice.iPhone15])
          ..addScenario(
            name: 'logged_in',
            widget: targetWidget(userState: createTestUserState()),
          );

        await tester.pumpDeviceBuilder(builder);
        await tester.pumpAndSettle(const Duration(milliseconds: 1000));

        await screenMatchesGolden(tester, 'CampaignSearchPage');
      });
    });
  });
}

学んだこと

意外だった落とし穴

  1. CachedNetworkImageのモック化の困難さ

    • 内部実装が複雑で、通常のモック手法では対応困難
    • ネットワーク処理とキャッシュ処理の分離が必要
    • 実は完全に不可能ではない: Mediumの記事ではGetItを使用したDIによるモック化の手法が紹介されている
    • Riverpodを使用した同様のアプローチを試みたが、CacheManagerの依存関係が複雑で上手く実装できなかった
    // 試みたRiverpodでのアプローチ(未完成)
    final cacheManagerProvider = Provider<BaseCacheManager>((ref) {
      return isTest ? MockCacheManager() : DefaultCacheManager();
    });
    
    // CachedNetworkImageの内部でcacheManagerを
    // 直接参照できないため、この方法は機能しない
    
  2. テスト環境の判定方法

    • Platform.environmentを使用した環境判定
    • kIsWebやkDebugModeでは不十分
  3. Golden Testでの画像処理

    • mockNetworkImagesForの使用が必須
    • ネットワーク画像の読み込み完了を待機する必要

今後使えそうな知見

  1. 環境別ウィジェット選択パターン

    Widget buildImageWidget({
      required String imageUrl,
      required BoxFit fit,
      Widget? errorWidget,
      Widget? placeholder,
    }) {
      return isTest
          ? Image.network(
              imageUrl,
              fit: fit,
              errorBuilder: (context, error, stackTrace) => errorWidget ?? Container(),
            )
          : CachedNetworkImage(
              imageUrl: imageUrl,
              fit: fit,
              placeholder: (context, url) => placeholder ?? Container(),
              errorWidget: (context, url, error) => errorWidget ?? Container(),
            );
    }
    
  2. テストヘルパーの活用

    extension NetworkImageTestExtension on WidgetTester {
      Future<void> pumpWithNetworkImages(Widget widget) async {
        await mockNetworkImagesFor(() async {
          await pumpWidget(widget);
          await pumpAndSettle();
        });
      }
    }
    
  3. キャッシュマネージャーの分離

    class TestCacheManager extends CacheManager {
      static const key = 'testCache';
      
      TestCacheManager() : super(Config(
        key,
        stalePeriod: const Duration(days: 7),
        maxNrOfCacheObjects: 100,
      ));
    }
    

CachedNetworkImageのテストに関する補足

実は、CachedNetworkImageのモック化は完全に不可能というわけではありません。参考記事で紹介されている手法を試みましたが、プロジェクトの構成上、完全な実装には至りませんでした。

  1. GetItを使用したDIアプローチ(Medium記事より)

    • CacheManagerをDIコンテナで管理
    • テスト時にモックを注入
    • 完全なモック化が可能
  2. Zennの記事で紹介されている手法

    • HttpOverridesを使用したHTTPレスポンスのモック
    • 実際のCachedNetworkImageを使用したテスト
    • より実装に近い形でのテストが可能

これらの手法は有効ですが、既存のプロジェクト構成や時間的制約から、今回は環境による条件分岐というシンプルな解決策を採用しました。

もっと良い書き方の発見

  1. Factory Patternの活用

    class NetworkImageFactory {
      static Widget create({
        required String imageUrl,
        BoxFit? fit,
        Widget? errorWidget,
        Widget? placeholder,
      }) {
        return isTest
            ? _createImageNetwork(imageUrl, fit, errorWidget)
            : _createCachedNetworkImage(imageUrl, fit, errorWidget, placeholder);
      }
    }
    
  2. Builder Patternの活用

    class NetworkImageBuilder {
      String? _imageUrl;
      BoxFit? _fit;
      Widget? _errorWidget;
      Widget? _placeholder;
      
      NetworkImageBuilder imageUrl(String url) {
        _imageUrl = url;
        return this;
      }
      
      NetworkImageBuilder fit(BoxFit fit) {
        _fit = fit;
        return this;
      }
      
      Widget build() {
        return isTest 
            ? _buildImageNetwork() 
            : _buildCachedNetworkImage();
      }
    }
    

注意点

1. パフォーマンス面での考慮

  • 本番環境では必ずキャッシュ機能を使用
  • テスト環境でのネットワーク使用量に注意
  • 適切なキャッシュ戦略の実装

2. エラーハンドリングの統一

  • 両方の実装でエラー処理を統一
  • フォールバック画像の一貫性
  • ユーザーエクスペリエンスの維持

3. テストの安定性

  • ネットワーク画像の読み込み完了を確実に待機
  • タイムアウト設定の適切な調整
  • 環境依存の要素の排除

終わりに

ネットワーク画像の取り扱いは、Flutter開発において重要な要素です。本番環境でのパフォーマンス向上とテスト環境での安定性を両立させるためには、適切な抽象化と条件分岐が必要です。

今回紹介したテクニックを活用することで、開発効率を損なうことなく、品質の高いアプリケーションを構築できるでしょう。テスト時の課題を事前に把握し、適切な対策を講じることが成功の鍵となります。

参考リンク

91works Tech Blog

Discussion