Flutter での単体テスト: ワークフローの要点から複雑なシナリオまで
公開: 2022-09-21Flutter への関心はこれまでになく高まっています。 Google のオープンソース SDK は、Android、iOS、macOS、ウェブ、Windows、および Linux と互換性があります。 単一の Flutter コードベースがそれらすべてをサポートします。 また、単体テストは、一貫性のある信頼性の高い Flutter アプリを提供するのに役立ち、組み立てる前にコードの品質を事前に改善することで、エラー、欠陥、欠陥を防ぎます。
このチュートリアルでは、Flutter 単体テストのワークフローの最適化を共有し、基本的な Flutter 単体テストを示してから、より複雑な Flutter テスト ケースとライブラリに進みます。
Flutterでの単体テストの流れ
他のテクノロジー スタックで行っているのとほぼ同じ方法で、Flutter でユニット テストを実装します。
- コードを評価します。
- データのモックを設定します。
- テスト グループを定義します。
- 各テスト グループのテスト関数シグネチャを定義します。
- テストを書きます。
単体テストを実演するために、サンプルの Flutter プロジェクトを用意しました。コードを自由に使用してテストすることをお勧めします。 このプロジェクトでは、外部 API を使用して、国別にフィルター処理できる大学のリストを取得して表示します。
Flutter の仕組みに関するいくつかの注意事項: フレームワークは、プロジェクトの作成時にflutter_test
ライブラリを自動ロードすることにより、テストを容易にします。 このライブラリにより、Flutter は単体テストの読み取り、実行、分析を行うことができます。 Flutter は、テストを保存するtest
フォルダーも自動作成します。 test
フォルダーの名前変更や移動は避けることが重要です。これにより機能が損なわれ、テストを実行できなくなるためです。 この接尾辞は Flutter がテスト ファイルを認識する方法であるため、テスト ファイル名に_test.dart
を含めることも不可欠です。
ディレクトリ構造のテスト
私たちのプロジェクトで単体テストを促進するために、ソース コードのサブフォルダーに選択された名前で証明されているように、クリーンなアーキテクチャと依存関係の挿入 (DI) を備えた MVVM を実装しました。 MVVM と DI の原則を組み合わせることで、懸念事項の分離が保証されます。
- 各プロジェクト クラスは、1 つの目的をサポートします。
- クラス内の各関数は、独自のスコープのみを満たします。
作成するテスト ファイル用に整理されたストレージ スペースを作成します。これは、テストのグループが簡単に識別できる「ホーム」を持つシステムです。 test
フォルダー内にテストを配置するという Flutter の要件に照らして、 test
の下にソース コードのフォルダー構造をミラーリングしましょう。 次に、テストを作成するときに、それを適切なサブフォルダーに保存します。きれいな靴下がドレッサーの靴下の引き出しに入れられ、折りたたまれたシャツがシャツの引き出しに入れられるのと同じように、 Model
クラスの単体テストはmodel
という名前のフォルダーに入れられます。 、 例えば。
このファイル システムを採用すると、プロジェクトに透過性が組み込まれ、コードのどの部分にテストが関連付けられているかをチームが簡単に確認できるようになります。
これで、単体テストを実行する準備が整いました。
シンプルな Flutter 単体テスト
model
クラス (ソース コードのdata
レイヤー内) から始め、例を制限して、 model
クラスApiUniversityModel
を 1 つだけ含めます。 このクラスには 2 つの機能があります。
- JSON オブジェクトを
Map
でモックして、モデルを初期化します。 -
University
のデータ モデルを構築します。
モデルの各機能をテストするために、前述の一般的な手順をカスタマイズします。
- コードを評価します。
- データのモックを設定する: API 呼び出しに対するサーバーの応答を定義します。
- テスト グループを定義します。関数ごとに 1 つずつ、合計 2 つのテスト グループを作成します。
- 各テスト グループのテスト関数シグネチャを定義します。
- テストを書きます。
コードを評価したら、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 またはコマンド ラインからテストを実行できるようになりました。
ターミナルで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 つの状況に対する関数の応答をテストします。
-
Dio
クラスの関数は実際にgetUniversitiesByCountry
呼び出されますか? - API リクエストがエラーを返した場合、どうなりますか?
- 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})
の一部であることを確認しました。これは、 UniversityRemoteDataSource
がUniversityEndpoint
クラスを使用してそのスコープを満たすことを示しているため、このクラスをモックします。
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
で始まるモック クラスが含まれます。 たとえば、 UniversityEndpoint
はMockUniversityEndpoint
になります。
次に、 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 に感謝の意を表します。