【Flutter/Dart】テストコードの実装方法!mocktailでモックの作成とカバレッジ率の計測

【Flutter/Dart】テストコードの実装方法!mocktailでモックの作成とカバレッジ率の計測

この記事からわかること

  • Flutter/Dartテストコード実装する方法
  • mocktailモック生成と単体テストの記述方法
  • Flutterのテストカバレッジ取得手順

index

[open]

\ アプリをリリースしました /

みんなの誕生日

友達や家族の誕生日をメモ!通知も届く-みんなの誕生日-

posted withアプリーチ

Flutterでテストコードを実装する方法をローカル保存データのクラスを例にmocktailを使用したモックテストまでを解説します。

Flutterでテストコードを実装する方法

Flutterでテストコードを実装するテストしやすい構造でコード自体を設計することが大切です。今回はクリーンアーキテクチャに習ってDataSourceRepositoryに層を分離した上でテストコードを書いていきます。

3A / AAAとは?

「3A」または「AAA」テストコードを書く際の一般的な構成手法のことです。「Arrange」「Act」「Assert」の3段階で構成されます。

今回紹介するテストコードもこの3段階に従って記述していきたいと思います。

前準備1:DataSourceの実装

今回はローカルにデータを保存するDataSourceとしてSharedPreferencesDataSourceを用意します。具体的な実装はDI(依存性注入)を活用してSharedPreferencesを外部から受け取るようにしつつ、インターフェースISharedPreferencesDataSourceも定義しておきます。DataSourceはプリミティブな型しか扱わないようにしておき「何のためのデータかを認識しない」ような構造にします。



abstract class ISharedPreferencesDataSource {
  Future<void> saveString(SharedPreferencesKeys key, String value);
  String? getString(SharedPreferencesKeys key);
  Future<void> saveInt(SharedPreferencesKeys key, int value);
  int? getInt(SharedPreferencesKeys key);
}

class SharedPreferencesDataSource implements ISharedPreferencesDataSource {

  final SharedPreferences _prefs;
  SharedPreferencesDataSource(this._prefs);

  @override
  Future<void> saveString(SharedPreferencesKeys key, String value) async {
    _prefs.setString(key.key, value);
  }

  @override
  String? getString(SharedPreferencesKeys key) {
    return _prefs.getString(key.key);
  }

  @override
  Future<void> saveInt(SharedPreferencesKeys key, int value) async {
    _prefs.setInt(key.key, value);
  }

  @override
  int? getInt(SharedPreferencesKeys key) {
    return _prefs.getInt(key.key);
  }

}

前準備2:Repositoryの実装

続いてRepositoryも実装していきます。ここではDataSourceを依存関係として受け取り、カウンターの値を取得・保存する機能を実装します。ここで初めてドメイン領域に触れ「何のためのデータ」かを意識するようになります。



enum SharedPreferencesKeys {
  counter('counter'),
}  

class CounterRepository {
  final ISharedPreferencesDataSource dataSource;
  static const _counterKey = SharedPreferencesKeys.counter;
  CounterRepository({required this.dataSource});

  /// カウンターの値を取得。未設定なら0を返す
  int fetchCounter() {
    return dataSource.getInt(_counterKey) ?? 0;
  }

  /// カウンターの値を保存
  Future<void> updateCounter(int value) async {
    await dataSource.saveInt(_counterKey, value);
  }
}

前準備3:mocktailの導入

テストコードを書く前に、モックを作成するためのライブラリmocktailを導入しておきます。これでDataSourceのモックを簡単に作成し、Repositoryの挙動をモッククラスを自身で実装しなくてもテストできるようになります。テストでしか使用しないライブラリなので「pubspec.yaml」のdev_dependenciesに追加します。



// dev_dependencies に下記を追加
dev_dependencies:
  flutter_test:
    sdk: flutter
  mocktail: ^1.0.4

前準備4:モックの作成

テストコードを実装するべきはRepositoryの機能です。ただRepositoryDataSourceに依存するため、モックを用意してテストを行えるようにします。mocktailを使用することで簡単にモッククラスを作成することができます。作成して完了ではなくモックの振る舞いを都度定義していく必要があります。


