【Flutter】APIテストを簡単に！http_mock_adapterの使い方入門

## はじめに
アプリケーション開発において、APIテスト大切なものであり、テストを行うことで、アプリケーションが外部のデータソースやサービスと正しく通信できることを保証できます。

`http_mock_adapter`は、FlutterのDioライブラリ用のテストツールで、実際のAPIサーバーにリクエストを送ることなく、APIレスポンスを模倣（モック）してテストを行うことが可能です。これにより、実際のサーバーがなくても、APIの動作を効率的にテストできます。

この記事では、http_mock_adapter を使用してAPIのモックを作成し、基本的なテストコードの書き方について解説します。

## 環境
・Flutter 3.16.4
・パッケージは以下を使用（本記事で使用するもののみ書いています。）

```pubspec.yaml
dependencies:
  freezed_annotation: ^2.4.1
  dio: ^5.4.0
  retrofit: ^4.0.3
  json_annotation: ^4.8.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.4.7
  freezed: ^2.4.6
  json_serializable: ^6.7.1
  retrofit_generator: ^8.0.6
  http_mock_adapter: ^0.6.1
```

今回のメインは以下になります。
・http_mock_adapter
・dio

https://pub.dev/packages/dio

https://pub.dev/packages/http_mock_adapter

※RetrofitやFreezedの使用方法等については本記事では深くは触れません。

## 使用するAPI
今回使用するAPIは、映画データベースを提供する人気のある`TMDB API`です。
このAPIは映画の情報や評価、ジャンルなど様々なデータを取得できます。

https://developer.themoviedb.org/docs


## プロジェクト構成
※構成はあくまで一例です。
![スクリーンショット 2023-12-21 15.15.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2658262/4273c62c-c26b-520a-712a-fce3aae86519.png)

- api：TMDB APIとの通信を管理するクラスを格納
- models： APIから取得したデータの構造を定義するモデルクラスを格納
- tests：テスト関連のファイルを含むディレクトリ
  - stubには、テスト用のモックデータ（例: movies_search.json）が格納されています。
  - fixture.dart はテストデータを読み込むためのユーティリティ関数格納
  - movies_search_test.dart はAPIのテストケースになります。

## テストイメージ
TMDB APIを使用して映画データを取得することを想定しています。テストのために、以下のようなmovies_search.jsonというstub（テスト用の模擬データ）を用意しました。

```movies_search.json
{
  "page": 1,
  "results": [
    {
      "id": 1678,
      "original_language": "ja",
      "overview": "原水爆実験の影響で、大戸島の伝説の怪獣ゴジラが復活し、東京に上陸。帝都は蹂躙され廃墟と化した。ゴジラ抹殺の手段はあるのか・・・。戦後の日本映画界に特撮怪獣映画というジャンルを築いた、記念すべきゴジラ映画第1作。核の恐怖を描いた、本多猪四郎の真摯な本編ドラマと、円谷英二のリアリズム溢れる特撮演出が絶妙のコンビネーションを見せ、「ゴジラ」の名を一躍世界に轟かせた傑作。",
      "poster_path": "/lNxPgdQ0RThTVGVgE3j5mzaP6Ku.jpg",
      "title": "ゴジラ",
    },
  ],
	"total_pages": 4,
}
```

このデータは、TMDB APIのURL（`https://api.themoviedb.org/3/`）に、エンドポイント（`search/movie`）とクエリパラメータ（例：`query=ゴジラ`）を設定した際のレスポンスの例です（例なので実際のものから抜粋しています）。

通常、APIを使用する際にはapi_keyを設定する必要がありますが、今回はテストのためにモックデータを使用するので、api_keyは不要です。


## 実装手順
APIテストを行う前に、いくつかの準備が必要です。
まず、API通信を管理するクラスと、APIから取得したデータの構造を定義するクラスを作成します。

### (1)データモデルの定義
TMDB APIから取得する映画データを表現するために、`MoviesListData`と`MoviesSearchData`の二つのモデルクラスを定義します。これらのクラスは、freezedライブラリを使用して作成します。

