📂

【Flutter】fileパッケージを使ったコードにおけるユニットテストのベストプラクティス

に公開

はじめに

Flutter アプリで画像や動画などの大きなファイルを保存・操作する場合、file パッケージはその代表的な選択肢の一つです。

https://pub.dev/packages/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)

ソースコード

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

fileを使った実装

fileパッケージを使った実装を簡単にご紹介します。
なお、依存性注入はriverpod経由で行っています。

https://riverpod.dev

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 が発生する可能性があるためです。
対象の操作としては existsSynccreate です。

ただし、この例外の詳細に関してはさまざまなものがあり、 主に 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 としてメッセージだけ受け取るようにしています。

エラーコード一覧
io/common.dart
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.existsFileSystemOp.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