📂

【Flutter】path_providerを使ったコードをユニットテストするベストプラクティス

に公開

はじめに

Flutterでファイルやデータを保存する際には、デバイスごとの適切な保存先ディレクトリを取得する必要があります。その際によく使われるのが path_provider パッケージです。

https://pub.dev/packages/path_provider

しかし、このパッケージのメソッドはすべてグローバル関数として提供されており、ユニットテストでモック化するにはひと工夫が必要です。

この記事では、path_provider を使ったコードをテストする際の課題と、それを解決するためのモックの方法を具体的に紹介します。

記事の対象者

  • path_provider を使っているが、テストコードでエラーになって困っている人
  • グローバル関数ベースのAPIのテストに課題を感じている人
  • PathProviderPlatform を使ったモック差し替え方法を知りたい人
  • Flutterでのユニットテストを実践的に学びたい人

記事を執筆時点での筆者の環境

[✓] Flutter (Channel stable, 3.29.0, on macOS
    15.3.1 24D70 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android
    devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] VS Code (version 1.100.0)

ソースコード

https://github.com/HaruhikoMotokawa/file_system_test_sample/tree/main

※ タイトルが紛らわしいのですが、別記事のソースコードも入っていますのでご了承ください。

path_providerを使った実装

まずは以下のようなよくあるディレクトリのパスを取得するメソッド getDirectoryPath を定義します。
このメソッドで取得したディレクトリに画像データや音声データなどのファイルを書き込む用途で使うと仮定します。

import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';

class LocalStorageRepository {
  const LocalStorageRepository(this.ref);
  final Ref ref;

  Future<String> getDirectoryPath() async {
    switch (defaultTargetPlatform) {
      case TargetPlatform.android:
        return _getPathForAndroid();
      case TargetPlatform.iOS:
        return _getPathForIos();
      case _:
        throw UnsupportedError(
          'This platform is not supported for getting directory path.',
        );
    }
  }

  // ...
}

getDirectoryPath は呼び出された際のプラットフォームによって呼び出す関数を変えています。
今回はAndroidとiOSのみ対応としています。
Androidの場合は _getPathForAndroid 、iOSの場合は _getPathForIos を呼び出しています。

Androidの場合はデータサイズの大きいデータは外部ストレージに保存することが推奨されているため、このような出しわけを行っています。

ちなみに今回OSの判定方法は defaultTargetPlatform を使っています。
理由としてはテストのためなのですが詳しくは過去の記事で解説していますので、気になる方はぜひご覧ください。

https://qiita.com/Harx02663971/items/5bccf49b012756f36adb

_getPathForAndroid

Androidの場合はpath_providerの getExternalStorageDirectory を呼び出しています。

Future<String> _getPathForAndroid() async {
  final directory = await getExternalStorageDirectory();

  if (directory == null) {
    throw NotFoundDirectoryException(
      'Unable to get external storage directory',
    );
  }

  return directory.path;
}

getExternalStorageDirectory はnullがありえます。
もしもnullであった場合は今回は自作の NotFoundDirectoryException をスローさせています。

/// ディレクトリーが見つからない場合にスローされる例外
class NotFoundDirectoryException implements Exception {
  NotFoundDirectoryException(this.message);
  final String message;

  
  String toString() => 'NotFoundDirectoryException: $message';
}

_getPathForIos

iOSの場合は getApplicationDocumentsDirectory を呼び出しています。

Future<String> _getPathForIos() async {
  try {
    final directory = await getApplicationDocumentsDirectory();
    return directory.path;
  } on MissingPlatformDirectoryException catch (e) {
    debugPrint('Error getting documents path: $e');
    rethrow;
  }
}

getApplicationDocumentsDirectory はドキュメントコメントにも記載がある通り、MissingPlatformDirectoryException をスローする可能性があります。
今回はそのままリスローさせていますが、場合によっては独自の Exception に変換するなどします。

path_providerを使っている部分をテストする

まず、今回はテストのために以下のパッケージを使用します。

# Mockitoを使ってモッククラスを自動生成するためのパッケージ
mockito:

# path_providerが内部で使っているプラットフォームインターフェースをモックするために必要
path_provider_platform_interface:

# プラットフォームインターフェースのモック差し替え時に使うMixinを提供
plugin_platform_interface:

https://pub.dev/packages/mockito

https://pub.dev/packages/path_provider_platform_interface

https://pub.dev/packages/plugin_platform_interface

モックする上での大きな問題

mockitoを使ったモック作成は通常だと以下のようにモックしたいクラスをアノテーション内に定義して、自動生成コマンドを叩けば簡単にできます。

([
  MockSpec<Cat>()
])
// モック対象のクラス
class Cat {
  // ...
}

しかし、ここで大きな問題として上がるのがpath_providerにはインスタンスが公開されていないということです。
このパッケージの提供するメソッドは全てグローバル関数です。
こうなってくると、path_providerのインスタンスをモックと差し替えるということが通常の手順ではできません。

path_providerのモック作成

そこでまずはpath_providerのモックを作る下準備として以下のような偽のクラスを作成します。

class FakePathProviderPlatform extends Mock
    with MockPlatformInterfaceMixin
    implements PathProviderPlatform {}

クラス名 FakePathProviderPlatform は任意に変更可能です。

MockMockito によるモック機能を提供するベースクラスです。

MockPlatformInterfaceMixin は、PathProviderPlatform のような PlatformInterface 継承クラスに対して行われる
トークン検証(instance に代入できるかの安全性チェック) をスキップするために使います。

最後に通常のモック作成と同様にアノテーション内に定義して自動生成を実行します。

([
  MockSpec<FakePathProviderPlatform>(),
])
void main() {}

class FakePathProviderPlatform extends Mock
    with MockPlatformInterfaceMixin
    implements PathProviderPlatform {}

テストのセットアップ

モックの差し替えもちょっと普通とは違います。

void main() {
  late ProviderContainer container;
  late LocalStorageRepository repository;

  final mockPathProviderPlatform = MockFakePathProviderPlatform();

  // AndroidのPath
  const androidPath = '/storage/emulated/0/Android/data/com.example.app/files';
  // iOSのPath
  const iosPath =
      '/var/mobile/Containers/Data/Application/12345678-1234-1234-1234-123456789012/Documents';

  setUp(() {
    // モックの初期化
    reset(mockPathProviderPlatform);

    // PathProviderPlatformのインスタンスをモックに置き換える
    PathProviderPlatform.instance = mockPathProviderPlatform;

    // ProviderContainerの初期化
    container = ProviderContainer();

    // LocalStorageRepositoryの取得
    repository = container.read(localStorageRepositoryProvider);

    // デフォルトのターゲットプラットフォームをAndroidに設定
    debugDefaultTargetPlatformOverride = TargetPlatform.android;

    // path_providerのスタブを設定 --->
    when(mockPathProviderPlatform.getApplicationDocumentsPath())
        .thenAnswer((_) async => iosPath);

    when(mockPathProviderPlatform.getExternalStoragePath())
        .thenAnswer((_) async => androidPath);
    // <--- path_providerのスタブを設定
  });

  tearDown(() {
    container.dispose();
    // デフォルトのターゲットプラットフォームを元に戻す
    debugDefaultTargetPlatformOverride = null;
  });

  // ...
}

特筆すべきは以下です。

// PathProviderPlatformのインスタンスをモックに置き換える
PathProviderPlatform.instance = mockPathProviderPlatform;

mockPathProviderPlatform を作ったら、差し替える先が PathProviderPlatform.instance だと言うのが通常のテストとは違いますね。

あとはスタブはmockitoお馴染みの when で準備できます。