https://pub.dev/packages/freezed

#### ①MoviesListData
MoviesListData クラスは、個々の映画に関する情報（例えば、ID、タイトル、ポスターのパスなど）を保持します。

```movies_list_data.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'movies_list_data.freezed.dart';

part 'movies_list_data.g.dart';

@freezed
class MoviesListData with _$MoviesListData {
  const factory MoviesListData({
    int? id,
    String? title,
    @JsonKey(name: 'poster_path') String? posterPath,
  }) = _MoviesListData;

  factory MoviesListData.fromJson(Map<String, dynamic> json) =>
      _$MoviesListDataFromJson(json);

  const MoviesListData._();

  String get fullPosterPath => 'https://image.tmdb.org/t/p/w500/$posterPath';
}
```

#### ②MoviesSearchData
MoviesSearchData クラスは、映画検索の結果を表すために使用され、検索結果のリストやページ情報などを含みます。

```movies_search_data.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:mockito_sample/models/movies_search_data/movies_list_data.dart';

part 'movies_search_data.freezed.dart';

part 'movies_search_data.g.dart';

@freezed
class MoviesSearchData with _$MoviesSearchData {
  const factory MoviesSearchData({
    @Default(1) int page,
    @JsonKey(name: 'total_pages') @Default(1) int totalPages,
    @Default(<MoviesListData>[]) List<MoviesListData> results,
  }) = _MoviesSearchData;

  factory MoviesSearchData.fromJson(Map<String, dynamic> json) =>
      _$MoviesSearchDataFromJson(json);
}

```

### (2)APIのリクエストとレスポンスの処理
TMDB APIの
APIのリクエストとレスポンスの処理には、RetrofitとDioを組み合わせて使用します。
Retrofitは、APIリクエストを簡単に定義し、実行するためのツールです。
Retrofitを使用することで、APIからデータを取得するコードをより簡潔かつ効率的に書くことができます。

https://pub.dev/packages/retrofit

#### TmdbApiClient
このクラスは、TMDB APIの`search/movie`エンドポイントに対するリクエストを処理し、検索結果を`MoviesSearchData`というクラスのオブジェクトに変換します。

```api_client.dart
import 'package:mockito_sample/models/movies_search_data/movies_search_data.dart';
import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';

part 'api_client.g.dart';

@RestApi(baseUrl: 'https://api.themoviedb.org/3/')
abstract class TmdbApiClient {
  factory TmdbApiClient(Dio dio, {String baseUrl}) = _TmdbApiClient;

  @GET('search/movie')
  Future<MoviesSearchData> fetchSearchMoviesItems(
    @Query('query') String searchQuery, {
    @Query('page') int page = 1,
    @Query('language') String language = 'ja-JA',
  });
}
```

コードを生成は忘れないようにしましょう。
```
flutter pub run build_runner build --delete-conflicting-outputs
```

### (3)テストコードの実装
いよいよ、TMDB APIのテストコードを実装します。
このプロセスでは、まずテストデータを読み込むためのユーティリティ関数を作成し、次に実際のテストケースを記述します。

#### ①fixture.dartの準備
テストデータをファイルから読み込むために、`fixture.dart`というファイルを作成します。
このファイルには、テストデータファイル(stub)の内容を文字列として読み込むための関数`fixture`を定義します。

```fixture.dart
import 'dart:io';

String fixture(String name) {
  return File('test/stub/$name').readAsStringSync();
}
```

#### ②movies_search_test.dartの作成
`http_mock_adapter`を使用してAPIのレスポンスをモックし、`TmdbApiClient`クラスが期待通りに動作するかを検証します。

