Flutter での単体テスト: ワークフローの要点から複雑なシナリオまで

公開: 2022-09-21

Flutter への関心はこれまでになく高まっています。 Google のオープンソース SDK は、Android、iOS、macOS、ウェブ、Windows、および Linux と互換性があります。 単一の Flutter コードベースがそれらすべてをサポートします。 また、単体テストは、一貫性のある信頼性の高い Flutter アプリを提供するのに役立ち、組み立てる前にコードの品質を事前に改善することで、エラー、欠陥、欠陥を防ぎます。

このチュートリアルでは、Flutter 単体テストのワークフローの最適化を共有し、基本的な Flutter 単体テストを示してから、より複雑な Flutter テスト ケースとライブラリに進みます。

Flutterでの単体テストの流れ

他のテクノロジー スタックで行っているのとほぼ同じ方法で、Flutter でユニット テストを実装します。

  1. コードを評価します。
  2. データのモックを設定します。
  3. テスト グループを定義します。
  4. 各テスト グループのテスト関数シグネチャを定義します。
  5. テストを書きます。

単体テストを実演するために、サンプルの Flutter プロジェクトを用意しました。コードを自由に使用してテストすることをお勧めします。 このプロジェクトでは、外部 API を使用して、国別にフィルター処理できる大学のリストを取得して表示します。

Flutter の仕組みに関するいくつかの注意事項: フレームワークは、プロジェクトの作成時にflutter_testライブラリを自動ロードすることにより、テストを容易にします。 このライブラリにより、Flutter は単体テストの読み取り、実行、分析を行うことができます。 Flutter は、テストを保存するtestフォルダーも自動作成します。 testフォルダーの名前変更や移動は避けることが重要です。これにより機能が損なわれ、テストを実行できなくなるためです。 この接尾辞は Flutter がテスト ファイルを認識する方法であるため、テスト ファイル名に_test.dartを含めることも不可欠です。

ディレクトリ構造のテスト

私たちのプロジェクトで単体テストを促進するために、ソース コードのサブフォルダーに選択された名前で証明されているように、クリーンなアーキテクチャと依存関係の挿入 (DI) を備えた MVVM を実装しました。 MVVM と DI の原則を組み合わせることで、懸念事項の分離が保証されます。

  1. 各プロジェクト クラスは、1 つの目的をサポートします。
  2. クラス内の各関数は、独自のスコープのみを満たします。

作成するテスト ファイル用に整理されたストレージ スペースを作成します。これは、テストのグループが簡単に識別できる「ホーム」を持つシステムです。 testフォルダー内にテストを配置するという Flutter の要件に照らして、 testの下にソース コードのフォルダー構造をミラーリングしましょう。 次に、テストを作成するときに、それを適切なサブフォルダーに保存します。きれいな靴下がドレッサーの靴下の引き出しに入れられ、折りたたまれたシャツがシャツの引き出しに入れられるのと同じように、 Modelクラスの単体テストはmodelという名前のフォルダーに入れられます。 、 例えば。

lib と test の 2 つの第 1 レベル フォルダーを含むファイル フォルダー構造。 lib の下にネストされているのは features フォルダーで、さらにネストされているのはUniversitys_feed、さらにネストされているのは data です。データ フォルダーには、リポジトリとソース フォルダーが含まれます。ソース フォルダーの下にネストされているのは、ネットワーク フォルダーです。ネットワークの下にネストされているのは、エンドポイント フォルダーとモデル フォルダー、およびUniversity_remote_data_source.dart ファイルです。モデル フォルダーには、api_university_model.dart ファイルがあります。前述のUniversities_feed フォルダーと同じレベルに、ドメイン フォルダーとプレゼンテーション フォルダーがあります。ドメインの下にネストされているのはユースケース フォルダーです。プレゼンテーションの下にネストされているのは、モデルと画面フォルダーです。前述のテスト フォルダーの構造は、lib の構造を模倣しています。 test フォルダーの下にネストされているのは、university_feed フォルダーを含む unit_test フォルダーです。そのフォルダー構造は、上記のuniversities_feedフォルダーと同じで、そのdartファイルには名前に「_test」が追加されています。
ソース コード構造を反映したプロジェクトのテスト フォルダ構造

