【Flutter】path_providerを使ったコードをユニットテストするベストプラクティス
はじめに
Flutterでファイルやデータを保存する際には、デバイスごとの適切な保存先ディレクトリを取得する必要があります。その際によく使われるのが 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)
ソースコード
※ タイトルが紛らわしいのですが、別記事のソースコードも入っていますのでご了承ください。
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
を使っています。
理由としてはテストのためなのですが詳しくは過去の記事で解説していますので、気になる方はぜひご覧ください。
_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:
モックする上での大きな問題
mockitoを使ったモック作成は通常だと以下のようにモックしたいクラスをアノテーション内に定義して、自動生成コマンドを叩けば簡単にできます。
([
MockSpec<Cat>()
])
// モック対象のクラス
class Cat {
// ...
}
しかし、ここで大きな問題として上がるのがpath_providerにはインスタンスが公開されていないということです。
このパッケージの提供するメソッドは全てグローバル関数です。
こうなってくると、path_providerのインスタンスをモックと差し替えるということが通常の手順ではできません。
path_providerのモック作成
そこでまずはpath_providerのモックを作る下準備として以下のような偽のクラスを作成します。
class FakePathProviderPlatform extends Mock
with MockPlatformInterfaceMixin
implements PathProviderPlatform {}
クラス名 FakePathProviderPlatform
は任意に変更可能です。
Mock
は Mockito によるモック機能を提供するベースクラスです。
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 であるため、ユニットテストではモック化に少し工夫が必要です。
この記事では、PathProviderPlatform
を MockPlatformInterfaceMixin
を使って差し替えることで、実際のディレクトリ操作に依存せずにテストを行う方法を紹介しました。
特に PathProviderPlatform.instance
を使った差し替えなど、通常のテストとは異なるセットアップが必要になる点は、実務でもつまずきやすいポイントです。
今回紹介した方法は path_provider
に限らず、PlatformInterface
をベースに実装された他のパッケージにも応用可能です。モックを使ったテストを柔軟に行うためのひとつのパターンとして、ぜひ覚えておくと役立つでしょう。
この記事が、Flutter のテストに取り組む際の一助となれば幸いです。
Discussion