🧫

http_mock_adapterを使ってみる

2023/12/03に公開

Overview

dioを使ってAPI通信をやったことはあるが、テストコードを書いたことがなかった。mockkitを使ってテストをかけるそうだが、dioには専用のパッケージがあるらしく興味があり使ってみました!

公式より引用
http_mock_adapter は、テストで使用することを目的とした Dio の使いやすいモック パッケージです。 これは、要求と応答の通信を宣言的に模擬するためのさまざまな型とメソッドを提供します。

必要なパッケージ
https://pub.dev/packages/dio
https://pub.dev/packages/http_mock_adapter/example

pubspec.yamlにパッケージを追加します。mokkitとか他の入ってますが、こちらは入れなくて大丈夫です。

yaml
name: mock_app
description: A new Flutter project.
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev

# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1

environment:
  sdk: '>=3.1.3 <4.0.0'

# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
  flutter:
    sdk: flutter
  dio: ^5.4.0 # これが必要

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^5.4.3 # これが必要
  http_mock_adapter: ^0.6.1

  # The "flutter_lints" package below contains a set of recommended lints to
  # encourage good coding practices. The lint set provided by the package is
  # activated in the `analysis_options.yaml` file located at the root of your
  # package. See that file for information about deactivating specific lint
  # rules and activating additional ones.
  flutter_lints: ^2.0.0
  build_runner: ^2.4.7

# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

# The following section is specific to Flutter packages.
flutter:

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

  # To add assets to your application, add an assets section, like this:
  # assets:
  #   - images/a_dot_burr.jpeg
  #   - images/a_dot_ham.jpeg

  # An image asset can refer to one or more resolution-specific "variants", see
  # https://flutter.dev/assets-and-images/#resolution-aware

  # For details regarding adding assets from package dependencies, see
  # https://flutter.dev/assets-and-images/#from-packages

  # To add custom fonts to your application, add a fonts section here,
  # in this "flutter" section. Each entry in this list should have a
  # "family" key with the font family name, and a "fonts" key with a
  # list giving the asset and other descriptors for the font. For
  # example:
  # fonts:
  #   - family: Schyler
  #     fonts:
  #       - asset: fonts/Schyler-Regular.ttf
  #       - asset: fonts/Schyler-Italic.ttf
  #         style: italic
  #   - family: Trajan Pro
  #     fonts:
  #       - asset: fonts/TrajanPro.ttf
  #       - asset: fonts/TrajanPro_Bold.ttf
  #         weight: 700
  #
  # For details regarding fonts from package dependencies,
  # see https://flutter.dev/custom-fonts/#from-packages

解説を読んでみるが、どうやって使うのか詳しく書いていない?
Github Copilotに相談しながら、モックのサーバーにリクエストを送って、帰ってきたレスポンス毎にテストをするテストコードを作ってみました。

summary

テストの書き方についてですが、今回使うテスト用のメソッドについて解説しておきます。

group, setUp, testは、Flutterのテストフレームワークで使用される関数です。

  • group: テストケースをグループ化します。これにより、関連するテストを一緒にまとめて整理することができます。第一引数はグループの名前で、第二引数はグループ内のテストを定義する関数です。

  • setUp: 各テストケースが実行される前に実行される関数を設定します。これは通常、テストケースの初期設定を行うために使用されます。

  • test: 個々のテストケースを定義します。第一引数はテストケースの名前で、第二引数はテストを実行する関数です。この関数内でexpectを使用して結果を検証します。

📗以下に、これらの関数を使用したテストコードの例を示します。

group('String tests', () {
  setUp(() {
    // ここでテストケースの初期設定を行う
  });

  test('String.split() splits the string on the delimiter', () {
    var string = 'foo,bar,baz';
    expect(string.split(','), equals(['foo', 'bar', 'baz']));
  });

  test('String.trim() removes surrounding whitespace', () {
    var string = '  foo ';
    expect(string.trim(), equals('foo'));
  });
});