このファイル システムを採用すると、プロジェクトに透過性が組み込まれ、コードのどの部分にテストが関連付けられているかをチームが簡単に確認できるようになります。

これで、単体テストを実行する準備が整いました。

シンプルな Flutter 単体テスト

modelクラス (ソース コードのdataレイヤー内) から始め、例を制限して、 modelクラスApiUniversityModelを 1 つだけ含めます。 このクラスには 2 つの機能があります。

  • JSON オブジェクトをMapでモックして、モデルを初期化します。
  • Universityのデータ モデルを構築します。

モデルの各機能をテストするために、前述の一般的な手順をカスタマイズします。

  1. コードを評価します。
  2. データのモックを設定する: API 呼び出しに対するサーバーの応答を定義します。
  3. テスト グループを定義します。関数ごとに 1 つずつ、合計 2 つのテスト グループを作成します。
  4. 各テスト グループのテスト関数シグネチャを定義します。
  5. テストを書きます。

コードを評価したら、2 番目の目的を達成する準備が整いました。それは、 ApiUniversityModelクラス内の 2 つの関数に固有のデータ モックを設定することです。

最初の関数をモックする (JSON をMapでモックしてモデルを初期化する) fromJsonとして、2 つのMapオブジェクトを作成し、関数の入力データをシミュレートします。 また、2 つの同等のApiUniversityModelオブジェクトを作成して、提供された入力で関数の期待される結果を表します。

2 番目の関数 ( Universityデータ モデルの構築) であるtoDomainをモックするために、2 つのUniversityオブジェクトを作成します。これは、以前にインスタンス化されたApiUniversityModelオブジェクトでこの関数を実行した後に期待される結果です。

 void main() { Map<String, dynamic> apiUniversityOneAsJson = { "alpha_two_code": "US", "domains": ["marywood.edu"], "country": "United States", "state-province": null, "web_pages": ["http://www.marywood.edu"], "name": "Marywood University" }; ApiUniversityModel expectedApiUniversityOne = ApiUniversityModel( alphaCode: "US", country: "United States", state: null, name: "Marywood University", websites: ["http://www.marywood.edu"], domains: ["marywood.edu"], ); University expectedUniversityOne = University( alphaCode: "US", country: "United States", state: "", name: "Marywood University", websites: ["http://www.marywood.edu"], domains: ["marywood.edu"], ); Map<String, dynamic> apiUniversityTwoAsJson = { "alpha_two_code": "US", "domains": ["lindenwood.edu"], "country": "United States", "state-province":"MJ", "web_pages": null, "name": "Lindenwood University" }; ApiUniversityModel expectedApiUniversityTwo = ApiUniversityModel( alphaCode: "US", country: "United States", state:"MJ", name: "Lindenwood University", websites: null, domains: ["lindenwood.edu"], ); University expectedUniversityTwo = University( alphaCode: "US", country: "United States", state: "MJ", name: "Lindenwood University", websites: [], domains: ["lindenwood.edu"], ); }