/// DataSourceのモック
class MockSharedPreferencesDataSource extends Mock implements ISharedPreferencesDataSource {}

具体的なモックの振る舞いはthenReturnなどを使用して定義します。例えば「引数Aで呼ばれたときにBを返す」の際にthenReturnを使用します。またこのことを「スタブ」と呼んだりします。

// 「SharedPreferencesKeys.counterの値」の引数で呼ばれた時だけ 10 を返す
when(() => mockDataSource.getInt(SharedPreferencesKeys.counter)).thenReturn(10);

メソッドがFutureを返す場合はthenAnswerを使用します。こちらも同様に特定の引数で呼ばれた場合の挙動を定義します。

// 「SharedPreferencesKeys.counterの値 かつ 20」の引数で呼ばれた時の結果を定義
when(() => mockDataSource.saveInt(SharedPreferencesKeys.counter, 20)).thenAnswer((_) async {});

また特定の引数は指定せずに「どんな値が来てもBを返す」といった定義も可能になっています。その場合は引数マッチャーany()を使用します。また例外を投げたいときはthenThrowを使用します。

// どんなキーが来ても 0 を返す
when(() => mockDataSource.getInt(any())).thenReturn(0);

// 第1引数のキーは固定だけど、第2引数の数字は何でもいい時
when(() => mockDataSource.saveInt(SharedPreferencesKeys.counter, any())).thenAnswer((_) async {});

// saveInt が呼ばれたら、例外(Exception)を投げるように覚えさせる
when(() => mockDataSource.saveInt(any(), any()))
    .thenThrow(Exception('ディスク空き容量不足'));

ただ引数マッチャーを使用する場合にプリミティブ型以外の独自定義した型を使用する場合は、事前にその型のフォールバック値を登録しておく必要があります。例えば今回のDataSourceではSharedPreferencesKeys型を使用しているため、事前にregisterFallbackValueでフォールバック値を登録する必要があります。setUpAllメソッドを使用してテスト全体で1回だけ実行されるようにしておけばOKです。

void main() {
  // テスト全体で1回だけ実行すればOK
  setUpAll(() {
    registerFallbackValue(SharedPreferencesKeys.counter); 
  });

  test('テスト内容', () {
    // これで any() が使えるようになる
    when(() => mockDataSource.getBool(any())).thenReturn(true);
  });
}

テストコードの実装

テストコードを実際に実装するために使用するコードを先にまとめておきます。まずテストファイルにはエントリーポイントとなるmainを定義します。

void main() {
  // この中にテストコードを記載していく
}

事前準備・共通設定 (setUpAll / setUp)

テストを実行する前に、モックの作成や独自型の登録、インスタンスの初期化などを行います。setUpAllテスト全体で1回だけ実行され、setUp各テストの前に都度実行されます。

void main() {
  late MockSharedPreferencesDataSource mockDataSource;
  late CounterRepository repository;

  /// テスト全体で1回だけ実行される
  setUpAll(() {
    registerFallbackValue(SharedPreferencesKeys.counter);
  });

  /// 各テストの前に実行される
  setUp(() {
    mockDataSource = MockSharedPreferencesDataSource();
    repository = CounterRepository(dataSource: mockDataSource);
  });
}

グループ化 (group)

テスト対象のメソッドごとや機能ごとにテストをグループ化することができます。これを行うとテスト結果のレポートが見やすくなります。

void main() {
  group('CounterRepository Test', () {
    // この中に CounterRepository に関する複数のテストケースを書く
  });
}

テストケースの実装

実際の各テストケースはtest関数を使用して実装します。AAAに則り「①準備(Arrange/Stubbing) ②実行(Activity) ③検証(Assert)」の3段階で構成するように記述します。テストコードとして重要になるのが「③検証(Assert)」の部分です。

test('値が保存されている場合、その値をそのまま返すこと', () {
  // ① Arrange (準備): DataSourceが10を返すように設定
  when(() => mockDataSource.getInt(SharedPreferencesKeys.counter)).thenReturn(10);

  // ② Act (実行)
  final result = repository.fetchCounter();

  // ③ Assert (検証)
  expect(result, 10);
  verify(() => mockDataSource.getInt(SharedPreferencesKeys.counter)).called(1);
});