このコードでは、String.split()とString.trim()の2つのテストケースを定義しています。これらのテストケースはString testsという名前のグループにまとめられています。


🔨testディレクトリに、拡張子がファイル名_test.dartを作成してテストコードを書いてみました!

TestCode
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';

void main() async {
  // DioとDioAdapterインスタンスを作成します。lateをつけるのは、初期化が遅れることを示すためです。
  late Dio dio;
  late DioAdapter dioAdapter;
  // Dioのレスポンスを格納する変数を作成します。
  Response<dynamic> response;

  // group()を使ってテストをグループ化します。
  group('Accounts', () {
    // これは実際には存在しないURLです。
    const baseUrl = 'https://example.com';
    // テストで使用するユーザーの認証情報を作成します。
    const userCredentials = <String, dynamic>{
      'email': 'test@example.com',
      'password': '1234abcd',
    };
    // setUp()を使って、テストの前にDioAdapterを初期化します。
    setUp(() {
      // DioAdapterを初期化します。
      dio = Dio(BaseOptions(
        baseUrl: baseUrl, // baseUrlを設定します。
        /* validateStatusを設定します。これは、500未満のステータスコードを受け取った場合にのみ、
        レスポンスが成功したと見なされることを意味します。*/
        validateStatus: (status) {
          return status! < 500;
        },
      ));
      // この変数は、DioAdapterを使用してDioインスタンスをテストするために使用されます。
      dioAdapter =
          DioAdapter(dio: dio, matcher: const FullHttpRequestMatcher());
    });
    // ユーザーがサインアップすると、サーバーは200を返します。
    test('signs up user', () async {
      const route = '/signup';

      dioAdapter.onPost(
        route,
        (server) => server.reply(
          200,
          null,
          delay: const Duration(seconds: 1),
        ),
        data: userCredentials,
      );

      response = await dio.post(route, data: userCredentials);
      expect(response.statusCode, 200);
      debugPrint('statusCode: ${response.statusCode}');
      debugPrint('👻 200のテストが通りました!');
    });

    /*ユーザーがサインインするが400を返す。サーバーは無効な資格情報を返します。
    StatusCode: 400は、
    何らかのクライアント側のエラーであると分かったために、サーバーがそのリクエストを処理しない (できない) ことを表します
    */
    test('signs in user with invalid credentials', () async {
      const signInRoute = '/signin';

      dioAdapter.onPost(
        signInRoute,
        (server) => server.reply(
          400,
          null,
        ),
        data: userCredentials,
      );

      response = await dio.post(signInRoute, data: userCredentials);
      expect(response.statusCode, 400);
      debugPrint('statusCode: ${response.statusCode}');
      debugPrint('👻 400のテストが通りました!');
    });
    /*
    statusCode: 500は、
    サーバーが落ちているときに返されるエラーです。
    サーバーのレスポンスが、500だったらテストが成功する。
    */
    test('fetches account information with server error', () async {
      const accountRoute = '/account';

      final headers = <String, dynamic>{
        'Authentication': 'Bearer ACCESS_TOKEN',
      };
      // 500を返す
      dioAdapter.onGet(
        accountRoute,
        (server) => server.reply(
          500,
          null,
        ),
        headers: headers,
      );
      // try-catchでエラーをキャッチする
      try {
        // dio.get()でaccountRouteにアクセスする
        await dio.get(
          accountRoute,
          options: Options(headers: headers),
        );
      } catch (e) {
        // 例外処理が出たら、テストが成功する。
        expect(e, isA<DioException>());
        expect((e as DioException).response?.statusCode, 500);
        debugPrint('statusCode: ${(e).response?.statusCode}');
        debugPrint('👻 500のテストが通りました!');
      }
    });
  });
}

テストが成功するとこのようなログが表示されます💡

thoughts

dioを使用したプロジェクトに入ることになるっぽいので、テストコード書いたことなかったので、アウトプットのためにやってみました!
テストコード書くのって簡単と聞くけどそもそも知識ないと書けないので難しいですね😅

Discussion