次に、3 番目と 4 番目の目的のために、説明的な言語を追加して、テスト グループとテスト関数のシグネチャを定義します。

 void main() { // Previous declarations group("Test ApiUniversityModel initialization from JSON", () { test('Test using json one', () {}); test('Test using json two', () {}); }); group("Test ApiUniversityModel toDomain", () { test('Test toDomain using json one', () {}); test('Test toDomain using json two', () {}); }); }

toDomain関数をチェックする 2 つのテストとfromJson関数をチェックする 2 つのテストの署名を定義しました。

5 番目の目的を達成してテストを作成するために、flutter_test ライブラリのexpectメソッドを使用して、関数の結果を期待値と比較しましょう。

 void main() { // Previous declarations group("Test ApiUniversityModel initialization from json", () { test('Test using json one', () { expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson), expectedApiUniversityOne); }); test('Test using json two', () { expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson), expectedApiUniversityTwo); }); }); group("Test ApiUniversityModel toDomain", () { test('Test toDomain using json one', () { expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson).toDomain(), expectedUniversityOne); }); test('Test toDomain using json two', () { expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson).toDomain(), expectedUniversityTwo); }); }); }

5 つの目標を達成したので、IDE またはコマンド ラインからテストを実行できるようになりました。

5 つのテストのうち 5 つが合格したことを示すスクリーンショット。ヘッダーの読み取り: Run: tests in api_university_model_test.dart.画面の左側のパネルには次のように表示されます: テスト結果--- api_university_model_test.dart の読み込み---api_university_model_test.dart---json からの ApiUniversityModel 初期化のテスト---json 1 を使用したテスト---json 2 を使用したテスト---ApiUniversityModel toDomain のテスト---json one を使用して toDomain をテストします---json two を使用して toDomain をテストします。画面の右側のパネルには次のように表示されます: テストに合格しました: 5 つのテストのうちの 5 つ --- フラッター テスト test/unit_test/universities_feed/data/source/network/model/api_university_model_test.dart

ターミナルでflutter testコマンドを入力して、 testフォルダー内に含まれるすべてのテストを実行し、テストが成功することを確認できます。

または、 flutter test --plain-name "ReplaceWithName"コマンドを入力し、 ReplaceWithNameをテストまたはテスト グループの名前に置き換えて、単一のテストまたはテスト グループを実行することもできます。

Flutter でのエンドポイントの単体テスト

依存関係のない単純なテストを完了したので、さらに興味深い例を見てみましょう。 endpointクラスをテストします。そのスコープには以下が含まれます。

  • サーバーへの API 呼び出しを実行します。
  • API JSON 応答を別の形式に変換します。

コードを評価した後、flutter_test ライブラリのsetUpメソッドを使用して、テスト グループ内のクラスを初期化します。

 group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); }

API へのネットワーク リクエストを作成するには、必要なコードのほとんどを生成するレトロフィット ライブラリを使用することを好みます。 UniversityEndpointクラスを適切にテストするために、dio ライブラリ ( Retrofitが API 呼び出しを実行するために使用する) に、カスタム応答アダプターを介してDioクラスの動作をモックすることにより、目的の結果を返すように強制します。

カスタム ネットワーク インターセプター モック

DI を介してUniversityEndpointクラスを構築したため、モックが可能です。 ( UniversityEndpointクラスがDioクラスを単独で初期化する場合、クラスの動作を模倣する方法はありません。)

Dioクラスの動作を模擬するには、 Retrofitライブラリ内で使用されるDioメソッドを知る必要がありますが、 Dioに直接アクセスすることはできません。 したがって、カスタム ネットワーク応答インターセプターを使用してDioをモックします。

 class DioMockResponsesAdapter extends HttpClientAdapter { final MockAdapterInterceptor interceptor; DioMockResponsesAdapter(this.interceptor); @override void close({bool force = false}) {} @override Future<ResponseBody> fetch(RequestOptions options, Stream<Uint8List>? requestStream, Future? cancelFuture) { if (options.method == interceptor.type.name.toUpperCase() && options.baseUrl == interceptor.uri && options.queryParameters.hasSameElementsAs(interceptor.query) && options.path == interceptor.path) { return Future.value(ResponseBody.fromString( jsonEncode(interceptor.serializableResponse), interceptor.responseCode, headers: { "content-type": ["application/json"] }, )); } return Future.value(ResponseBody.fromString( jsonEncode( {"error": "Request doesn't match the mock interceptor details!"}), -1, statusMessage: "Request doesn't match the mock interceptor details!")); } } enum RequestType { GET, POST, PUT, PATCH, DELETE } class MockAdapterInterceptor { final RequestType type; final String uri; final String path; final Map<String, dynamic> query; final Object serializableResponse; final int responseCode; MockAdapterInterceptor(this.type, this.uri, this.path, this.query, this.serializableResponse, this.responseCode); }