##### 全コード
```movies_search_test.dart
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import 'package:mockito_sample/api/api_client.dart';
import 'package:mockito_sample/models/movies_search_data/movies_search_data.dart';

import 'fixture.dart';

void main() {
  group('TmdbApiClient Tests', () {
    late Dio dio;
    late DioAdapter dioAdapter;
    late TmdbApiClient apiClient;
    // 各テストケースが実行される前に行う初期設定
    setUp(() {
      dio = Dio();
      dioAdapter = DioAdapter(dio: dio);
      apiClient = TmdbApiClient(dio);
    });
    // 映画データを返すことをテスト
    test('fetchSearchMoviesItems returns MoviesSearchData for valid response',
        () async {
      // スタブデータ
      final mockResponse = fixture('movies_search.json');
      // 成功したレスポンスを返すように設定
      dioAdapter.onGet(
        'search/movie',
        (server) => server.reply(200, jsonDecode(mockResponse)),
        queryParameters: {
          'query': 'ゴジラ',
          'language': 'ja-JA',
          'page': 1,
        },
      );
      // APIクライアントを通じてデータを取得
      final result = await apiClient.fetchSearchMoviesItems('ゴジラ');
      // レスポンスが MoviesSearchData型であることを検証
      expect(result, isA<MoviesSearchData>());
    });

    test('fetchSearchMoviesItems throws error for 404 response', () async {
      // エラーレスポンスを返すように設定
      dioAdapter.onGet(
        'search/movie',
        (server) => server.reply(404, {'message': 'Not Found'}),
        queryParameters: {
          'query': 'モニラ',
          'language': 'ja-JA',
          'page': 1,
        },
      );
      // APIメソッドがエラーを投げることを検証
      expect(() async => await apiClient.fetchSearchMoviesItems('モニラ'),
          throwsA(isA<DioException>()));
    });
  });
}
```

##### ①初期設定
各テストケースが実行される前に、Dio、DioAdapter、そしてTmdbApiClientクラスのインスタンスを作成します。
```dart
setUp(() {
  dio = Dio();
  dioAdapter = DioAdapter(dio: dio);
  apiClient = TmdbApiClient(dio);
});
```
##### ②test
`test`関数を使用して個々のテストケースを定義します。第一引数にはテストケースの名前を記述し、第二引数にはテストを実行する関数を渡します。
```dart
test('fetchSearchMoviesItems returns MoviesSearchData for valid response', () async {
  
});
```

##### ③スタブデータの使用
```dart
final mockResponse = fixture('movies_search.json');
```

##### ④APIレスポンスのモック
`dioAdapter.onGet`メソッドを使用して、特定のAPIリクエスト（今回はsearch/movie）に対するレスポンスをモックします。
これにより、実際のAPIサーバーにアクセスすることなく、APIの動作をテストできます。
```dart
dioAdapter.onGet(
    'search/movie',
    (server) => server.reply(200, jsonDecode(mockResponse)),
    queryParameters: {
      'query': 'ゴジラ',
      'language': 'ja-JA',
      'page': 1,
    },
);
```

https://pub.dev/documentation/http_mock_adapter/latest/http_mock_adapter/DioAdapter-class.html

##### ⑤テストケースの記述
今回は正常なレスポンスとエラーレスポンスの両方のシナリオに対するテストケースを記述してみました。

`expect`関数を使用して、APIクライアントが期待される結果（例えば、MoviesSearchData オブジェクト）を返すか、または適切なエラーを投げるかを検証します。

正常なレスポンスのテストケース例
```dart
final result = await apiClient.fetchSearchMoviesItems('ゴジラ');
// レスポンスが MoviesSearchData型であることを検証
expect(result, isA<MoviesSearchData>());
```

#### テストの実行と結果の確認
テストケースの記述が完了したら、実際にテストを実行して、すべてが期待通りに動作するかを確認します。
以下のコマンドを使用して、movies_search_test.dart ファイルに記述されたテストを実行できます。

```
flutter test test/movies_search_test.dart --reporter expanded
```

テストが全て正常に完了し、期待される結果が得られると以下のようになります。
![スクリーンショット 2023-12-21 17.28.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2658262/02ffeaf0-8d4d-aeb1-4897-6a372fbae4ad.png)

### 今回はAPIテストに特化しているため、全体的な開発プロセスについてはカバーしていませんが、実際の開発ではさまざまなテストケースを想定して書いていきたいです。