【Flutter】Unit Testの基本的なやり方と業務で使えるUnit Test(Mockitoを使ったUnit Test)

こんにちは、株式会社Pentagonでアプリ開発をしている山田です。

今回は、FlutterでUnit Testのやり方について説明していきます。

【こんな人に読んで欲しい】

  • 初めてUnit Testを実施する方
  • 現在のプロダクトにUnit Testを入れたい方

【この記事を読むメリット】

導入するに当たってのやり方を学ぶことが出来ます。
Unit Test を実施し、自身のプロダクトの品質を上げることが出来ます。

【結論】

プロダクトを作る人たちにとって、テストをやることは大切。

目次

そもそもUnit Text(ユニットテスト)とは?

単体テストとも呼ばれているUnitTest(ユニットテスト)は、プログラムを構成する比較的小さな単位で、個々の機能を正しく果たしているかを検証するテストです。

この「プログラムを構成する比較的小さな単位」というのはプログラムで言うと、関数やメソッドに当たるところです。

例えば、パラメータを入れた関数が正しい値で返ってくるのか?メソッドは正しく動いているか?これらを確認するために実施します。

FlutterにおけるUnit Text(ユニットテスト)

Flutterでは、testと呼ばれるパッケージがあり、Unit Testを描くためのフレームワークを提供しています。
今回はこれを使い、テストを実装していきます。

インストール方法

インストールの方法は以下のpub.devに記載されています。こちらをご確認下さい。
https://pub.dev/packages/test/install

テストファイルの作成方法

例えば、user.dart をテストしたい場合、次のようなファイル構成にする必要があります。

counter_app/
  lib/
    user.dart
  test/
    user_test.dart

Testファイルは、user_test.dart と付ける必要があります。
つまり、テストをしたいファイル名の後ろに’_test’ を付けて下さい。そうするとテストファイルになります。

また、テストしたいファイルのディレクトリ構成が次の様な場合には、testファイルも同様のディレクトリを作り設置します。

counter_app/
  lib/
    model/
       user.dart
  test/
   model/
    user_test.dart

テストの実施

ここでは、具体的な事例を用いて、テストのやり方を実施していきます。

modelのテスト

実際の業務で用いられているmodelのテストを行います。
今回使用するmodelは次の通りです。
※コードに関しては一部改変しております。