ネットワーク応答をモックするインターセプターを作成したので、テスト グループとテスト関数シグネチャを定義できます。

この場合、テストする関数は 1 つだけ ( getUniversitiesByCountry ) であるため、テスト グループを 1 つだけ作成します。 3 つの状況に対する関数の応答をテストします。

  1. Dioクラスの関数は実際にgetUniversitiesByCountry呼び出されますか?
  2. API リクエストがエラーを返した場合、どうなりますか?
  3. API リクエストが期待どおりの結果を返す場合、何が起こるでしょうか?

テスト グループとテスト関数のシグネチャは次のとおりです。

 group("Test University Endpoint API calls", () { test('Test endpoint calls dio', () async {}); test('Test endpoint returns error', () async {}); test('Test endpoint calls and returns 2 valid universities', () async {}); });

テストを書く準備ができました。 テスト ケースごとに、対応する構成でDioMockResponsesAdapterのインスタンスを作成します。

 group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); test('Test endpoint calls dio', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 200, [], ); var result = await endpoint.getUniversitiesByCountry("us"); expect(result, <ApiUniversityModel>[]); }); test('Test endpoint returns error', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 404, {"error": "Not found!"}, ); List<ApiUniversityModel>? response; DioError? error; try { response = await endpoint.getUniversitiesByCountry("us"); } on DioError catch (dioError, _) { error = dioError; } expect(response, null); expect(error?.error, "Http status error [404]"); }); test('Test endpoint calls and returns 2 valid universities', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 200, generateTwoValidUniversities(), ); var result = await endpoint.getUniversitiesByCountry("us"); expect(result, expectedTwoValidUniversities()); }); });

エンドポイントのテストが完了したので、データ ソース クラスUniversityRemoteDataSourceをテストしましょう。 前に、 UniversityEndpointクラスがコンストラクターUniversityRemoteDataSource({UniversityEndpoint? universityEndpoint})の一部であることを確認しました。これは、 UniversityRemoteDataSourceUniversityEndpointクラスを使用してそのスコープを満たすことを示しているため、このクラスをモックします。

Mockitoで嘲笑する

前の例では、カスタムNetworkInterceptorを使用して、 Dioクライアントのリクエスト アダプターを手動でモックしました。 ここでは、クラス全体をモックしています。 クラスとその関数を手動でモックすると、時間がかかります。 幸いなことに、モック ライブラリはそのような状況を処理するように設計されており、最小限の労力でモック クラスを生成できます。 Flutter でのモック用の業界標準ライブラリである mockito ライブラリを使用してみましょう。

Mockitoをモックするには、まず、テストのコードの前、つまりvoid main() {}関数のすぐ上に「 @GenerateMocks([class_1,class_2,…]) 」という注釈を追加します。 注釈には、クラス名のリストをパラメーターとして含めます ( class_1,class_2…の代わりに)。

次に、テストと同じディレクトリにモック クラスのコードを生成する Flutter のflutter pub run build_runner buildコマンドを実行します。 結果のモック ファイルの名前は、テスト ファイル名と.mocks.dartを組み合わせたものになり、テストの.dartサフィックスが置き換えられます。 ファイルの内容には、名前が接頭辞Mockで始まるモック クラスが含まれます。 たとえば、 UniversityEndpointMockUniversityEndpointになります。

次に、 university_remote_data_source_test.dart.mocks.dart (モック ファイル) をuniversity_remote_data_source_test.dart (テスト ファイル) にインポートします。