expectを使用して結果を検証し、verifyを使用してモックの呼び出しを確認します。.called(1)を指定しているので、そのメソッドが1回だけ呼び出されたかどうかを確認しています。

CounterRepositoryの全体のテストコードは以下になります。またファイル名には末尾に_testと付与しないとテストが実行されないみたいです。


void main() {
  late MockSharedPreferencesDataSource mockDataSource;
  late CounterRepository repository;

  setUpAll(() {
    registerFallbackValue(SharedPreferencesKeys.counter);
  });

  setUp(() {
    mockDataSource = MockSharedPreferencesDataSource();
    repository = CounterRepository(dataSource: mockDataSource);
  });

  group('CounterRepository Test', () {

    group('fetchCounter', () {
      test('値が保存されている場合、その値をそのまま返すこと', () {
        when(() => mockDataSource.getInt(SharedPreferencesKeys.counter)).thenReturn(10);
        final result = repository.fetchCounter();
        expect(result, 10);
        verify(() => mockDataSource.getInt(SharedPreferencesKeys.counter)).called(1);
      });

      test('値が保存されていない(null)場合、デフォルト値の0を返すこと', () {
        when(() => mockDataSource.getInt(SharedPreferencesKeys.counter)).thenReturn(null);
        final result = repository.fetchCounter();
        expect(result, 0);
      });
    });

    group('updateCounter', () {
      test('値を保存する際、DataSourceのsaveIntが正しい引数で呼ばれること', () async {
        when(() => mockDataSource.saveInt(any(), any()))
            .thenAnswer((_) async {});
        await repository.updateCounter(5);
        verify(() => mockDataSource.saveInt(SharedPreferencesKeys.counter, 5)).called(1);
      });
    });
  });
}

テストコードの実装であればAIを使ってコーディングした方が早いケースもありますが、実際に内容を把握していないとテストの意図が理解しづらくなるので一度詳しく構成やテストの目的を確認しつつ手動で実装してみるとAIの書いたテストコードも理解しやすくなります。AIの選定に関してはClaude CodeやGitHub Copilot、Gemini、ChatGPTでも実用的なテストコードを書いてくれるのでおすすめです。

テストの実行方法

テストの実行は以下のコマンドを実行します。Android StudioであればGUIでメソッドの横にテスト実行ボタンが表示されるのでそれをクリックすることで実行することも可能です。成功すると「All tests passed!」と出力されます。

$ flutter test

00:03 +0: ... CounterRepository Test fetchCounter 値が保存されている場合、その値をそのまま返すこと
00:03 +1: ... CounterRepository Test fetchCounter 値が保存されている場合、その値をそのまま返すこと
00:03 +1: ... CounterRepository Test fetchCounter 値が保存されていない(null)場合、デフォルト値の0を返すこと
00:03 +2: ... CounterRepository Test fetchCounter 値が保存されていない(null)場合、デフォルト値の0を返すこと  
00:03 +2: ... CounterRepository Test updateCounter 値を保存する際、DataSourceのsaveIntが正しい引数で呼ばれること
00:04 +2: ... CounterRepository Test updateCounter 値を保存する際、DataSourceのsaveIntが正しい引数で呼ばれること
00:04 +3: ... CounterRepository Test updateCounter 値を保存する際、DataSourceのsaveIntが正しい引数で呼ばれること
00:04 +3: All tests passed!

テストカバレッジの取得

テストカバレッジを取得するには--coverageを付与して実行します。

$ flutter test --coverage

実行するとcoverage/lcov.infoが生成されます。このままでは読めないのでgenhtmlなどを使用してHTMLに変換すればブラウザで確認できるようになります。

$ brew install lcov
$ genhtml coverage/lcov.info -o coverage/html
$ open coverage/html/index.html

まだまだ勉強中ですので間違っている点や至らぬ点がありましたら教えていただけると助かります。

ご覧いただきありがとうございました。

Search Box

Sponsor

ProFile

ame

趣味:読書,プログラミング学習,サイト制作,ブログ

IT嫌いを克服するためにITパスを取得しようと勉強してからサイト制作が趣味に変わりました笑今はCMSを使わずこのサイトを完全自作でサイト運営中〜

New Article

index