abstract class Gift with _$Gift {
 @Implements(BaseModel)
 const factory Gift({
   int id,
   DateTime createdAt,
   DateTime updatedAt,
   String name,
   int amount,
   PublicImage image,
   DateTime getAt,
 }) = _Gift;

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

次に以下がテストコードです。(import文は省略しています。)

void main() {
 test('Gift model test', () {
   final gift = Gift(
     id: 999,
     name: 'unit test gift',
     amount: 999,
     image: PublicImage(
       id: 4854,
       image: SampleImage(
         url: 'https://text.jpg',
         thumb: Url(url: 'http://thumb.jpg'),
         small: Url(url: 'http://small.jpg'),
         medium: Url(url: 'http://medium.jpg'),
       ),
     ),
     getAt: null,
   );

   expect(gift.id, 999);
   expect(gift.image.image.url, 'https://text.jpg');
   expect(gift.image.image.thumb.url, 'http://thumb.jpg');
 });
}

解説をしていきます。
testに関しては、基本的に次の様な形で書いていきます。

test(テスト名, () {
  // 前処理
  expect(処理や変数, 処理による結果や変数の期待値);
});

expect()関数には、1つ目のパラメータに処理や変数で得られた値を入れ、2つ目のパラメータには自分が期待する値や結果を入れます。そうする事により、テストで得られた値と自分が期待する値を比較可能です。

gift変数には、テストになるデータを入れています。

このmodelをテストする目的としては、本来必要なパラメータが入っているか、他には型が正しいかなどをこのUnit Testを流すことによって確認するためです。

正しくテストが通ると次の様に結果が出力されます。

予測したデータと異なると、次の様な結果が出力されます。
ここでは、expect(gift.id, 989); と書き換えました。そうすると989と予測したけれども、999というデータが入ってきている。と言うエラーメッセージです。

もし、テストデータをjson データで扱いたい場合は以下の様にします。
例えば次の様なデータがあったとしましょう。

[
 {
   "id": 999,
   "created_at": "2033-04-06T14:55:23.000+09:00",
   "updated_at": "2044-04-06T14:55:23.000+09:00",
   "name": "testest",
   "amount": 30000,
   "image": {
     "id": 9999,
     "created_at": "2033-04-06T14:55:22.000+09:00",
     "updated_at": "2044-04-06T14:55:22.000+09:00",
     "image": {
       "url": "https://text.jpg",
       "thumb": {
         "url": "https://text.jpg"
       },
       "small": {
         "url": "https://text.jpg"
       },
       "medium": {
         "url": "https://text.jpg"
       }
     },
     "image_type": "gift"
   },
   "get_at": null
 }
]

このデータを使ってmodelをテストしたい場合は次の様になります。

void main() {
   test('Gift model test', () {
     final file = File('json/gift_test.json').readAsStringSync();
     final gifts = Gift.fromJson(
         (jsonDecode(file) as List).first as Map<String, dynamic>);

     expect(gifts.id, 998);
     expect(gifts.image.image.url, 'https://text.jpg');
     expect(gifts.image.image.thumb.url, 'https://text.jpg');
   });
}

ここでは、jsonファイルを読み込んだ後、Giftモデルに格納しています。

group()関数について

上記のコードでは出てきませんでしたが、group()関数というものがあります。
基本的な形は次の通りです。

void main() {
  group(このグループのテスト名, () {
    test(テスト1, () {
   ・・・テスト1のテスト・・・
    });

    test(テスト2, () {
   ・・・テスト2のテスト・・・
    });

    test(テスト3, () {
   ・・・テスト3のテスト・・・
    });
  });
}

このように、複数のテストをまとめてくれる関数です。
例えば、一つのクラスに複数のメソッドを同時にテストしたい時などはこれを使うと便利です。

ビジネスロジックのテスト

次に以下のコード内にある、isAnonymousUser をテストしてみます。

abstract class User with _$User {
 @Implements(BaseModel)
 const factory User({
   int id,
   DateTime createdAt,
   DateTime updatedAt,
   String name,
   String email,
 }) = _User;

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

extension UserExt on User {
 bool get isAnonymousUser {
   return RegExp(
           r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@anonymous.example.com")
       .hasMatch(email);
 }
}

テストコードは次の様になります。

void main() {
 test('User anonymous test', () {
   const user = User(
     id: 999,
     name: 'testUser',
     email: 'test@anonymous.example.com',
     identifier: 'hogehoge',
   );

   expect(user.isAnonymousUser, true);
 });
}

User変数にテスト用のユーザー情報を記載し、expect で isAnonumousUserにemail がanonumous userか否かを確認させています。

例えば次の様にemailを変更すると、以下の画像の様にメッセージが出力されます。

email: 'test@example.com',

ここでは、trueと予想していましたが、結果がfalseと返った為、上記の様に誤っているというメッセージがでます。

Dio が使われているプロダクト向けのMockitoを使った Unit Test

Dioとは、HTTP Clientのパッケージで、インターセプターやグローバル設定、ファイルのダウンロードやタイムアウトといったものをサポートします。

以下がDioのパッケージです。
https://pub.dev/packages/dio

今回はUnit Testで Http通信をしたかの様に動かし、きちんと関数がデータを受け取れているかのを確認するための方法を、Dioを使った場合のテストを記載します。

Mockito とは?

Mockitoについて少し解説します。
Mockitoとは、Javaでも使われている、Unit testのためのモックフレームワークです。テストでモックオブジェクトを直感的に操作できることを目的として開発されました。
今回はFlutter用に作られたMockito パッケージを使ってテストを書いていきます。

以下がMockitoのパッケージです。
https://pub.dev/packages/mockito

以下が今回テストする、API
側のコードです。
一部テスト用に改変と省略をしております。