テスト全文
void main() {
  late ProviderContainer container;
  late LocalStorageRepository repository;

  final mockPathProviderPlatform = MockFakePathProviderPlatform();

  // AndroidのPath
  const androidPath = '/storage/emulated/0/Android/data/com.example.app/files';
  // iOSのPath
  const iosPath =
      '/var/mobile/Containers/Data/Application/12345678-1234-1234-1234-123456789012/Documents';

  setUp(() {
    // モックの初期化
    reset(mockPathProviderPlatform);

    // PathProviderPlatformのインスタンスをモックに置き換える
    PathProviderPlatform.instance = mockPathProviderPlatform;

    // ProviderContainerの初期化
    container = ProviderContainer();

    // LocalStorageRepositoryの取得
    repository = container.read(localStorageRepositoryProvider);

    // デフォルトのターゲットプラットフォームをAndroidに設定
    debugDefaultTargetPlatformOverride = TargetPlatform.android;

    // path_providerのスタブを設定 --->
    when(mockPathProviderPlatform.getApplicationDocumentsPath())
        .thenAnswer((_) async => iosPath);

    when(mockPathProviderPlatform.getExternalStoragePath())
        .thenAnswer((_) async => androidPath);
    // <--- path_providerのスタブを設定
  });

  tearDown(() {
    container.dispose();
    // デフォルトのターゲットプラットフォームを元に戻す
    debugDefaultTargetPlatformOverride = null;
  });

  group('getDirectoryPath', () {
    test('【Android】ディレクトリのパスが取得できる', () async {
      // 値を取得する
      final path = await repository.getDirectoryPath();

      // 期待するパスと一致することを確認
      expect(path, equals(androidPath));

      // スタブの呼び出しを確認
      verify(mockPathProviderPlatform.getExternalStoragePath()).called(1);
    });

    test('【iOS】ディレクトリのパスが取得できる', () async {
      // iOSに切り替え
      debugDefaultTargetPlatformOverride = TargetPlatform.iOS;

      // 値を取得する
      final path = await repository.getDirectoryPath();

      // 期待するパスと一致することを確認
      expect(path, equals(iosPath));

      // スタブの呼び出しを確認
      verify(mockPathProviderPlatform.getApplicationDocumentsPath()).called(1);
    });
    test('サポートされていないプラットフォームでは例外がスローされる', () async {
      // サポートされていないプラットフォームに切り替え
      debugDefaultTargetPlatformOverride = TargetPlatform.windows;

      // 例外がスローされることを確認
      expect(
        repository.getDirectoryPath(),
        throwsA(isA<UnsupportedError>()),
      );

      // スタブの呼び出しを確認
      verifyNever(mockPathProviderPlatform.getExternalStoragePath());
      verifyNever(mockPathProviderPlatform.getApplicationDocumentsPath());
    });
    test('【Android】取得に失敗するとNotFoundDirectoryExceptionをスローする', () async {
      // スタブを設定
      when(mockPathProviderPlatform.getExternalStoragePath())
          .thenThrow(NotFoundDirectoryException('error'));

      // 例外がスローされることを確認
      await expectLater(
        repository.getDirectoryPath(),
        throwsA(isA<NotFoundDirectoryException>()),
      );

      // スタブの呼び出しを確認
      verify(mockPathProviderPlatform.getExternalStoragePath()).called(1);
    });
    test('【iOS】取得に失敗するとMissingPlatformDirectoryExceptionをスローする', () async {
      // iOSに切り替え
      debugDefaultTargetPlatformOverride = TargetPlatform.iOS;

      // スタブを設定
      when(mockPathProviderPlatform.getApplicationDocumentsPath())
          .thenThrow(MissingPlatformDirectoryException('error'));

      // 例外がスローされることを確認
      await expectLater(
        repository.getDirectoryPath(),
        throwsA(isA<MissingPlatformDirectoryException>()),
      );

      // スタブの呼び出しを確認
      verify(mockPathProviderPlatform.getApplicationDocumentsPath()).called(1);
    });
  });
}

終わりに

path_provider は便利なパッケージですが、グローバル関数ベースの API であるため、ユニットテストではモック化に少し工夫が必要です。

この記事では、PathProviderPlatformMockPlatformInterfaceMixin を使って差し替えることで、実際のディレクトリ操作に依存せずにテストを行う方法を紹介しました。

特に PathProviderPlatform.instance を使った差し替えなど、通常のテストとは異なるセットアップが必要になる点は、実務でもつまずきやすいポイントです。

今回紹介した方法は path_provider に限らず、PlatformInterface をベースに実装された他のパッケージにも応用可能です。モックを使ったテストを柔軟に行うためのひとつのパターンとして、ぜひ覚えておくと役立つでしょう。

この記事が、Flutter のテストに取り組む際の一助となれば幸いです。

Discussion