次に、 setUp関数で、 MockUniversityEndpointを使用してUniversityEndpointをモックし、 UniversityRemoteDataSourceクラスを初期化します。

 import 'university_remote_data_source_test.mocks.dart'; @GenerateMocks([UniversityEndpoint]) void main() { late UniversityEndpoint endpoint; late UniversityRemoteDataSource dataSource; group("Test function calls", () { setUp(() { endpoint = MockUniversityEndpoint(); dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint); }); }

UniversityEndpointを正常にモックし、 UniversityRemoteDataSourceクラスを初期化しました。 これで、テスト グループとテスト関数のシグネチャを定義する準備が整いました。

 group("Test function calls", () { test('Test dataSource calls getUniversitiesByCountry from endpoint', () {}); test('Test dataSource maps getUniversitiesByCountry response to Stream', () {}); test('Test dataSource maps getUniversitiesByCountry response to Stream with error', () {}); });

これで、モッキング、テスト グループ、およびテスト関数のシグネチャが設定されました。 実際のテストを書く準備が整いました。

最初のテストでは、データ ソースが国情報のフェッチを開始するときにUniversityEndpoint関数が呼び出されるかどうかを確認します。 関数が呼び出されたときに各クラスがどのように反応するかを定義することから始めます。 UniversityEndpointクラスをモックしたので、 when( function_that_will_be_called ).then( what_will_be_returned )コード構造を使用して、このクラスを使用します。

テストしている関数は非同期 ( Futureオブジェクトを返す関数) であるため、 when(function name).thenanswer( (_) {modified function result} )コード構造を使用して結果を変更します。

getUniversitiesByCountry関数がUniversityEndpointクラス内のgetUniversitiesByCountry関数を呼び出すかどうかを確認するには、 when(...).thenAnswer( (_) {...} )を使用してUniversityEndpointクラス内のgetUniversitiesByCountry関数をモックします。

 when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[]));

応答をモックしたので、データ ソース関数を呼び出し、 verify関数を使用して、 UniversityEndpoint関数が呼び出されたかどうかを確認します。

 test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); });

同じ原則を使用して、関数がエンドポイントの結果を関連するデータ ストリームに正しく変換するかどうかを確認する追加のテストを作成できます。

 import 'university_remote_data_source_test.mocks.dart'; @GenerateMocks([UniversityEndpoint]) void main() { late UniversityEndpoint endpoint; late UniversityRemoteDataSource dataSource; group("Test function calls", () { setUp(() { endpoint = MockUniversityEndpoint(); dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint); }); test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); }); test('Test dataSource maps getUniversitiesByCountry response to Stream', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); expect( dataSource.getUniversitiesByCountry("test"), emitsInOrder([ const AppResult<List<University>>.loading(), const AppResult<List<University>>.data([]) ]), ); }); test( 'Test dataSource maps getUniversitiesByCountry response to Stream with error', () { ApiError mockApiError = ApiError( statusCode: 400, message: "error", errors: null, ); when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.error(mockApiError)); expect( dataSource.getUniversitiesByCountry("test"), emitsInOrder([ const AppResult<List<University>>.loading(), AppResult<List<University>>.apiError(mockApiError) ]), ); }); }); }

多くの Flutter 単体テストを実行し、モックに対するさまざまなアプローチを示しました。 私のサンプル Flutter プロジェクトを引き続き使用して、追加のテストを実行することをお勧めします。

Flutter 単体テスト: 優れた UX への鍵

Flutter プロジェクトに単体テストを既に組み込んでいる場合、この記事では、ワークフローに挿入できるいくつかの新しいオプションを紹介している可能性があります。 このチュートリアルでは、単体テストを次の Flutter プロジェクトに組み込むことがいかに簡単か、そしてより微妙なテスト シナリオの課題に取り組む方法を示しました。 Flutter で単体テストをスキップしたくない場合があります。

Toptal Engineering Blog の編集チームは、この記事で紹介したコード サンプルやその他の技術コンテンツをレビューしてくれた Matija Becirevic と Paul Hoskins に感謝の意を表します。