 Future<dynamic> post(String uri, Map<String, dynamic> params,
      {FormData data}) async {
     //テスト用に追記
    if (params['password'] == 'example') {
      return true;
    }
    //
    try {
      _dio.options.headers = await headers;
      final response =
          await _dio.post<dynamic>(uri, queryParameters: params, data: data);
      return commonBehavior(response);
    } catch (e) {
      rethrow;
    }
  }

今回はこのpost()関数をテストしていきます。
今回はテスト用で結果を分かりやすくする為に、paramsで受け取ったpasswordがexampleだった場合、trueを返す様に追加しています。

使用したテストコードは以下の通りです。

class DioAdapterMock extends Mock implements HttpClientAdapter {}

void main() {
  final Dio dio = Dio();
  DioAdapterMock dioAdapterMock;
  AppService appService;
  const baseUrl = 'https://sample.com/api/v1';

  const params = <String, dynamic>{
    'email': 'example.com',
    'password': 'example',
    'identifier': 'example'
  };

  setUp(() {
    TestWidgetsFlutterBinding.ensureInitialized();
    const MethodChannel channel =
        MethodChannel('plugins.it_nomads.com/flutter_secure_storage');
    channel.setMockMethodCallHandler((methodCall) async {
      return '.';
    });
    dioAdapterMock = DioAdapterMock();
    dio.httpClientAdapter = dioAdapterMock;
    appService = AppService(dio: dio);
  });

  group('Get method', () {
    test('get response from post', () async {
      final responsePayload =
          jsonEncode({'response_code': '1000', 'body': 'testBody'});
      final httpResponse = ResponseBody.fromString(
        responsePayload,
        200,
        headers: {
          'Client': ['test'],
          'Access-Token': ['test'],
          'Uid': ['test15@gmail.com'],
        },
      );

      when(dioAdapterMock.fetch(any, any, any))
          .thenAnswer((_) async => httpResponse);

      final response = await appService.post(baseUrl, params);
      final expected =
          jsonEncode({'response_code': '1000', 'body': 'testBody'});

      expect(response, equals(expected));
      expect(response, true);
    });
  });
}

上から解説していきます。

class DioAdapterMock extends Mock implements HttpClientAdapter {}

ここで、Mock用のクラスを定義します。上記のようにMock対象クラスをimplementsします。今回はdioを使用しているため、HttpClientAdapterをimplementsしましょう。

final Dio dio = Dio();
 DioAdapterMock dioAdapterMock;
 AppService appService;
 const baseUrl = 'https://sample.com/api/v1';

ここでは、Dioをインスタンス化させています。
dioAdapterMockはこのテストのクラス名です。
appServiceはテスト対象のクラスです。
baseUrlは適当な名前で構いません。

const params = <String, dynamic>{
  'email': 'example.com',
  'password': 'example',
  'identifier': 'example'
};

パラメータはテスト対象のコード引数として渡しているため、テスト用に準備をしています。

setUp(() {
 TestWidgetsFlutterBinding.ensureInitialized();
 const MethodChannel channel =
     MethodChannel('plugins.it_nomads.com/flutter_secure_storage');
 channel.setMockMethodCallHandler((methodCall) async {
   return '.';
 });
 dioAdapterMock = DioAdapterMock();
 dio.httpClientAdapter = dioAdapterMock;
 appService = AppService(dio: dio);
});

このsetUp()内では、Unit Testを行う前に初期化や定義をさせるために使用する場所です。

TestWidgetsFlutterBinding.ensureInitialized();
 const MethodChannel channel =
     MethodChannel('plugins.it_nomads.com/flutter_secure_storage');
 channel.setMockMethodCallHandler((methodCall) async {
   return '.';
 });

TestWidgetsFlutterBinding.ensureInitialized();はテスト用にフレームワークとFlutter用に結びつけます。またsetMockMethodCallHandlerを呼び出すことによって指定したプラグインがつながり、独自の値を返すことができます。

今回、MethodChannelが必要になった経緯としましては、テスト対象のアプリ側へテストする際、次の様なエラーメッセージを受け取りました。

MissingPluginException(No implementation found for method read on channel plugins.it_nomads.com/flutter_secure_storage)

テスト側ではflutter_secure_storageは使われていませんが、アプリ側で使われており、ネイティブ側に依存するプラグインでした。現在、ネイティブに影響しているプラグインはUnit Test では使えないので、プラグインを使えるようにするためこちらのコードが必要となりました。
以下参考資料です。
https://stackoverflow.com/questions/52028969/testing-flutter-code-that-uses-a-plugin-and-platform-channel

dioAdapterMock = DioAdapterMock();
dio.httpClientAdapter = dioAdapterMock;
appService = AppService(dio: dio);

ここではDioAdapterMock() をインスタンス化させ、dio情報を今回テストするAppService に渡しています。

group('Get method', () {
 test('get response from post', () async {
   final responsePayload =
          jsonEncode({'response_code': '1000', 'body': 'testBody'});
   final httpResponse = ResponseBody.fromString(
     responsePayload,
     200,
     headers: {
       'sampleClinet': ['test'],
       'sample': ['test'],
       'Uid': ['test15@gmail.com'],
     },
   );

   when(dioAdapterMock.fetch(any, any, any))
       .thenAnswer((_) async => httpResponse);

   final response = await appService.post(baseUrl, params);
   final expected =
          jsonEncode({'response_code': '1000', 'body': 'testBody'});

   expect(response, equals(expected));
 });
});

responsePayloadで送るテストデータを指定しています。
httpResponse = ResponseBody.fromString() で仮の通信後に返ってくるテストデータ、ステータスコード、header情報を格納します。
これは自分で決めれるもので、仮の通信を行った際にどの様なレスポンスを受け取れる様にするかを決めます。ここに使い方のドキュメントを追記しておきます。
https://pub.dev/documentation/dio/latest/dio/ResponseBody-class.html

when()で、mockにこれらのテストデータを送り、Httpレスポンスの準備をさせ、
appService.post(baseUrl, params);で AppService内のpost() にパラメータを送ります。
その後、expected変数で準備したデータと返ってきた値が正しいかどうかを確認します。

さて、今回 params 変数に持たせた'password' は 'example'でした。
postを通したのち、返ってきたのはtrueで、expectで予期しいていたデータは
final expected = jsonEncode({'response_code': '1000', 'body': 'testBody'});
なので、
expect()の結果は失敗となります。その時の結果は以下の画像です。

もしpasswrod が exampleではない場合は、responsePayload で準備したものが返ってくるので、テスト成功となります。

以上がDioが用いられた場合のAPIクライアントのUnit Testの方法です。

状態管理に関する Unit Test

こちらの状態管理に関するUnit Testですが、Widget Testに切り分けても良いでしょう。
しかし、今回は状態管理に関するUnit Testを行いたいと思います。

今回状態管理のUnit testで確認する箇所は、notifyListeners(); が正常に通っているかを確認します。

テスト対象のコードは次の箇所です。

