【Flutter】fileパッケージを使ったコードにおけるユニットテストのベストプラクティス
はじめに
Flutter アプリで画像や動画などの大きなファイルを保存・操作する場合、file
パッケージはその代表的な選択肢の一つです。
しかし、このパッケージを用いたコードに対してユニットテストを書く際には、通常のモックとは異なるアプローチが求められます。
特に、ファイル操作に伴う FileSystemException
をどのように扱い、どうテストに反映させるかが重要です。
この記事では以下を中心に解説します。
-
file
パッケージを使った簡単な実装方法 -
FileSystemException
をアプリ独自の例外として扱う設計 -
MemoryFileSystem.test()
を使った柔軟なユニットテストの方法 - 特定のファイル操作(存在確認・作成・削除など)に対して例外をシミュレートするテクニック
記事の対象者
- Flutter でローカルファイルの読み書きを行うアプリを開発している方
-
file
パッケージを用いた実装とテスト方法を知りたい方 - ファイル操作時の例外処理を丁寧に扱いたいと考えている方
記事を執筆時点での筆者の環境
[✓] 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)
ソースコード
fileを使った実装
fileパッケージを使った実装を簡単にご紹介します。
なお、依存性注入はriverpod経由で行っています。
LocalFileSystem のインスタンスをプロバイダー化しておく
fileパッケージのインストールを行ったら、今回データのファイルやディレクトリの編集機能を提供する LocalFileSystem
をプロバイダー経由で取得できるようにしておきます。
(keepAlive: true)
FileSystem fileSystem(Ref ref) => const LocalFileSystem();
ここで重要な点としてはプロバイダの戻り値の型は LocalFileSystem
ではなくて抽象クラスの FileSystem
にする点です。
ここはテストの部分に関わってきますので、後ほど解説します。
LocalContentRepository
コンテンツの編集を司る 以下のクラスにコンテンツを保存するディレクトリの作成や取得、コンテンツの保存、取得、削除などを定義していきます。
編集機能を提供する LocalFileSystem
はref経由で取得しています。
class LocalContentRepository {
const LocalContentRepository(this.ref);
final Ref ref;
FileSystem get _localFileSystem => ref.read(fileSystemProvider);
// ...
}
また、 LocalContentRepository
もref経由で取得できるようにプロバイダー化しておきます。
(keepAlive: true)
LocalContentRepository localContentRepository(Ref ref) =>
LocalContentRepository(ref);
getOrCreateDirectory
実装例題: ここでは一例として getOrCreateDirectory
を取り上げたいと思います。
このメソッドはコンテンツを保存するディレクトリーを取得しますが、初めて取得を試みた場合はディレクトリーを作った上で返却するようにしています。
/// アプリケーションのデータを保存するディレクトリのベース名
static const String baseName = 'my_app_data';
/// 保存ディレクリを取得、存在しない場合は作成
Future<Directory> getOrCreateDirectory(String path) async {
try {
// ディレクトリのベース名を取得
final directory =
_localFileSystem.directory(path).childDirectory(baseName);
// ディレクトリが存在しない場合は作成
if (!directory.existsSync()) {
await directory.create(recursive: true);
}
return directory;
} on FileSystemException catch (e) {
throw FileOperationException.from(e);
}
}
ここでtry-catchしているのは _localFileSystem
の操作は例外 FileSystemException
が発生する可能性があるためです。
対象の操作としては existsSync
と create
です。
ただし、この例外の詳細に関してはさまざまなものがあり、 主に OSError
のエラーコードで表現されています。
そのままだとわかりづらいのでエラーコードごとに変換するように FileOperationException
を通してラップするようにしました。
/// FileSystemExceptionをラップした例外
class FileOperationException implements Exception {
FileOperationException(this.message);
/// FileSystemExceptionからFileOperationExceptionを生成するファクトリコンストラクタ
factory FileOperationException.from(FileSystemException e) {
// FileSystemExceptionからOSエラーを取得
final osError = e.osError;
// OSエラーがnullの場合は一般的なファイル操作例外を返す
if (osError == null) {
return FileOperationException(e.message);
}
// OSエラーのエラーコードに応じて適切な例外を返す
switch (osError.errorCode) {
case 2:
return NoSuchFileException(osError.message);
case 13:
return FilePermissionDeniedException(osError.message);
case 28:
return NoSpaceLeftException(osError.message);
default:
return FileOperationException(osError.message);
}
}
/// エラーメッセージ
final String message;
}
/// 権限がない場合の例外
class FilePermissionDeniedException extends FileOperationException {
FilePermissionDeniedException(super.message);
}
/// ファイルが見つからない場合の例外
class NoSuchFileException extends FileOperationException {
NoSuchFileException(super.message);
}
/// ストレージの空き容量がない場合の例外
class NoSpaceLeftException extends FileOperationException {
NoSpaceLeftException(super.message);
}
今回は上記の3つのパターンのみ変換し、それ以外は FileOperationException
としてメッセージだけ受け取るようにしています。
エラーコード一覧
part of dart.io;
// Constants used when working with native ports.
// These must match the constants in runtime/bin/dartutils.h class CObject.
const int _successResponse = 0;
const int _illegalArgumentResponse = 1;
const int _osErrorResponse = 2;
const int _fileClosedResponse = 3;
const int _errorResponseErrorType = 0;
const int _osErrorResponseErrorCode = 1;
const int _osErrorResponseMessage = 2;
// POSIX error codes.
// See https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/errno.h.html
const _ePerm = 1;
const _eNoEnt = 2;
const _eAccess = 13;
const _eExist = 17;
// Windows error codes.
// See https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-
const _errorFileNotFound = 2;
const _errorPathNotFound = 3;
const _errorAccessDenied = 5;
const _errorInvalidDrive = 15;
const _errorCurrentDirectory = 16;
const _errorNoMoreFiles = 18;
const _errorWriteProtect = 19;
const _errorBadLength = 24;
const _errorSharingViolation = 32;
const _errorLockViolation = 33;
const _errorBadNetpath = 53;
const _errorNetworkAccessDenied = 65;
const _errorBadNetName = 67;
const _errorFileExists = 80;
const _errorDriveLocked = 108;
const _errorInvalidName = 123;
const _errorBadPathName = 161;
const _errorAlreadyExists = 183;
const _errorFilenameExedRange = 206;
その他の実装も含めた実装も掲載しておきます。
LocalContentRepository 全体
class LocalContentRepository {
const LocalContentRepository(this.ref);
final Ref ref;
FileSystem get _localFileSystem => ref.read(fileSystemProvider);
/// アプリケーションのデータを保存するディレクトリのベース名
static const String baseName = 'my_app_data';
/// 保存ディレクリを取得、存在しない場合は作成
Future<Directory> getOrCreateDirectory(String path) async {
try {
// ディレクトリのベース名を取得
final directory =
_localFileSystem.directory(path).childDirectory(baseName);
// ディレクトリが存在しない場合は作成
if (!directory.existsSync()) {
await directory.create(recursive: true);
}
return directory;
} on FileSystemException catch (e) {
throw FileOperationException.from(e);
}
}
/// ファイルを保存
Future<void> saveFile({
required String directoryPath,
required String fileName,
required String content,
}) async {
final directory = await getOrCreateDirectory(directoryPath);
try {
final file = directory.childFile(fileName);
await file.writeAsString(content);
} on FileSystemException catch (e) {
throw FileOperationException.from(e);
}
}
/// ファイルを読み込み
Future<String> readFile({
required String directoryPath,
required String fileName,
}) async {
final directory = await getOrCreateDirectory(directoryPath);
try {
final file = directory.childFile(fileName);
return await file.readAsString();
} on FileSystemException catch (e) {
throw FileOperationException.from(e);
}
}
/// ファイルを削除
Future<void> deleteFile({
required String directoryPath,
required String fileName,
}) async {
final directory = await getOrCreateDirectory(directoryPath);
try {
final file = directory.childFile(fileName);
if (file.existsSync()) {
await file.delete();
}
} on FileSystemException catch (e) {
throw FileOperationException.from(e);
}
}
}
テスト
ここからはテストについて解説していきます。
LocalFileSystemのモック
LocalContentRepository
内で使っている LocalFileSystem
をモックしなければいけません。
ただ、そのままmockitoなどでモックを作るとスタブの作成がかなり面倒なことになってきます。
class LocalContentRepository {
const LocalContentRepository(this.ref);
final Ref ref;
FileSystem get _localFileSystem => ref.read(fileSystemProvider);
// ...
}
そこでもう一度 fileSystemProvider
との戻り値に着目します。
(keepAlive: true)
FileSystem fileSystem(Ref ref) => const LocalFileSystem();
FileSystem
は抽象クラスです。
abstract class FileSystem
結論を言うと、パッケージが用意してくれているテスト用の MemoryFileSystem.test()
に差し替えます。
これによりテスト上ではあたかもディレクトリを作ったり、ファイルを作ったりしますが、テスト後にはその痕跡は消えるようにできます。
命名通り、テストで使えるメモリー上にディレクトリーなどの操作を作り上げてくれると言うわけです。
上記を踏まえたテストファイルのsetUpは以下となります。
void main() {
late ProviderContainer container;
late MemoryFileSystem mockFileSystem;
late Directory applicationStorage;
late LocalContentRepository repository;
// テスト用のディレクトリパス
const directoryPath = '/test_directory';
// アプリケーションのデータを保存するディレクトリのベース名
const baseName = LocalContentRepository.baseName;
setUp(() {
// テスト用のMemoryFileSystemを初期化
mockFileSystem = MemoryFileSystem.test();
// ProviderContainerを作成し、fileSystemProvider を mockFileSystem で上書き
container = ProviderContainer(
overrides: [
fileSystemProvider.overrideWithValue(mockFileSystem),
],
);
// アプリケーションのストレージディレクトリを準備
applicationStorage = mockFileSystem.directory(directoryPath)
..createSync(recursive: true);
// LocalContentRepositoryのインスタンスを取得
repository = container.read(localContentRepositoryProvider);
});
tearDown(() {
container.dispose();
});
// ...
}
正常系のテスト
getOrCreateDirectory
を例に正常系のテストをご紹介します。
改めて実装は以下のとおりです。
/// 保存ディレクリを取得、存在しない場合は作成
Future<Directory> getOrCreateDirectory(String path) async {
try {
// ディレクトリのベース名を取得
final directory =
_localFileSystem.directory(path).childDirectory(baseName);
// ディレクトリが存在しない場合は作成
if (!directory.existsSync()) {
await directory.create(recursive: true);
}
return directory;
} on FileSystemException catch (e) {
throw FileOperationException.from(e);
}
}
テストコードは以下のとおりです。
test('ディレクトリが存在しなかった場合に新規作成したものを取得することができる', () async {
// ディレクトリがまだ存在しないことを確認
final hasDirectory =
applicationStorage.childDirectory(baseName).existsSync();
expect(hasDirectory, isFalse);
// ディレクトリを取得
final directory = await repository.getOrCreateDirectory(directoryPath);
// ディレクトリが存在することを確認
expect(directory.path, equals('$directoryPath/$baseName'));
});
test('ディレクトリが既に存在する場合は新規作成しない', () async {
// 事前にディレクトリを作成
final existingDirectory = applicationStorage.childDirectory(baseName)
..createSync(recursive: true);
// 既存のディレクトリが存在することを確認
final hasDirectory = existingDirectory.existsSync();
expect(hasDirectory, isTrue);
// ディレクトリを取得
final directory = await repository.getOrCreateDirectory(directoryPath);
// 既存のディレクトリが返されることを確認
expect(directory.path, equals(existingDirectory.path));
});
注意点としては同じオブジェクトかどうかを Directory
は単純な比較ができません。
内部で ==
を定義されていないのです。
よって、同一かどうかを確認するにはお互いのパスが同一かどうかで比較する必要があります。
異常系のテスト
次に異常系のテストですが、こちらも少々工夫が必要です。
まず getOrCreateDirectory
が例外を起こす可能性がある部分は以下の2点です。
// 💡 existsSync
if (!directory.existsSync()) {
// 💡 create
await directory.create(recursive: true);
}
今回はmockitoなどを使ってモックを作っているわけではないので、エラーをスローするスタブが用意できません。
そこで、MemoryFileSystem
が行う特定の処理だけエラーをスローするように最初から設定することができます。
ファイル内では正常系と異常系のテストが混在するので、異常系のテストを行う場合だけ特定のエラーをスローするように以下の便利関数を定義して、対象のテストで呼び出します。
/// mockFileSystemの特定の操作でExceptionを投げるように上書きする
void setUpOverrideMockFileSystem(FileSystemOp systemOp, int errorCode) {
// mockFileSystemのオペレーションを上書きする
mockFileSystem = MemoryFileSystem.test(
// オペレーションのハンドラを設定
opHandle: (String context, FileSystemOp op) {
// 指定されたオペレーションがsystemOpと一致する場合、例外を投げる
if (op == systemOp) {
throw FileSystemException(
'failed',
null,
// errorCodeに応じて異なるエラーコードを設定
OSError(
'failed',
errorCode,
),
);
}
},
);
applicationStorage = mockFileSystem.directory(directoryPath);
// systemOpがcreate以外は作る
// createが例外を投げるテストの場合は作ると即エラーになるので、作らない
if (systemOp != FileSystemOp.create) {
applicationStorage.createSync(recursive: true);
}
// ProviderContainerを更新して、mockFileSystemを上書きする
container.updateOverrides([
fileSystemProvider.overrideWithValue(mockFileSystem),
]);
}
MemoryFileSystem.test()
の opHandle
パラメータには、任意のファイルシステム操作が行われた際に呼び出されるコールバック関数を指定できます。
この opHandle
関数は、2つの引数 (context, op)
を受け取ります。
-
context
: 実行されているファイル操作の文脈を表す文字列(今回は使いません) -
op
: 実行されようとしている操作の種類(FileSystemOp.exists
やFileSystemOp.create
など)
この op
を判別して、特定の操作に対してだけ意図的に例外をスローすることができます。
これにより「このテストでは create
の操作だけ失敗させる」といった柔軟なテストが可能になります。
FileSystemOp
class FileSystemOp {
const FileSystemOp._(this._value);
// This field added to ensure const values can be different.
// ignore: unused_field
final int _value;
/// A file system operation used for all read methods.
///
/// * [FileSystemEntity.readAsString]
/// * [FileSystemEntity.readAsStringSync]
/// * [FileSystemEntity.readAsBytes]
/// * [FileSystemEntity.readAsBytesSync]
static const FileSystemOp read = FileSystemOp._(0);
/// A file system operation used for all write methods.
///
/// * [FileSystemEntity.writeAsString]
/// * [FileSystemEntity.writeAsStringSync]
/// * [FileSystemEntity.writeAsBytes]
/// * [FileSystemEntity.writeAsBytesSync]
static const FileSystemOp write = FileSystemOp._(1);
/// A file system operation used for all delete methods.
///
/// * [FileSystemEntity.delete]
/// * [FileSystemEntity.deleteSync]
static const FileSystemOp delete = FileSystemOp._(2);
/// A file system operation used for all create methods.
///
/// * [FileSystemEntity.create]
/// * [FileSystemEntity.createSync]
static const FileSystemOp create = FileSystemOp._(3);
/// A file operation used for all open methods.
///
/// * [File.open]
/// * [File.openSync]
/// * [File.openRead]
/// * [File.openWrite]
static const FileSystemOp open = FileSystemOp._(4);
/// A file operation used for all copy methods.
///
/// * [File.copy]
/// * [File.copySync]
static const FileSystemOp copy = FileSystemOp._(5);
/// A file system operation used for all exists methods.
///
/// * [FileSystemEntity.exists]
/// * [FileSystemEntity.existsSync]
static const FileSystemOp exists = FileSystemOp._(6);
// ...
}
setUpOverrideMockFileSystem
を使った異常系のテストは以下のとおりです。
test('existsSyncで例外が発生した場合はFileOperationExceptionをスローする', () async {
setUpOverrideMockFileSystem(FileSystemOp.exists, -1);
// エラーが発生することを確認
await expectLater(
repository.getOrCreateDirectory(directoryPath),
throwsA(isA<FileOperationException>()),
);
});
test('createで例外が発生した場合はFileOperationExceptionをスローする', () async {
setUpOverrideMockFileSystem(FileSystemOp.create, -1);
// エラーが発生することを確認
await expectLater(
repository.getOrCreateDirectory(directoryPath),
throwsA(isA<FileOperationException>()),
);
});
setUpOverrideMockFileSystem
の引数に渡しているのが FileSystemOp.exists
の場合は内部で exists
が呼び出された場合だけエラーをスローされます。今回で言うとエラーコードは -1
としていますが、これは特にエラーコードがない場合に使います。
いわゆるその他のエラーです。
/// Constant used to indicate that no OS error code is available.
static const int noErrorCode = -1;
特定のosErrorのコードを受け取る場合
特定のエラーコードを受け取った場合は FileOperationException
のファクトリー内で変換するようにしていました。
以下に再度掲載します。
/// FileSystemExceptionをラップした例外
class FileOperationException implements Exception {
FileOperationException(this.message);
/// FileSystemExceptionからFileOperationExceptionを生成するファクトリコンストラクタ
factory FileOperationException.from(FileSystemException e) {
// FileSystemExceptionからOSエラーを取得
final osError = e.osError;
// OSエラーがnullの場合は一般的なファイル操作例外を返す
if (osError == null) {
return FileOperationException(e.message);
}
// OSエラーのエラーコードに応じて適切な例外を返す
switch (osError.errorCode) {
case 2:
return NoSuchFileException(osError.message);
case 13:
return FilePermissionDeniedException(osError.message);
case 28:
return NoSpaceLeftException(osError.message);
default:
return FileOperationException(osError.message);
}
}
final String message;
}
LocalContentRepository
にある saveFile
メソッドのテストでこの上記のパターンが発生した場合をテストしてみました。
group('saveFile', () {
const fileName = 'test_file.txt';
const content = 'Hello, World!';
// ...
test('ファイルが見つからない場合、NoSuchFileExceptionをスローする', () async {
setUpOverrideMockFileSystem(FileSystemOp.write, 2);
// エラーが発生することを確認
await expectLater(
repository.saveFile(
directoryPath: directoryPath,
fileName: fileName,
content: content,
),
throwsA(isA<NoSuchFileException>()),
);
});
test('権限がない場合、FilePermissionDeniedExceptionをスローする', () async {
setUpOverrideMockFileSystem(FileSystemOp.write, 13);
// エラーが発生することを確認
await expectLater(
repository.saveFile(
directoryPath: directoryPath,
fileName: fileName,
content: content,
),
throwsA(isA<FilePermissionDeniedException>()),
);
});
test('ストレージの空き容量がない場合、NoSpaceLeftExceptionをスローする', () async {
setUpOverrideMockFileSystem(FileSystemOp.write, 28);
// エラーが発生することを確認
await expectLater(
repository.saveFile(
directoryPath: directoryPath,
fileName: fileName,
content: content,
),
throwsA(isA<NoSpaceLeftException>()),
);
});
});
saveFile
メソッドは内部で writeAsString
メソッドを読んでいます。
そこで setUpOverrideMockFileSystem
の第一引数に FileSystemOp.write
を渡します。
第二引数にはテストで検証したいエラーコードを入れれば良いだけです。
テスト全体
テストファイル全体
void main() {
late ProviderContainer container;
late MemoryFileSystem mockFileSystem;
late Directory applicationStorage;
late LocalContentRepository repository;
// テスト用のディレクトリパス
const directoryPath = '/test_directory';
// アプリケーションのデータを保存するディレクトリのベース名
const baseName = LocalContentRepository.baseName;
setUp(() {
// テスト用のMemoryFileSystemを初期化
mockFileSystem = MemoryFileSystem.test();
// ProviderContainerを作成し、fileSystemProvider を mockFileSystem で上書き
container = ProviderContainer(
overrides: [
fileSystemProvider.overrideWithValue(mockFileSystem),
],
);
// アプリケーションのストレージディレクトリを準備
applicationStorage = mockFileSystem.directory(directoryPath)
..createSync(recursive: true);
// LocalContentRepositoryのインスタンスを取得
repository = container.read(localContentRepositoryProvider);
});
tearDown(() {
container.dispose();
});
/// mockFileSystemの特定の操作でExceptionを投げるように上書きする
void setUpOverrideMockFileSystem(FileSystemOp systemOp, int errorCode) {
// mockFileSystemのオペレーションを上書きする
mockFileSystem = MemoryFileSystem.test(
// オペレーションのハンドラを設定
opHandle: (String context, FileSystemOp op) {
// 指定されたオペレーションがsystemOpと一致する場合、例外を投げる
if (op == systemOp) {
throw FileSystemException(
'failed',
null,
// errorCodeに応じて異なるエラーコードを設定
OSError(
'failed',
errorCode,
),
);
}
},
);
applicationStorage = mockFileSystem.directory(directoryPath);
// systemOpがcreate以外は作る
// createが例外を投げるテストの場合は作ると即エラーになるので、作らない
if (systemOp != FileSystemOp.create) {
applicationStorage.createSync(recursive: true);
}
// ProviderContainerを更新して、mockFileSystemを上書きする
container.updateOverrides([
fileSystemProvider.overrideWithValue(mockFileSystem),
]);
}
group('getOrCreateDirectory', () {
test('ディレクトリが存在しなかった場合に新規作成したものを取得することができる', () async {
// ディレクトリがまだ存在しないことを確認
final hasDirectory =
applicationStorage.childDirectory(baseName).existsSync();
expect(hasDirectory, isFalse);
// ディレクトリを取得
final directory = await repository.getOrCreateDirectory(directoryPath);
// ディレクトリが存在することを確認
expect(directory.path, equals('$directoryPath/$baseName'));
});
test('ディレクトリが既に存在する場合は新規作成しない', () async {
// 事前にディレクトリを作成
final existingDirectory = applicationStorage.childDirectory(baseName)
..createSync(recursive: true);
// 既存のディレクトリが存在することを確認
final hasDirectory = existingDirectory.existsSync();
expect(hasDirectory, isTrue);
// ディレクトリを取得
final directory = await repository.getOrCreateDirectory(directoryPath);
// 既存のディレクトリが返されることを確認
expect(directory.path, equals(existingDirectory.path));
});
test('existsSyncで例外が発生した場合はFileOperationExceptionをスローする', () async {
setUpOverrideMockFileSystem(FileSystemOp.exists, -1);
// エラーが発生することを確認
await expectLater(
repository.getOrCreateDirectory(directoryPath),
throwsA(isA<FileOperationException>()),
);
});
test('createで例外が発生した場合はFileOperationExceptionをスローする', () async {
setUpOverrideMockFileSystem(FileSystemOp.create, -1);
// エラーが発生することを確認
await expectLater(
repository.getOrCreateDirectory(directoryPath),
throwsA(isA<FileOperationException>()),
);
});
});
group('saveFile', () {
const fileName = 'test_file.txt';
const content = 'Hello, World!';
test('ファイルを保存できる', () async {
// まだファイルがないことを確認
final hasFile = applicationStorage
.childDirectory(baseName)
.childFile(fileName)
.existsSync();
expect(hasFile, isFalse);
// ファイルを保存する
await repository.saveFile(
directoryPath: directoryPath,
fileName: fileName,
content: content,
);
final file =
applicationStorage.childDirectory(baseName).childFile(fileName);
// ファイルが存在することを確認
expect(file.existsSync(), isTrue);
// ファイルの内容を確認
expect(file.readAsStringSync(), equals(content));
});
test('writeAsStringで例外が発生した場合はFileOperationExceptionをスローする', () async {
setUpOverrideMockFileSystem(FileSystemOp.write, -1);
// エラーが発生することを確認
await expectLater(
repository.saveFile(
directoryPath: directoryPath,
fileName: fileName,
content: content,
),
throwsA(isA<FileOperationException>()),
);
});
test('ファイルが見つからない場合、NoSuchFileExceptionをスローする', () async {
setUpOverrideMockFileSystem(FileSystemOp.write, 2);
// エラーが発生することを確認
await expectLater(
repository.saveFile(
directoryPath: directoryPath,
fileName: fileName,
content: content,
),
throwsA(isA<NoSuchFileException>()),
);
});
test('権限がない場合、FilePermissionDeniedExceptionをスローする', () async {
setUpOverrideMockFileSystem(FileSystemOp.write, 13);
// エラーが発生することを確認
await expectLater(
repository.saveFile(
directoryPath: directoryPath,
fileName: fileName,
content: content,
),
throwsA(isA<FilePermissionDeniedException>()),
);
});
test('ストレージの空き容量がない場合、NoSpaceLeftExceptionをスローする', () async {
setUpOverrideMockFileSystem(FileSystemOp.write, 28);
// エラーが発生することを確認
await expectLater(
repository.saveFile(
directoryPath: directoryPath,
fileName: fileName,
content: content,
),
throwsA(isA<NoSpaceLeftException>()),
);
});
});
group('readFile', () {
const fileName = 'test_file.txt';
const content = 'Hello, World!';
test('ファイルを正常に読み込むことができる', () async {
// 事前にファイルを保存
await repository.saveFile(
directoryPath: directoryPath,
fileName: fileName,
content: content,
);
// ファイルを読み込む
final readContent = await repository.readFile(
directoryPath: directoryPath,
fileName: fileName,
);
// 読み込んだ内容が正しいことを確認
expect(readContent, equals(content));
});
test('readAsStringで例外が発生した場合はFileOperationExceptionをスローする', () async {
setUpOverrideMockFileSystem(FileSystemOp.read, -1);
// エラーが発生することを確認
await expectLater(
repository.readFile(
directoryPath: directoryPath,
fileName: fileName,
),
throwsA(isA<FileOperationException>()),
);
});
});
group('deleteFile', () {
const fileName = 'test_file.txt';
const content = 'Hello, World!';
test('ファイルを正常に削除できる', () async {
// 事前にファイルを作成
await repository.saveFile(
directoryPath: directoryPath,
fileName: fileName,
content: content,
);
// ファイルを削除
await repository.deleteFile(
directoryPath: directoryPath,
fileName: fileName,
);
// ファイルが存在しないことを確認
final file =
applicationStorage.childDirectory(baseName).childFile(fileName);
expect(file.existsSync(), isFalse);
});
test('deleteで例外が発生した場合はFileOperationExceptionをスローする', () async {
setUpOverrideMockFileSystem(FileSystemOp.delete, -1);
// 事前にファイルを作成
// setUpOverrideMockFileSystemで作成したmockFileSystemで作成する必要がある
await repository.saveFile(
directoryPath: directoryPath,
fileName: fileName,
content: content,
);
// エラーが発生することを確認
await expectLater(
() => repository.deleteFile(
directoryPath: directoryPath,
fileName: fileName,
),
throwsA(isA<FileOperationException>()),
);
});
});
}
終わりに
この記事では、file
パッケージを使ったファイル操作の実装と、そのユニットテストのベストプラクティスについて解説しました。
特に、MemoryFileSystem.test()
を活用することで、安全かつ柔軟にファイル操作のテストが行えること、そして opHandle
を使って意図的に例外をスローすることで異常系のテストもカバーできることが重要なポイントでした。
また、FileSystemException
のエラーコードをアプリ独自の例外に変換する設計は、保守性と可読性の向上にもつながります。
ファイル操作に関するテストは手間がかかる印象を持たれがちですが、今回のような仕組みを取り入れることで、より堅牢で信頼性の高いコードベースに近づけることができます。
ぜひ皆さんのプロジェクトにも応用してみてください。
Discussion