 Future<void> loadSumPoints() async {
    _isPointsLoading = true;
    //テスト用コード
    _haveSumPoints = 1000;
    notifyListeners();
  }

今回は現在対応しているプロジェクトでテストを行っています。しかし、良い題材になる箇所が無かったため、コードを改変して使用しています。

次にテストコードです。

  group('State management test', () {
    test('gift notifyListeners() success test', () async {
      final giftScreenModel = GiftsScreenModel();
      var haveSumPoint = 0;
      giftScreenModel.addListener(() {
        haveSumPoint = giftScreenModel.haveSumPoints;
      });
      await giftScreenModel.loadSumPoints();
      expect(haveSumPoint, equals(1000));
    });
  });

確認すべきは、次のところです。

giftScreenModel.addListener(() {
    haveSumPoint = giftScreenModel.haveSumPoints;
});
   await giftScreenModel.loadSumPoints();
   expect(haveSumPoint, equals(1000));

giftScreenModel.addListener(() {}); で
giftScreenModel に addListener() を用いて値の変更をコールバックさせます。
その後、loadSumPoints()を通す事によりnotifyListeners(); を通す事によって、エラーがないかを確認し、ここで何もエラーがなければnotifyListeners(); でテストが成功になります。

最後に、expectで取得した変更が無いかを確認させています。
今回は取得した値giftScreenModel.haveSumPointsが1000なので、その値が1000とイコールかどうかを確認します。

まとめ

いかがでしたでしょうか?Unit Testを実施する事によって、早い段階でコードの品質を担保できます。
今回を機にUnit Testを書いてみてください!

参考文献

https://sahasuthpala.medium.com/unit-testing-in-dio-dart-package-91b7a78314bc
https://zenn.dev/omtians9425/articles/e2e1ce79c31413
https://docs.flutter.dev/development/data-and-backend/state-mgmt/simple
https://stackoverflow.com/questions/59932068/how-to-unit-test-whether-the-changenotifiers-notifylisteners-was-called-in-flut
https://docs.flutter.dev/cookbook/testing/unit/mocking
https://zuma-lab.com/posts/flutter-mockito-null-safety-unit-test
https://www.woolha.com/tutorials/flutter-dart-unit-testing-with-mockito

採用情報はこちら
目次