Flutter 中的單元測試:從工作流基礎到復雜場景

已發表: 2022-09-21

對 Flutter 的興趣空前高漲,而且早就該出現了。 Google 的開源 SDK 與 Android、iOS、macOS、Web、Windows 和 Linux 兼容。 一個 Flutter 代碼庫支持所有這些。 單元測試有助於交付一致且可靠的 Flutter 應用程序,通過在組裝之前先發製人地提高代碼質量來確保防止錯誤、缺陷和缺陷。

在本教程中,我們將分享 Flutter 單元測試的工作流程優化,演示基本的 Flutter 單元測試,然後轉向更複雜的 Flutter 測試用例和庫。

Flutter 中的單元測試流程

我們在 Flutter 中實現單元測試的方式與在其他技術堆棧中的方式非常相似:

  1. 評估代碼。
  2. 設置數據模擬。
  3. 定義測試組。
  4. 為每個測試組定義測試功能簽名。
  5. 編寫測試。

為了演示單元測試,我準備了一個示例 Flutter 項目,並鼓勵您在閒暇時使用和測試代碼。 該項目使用外部 API 來獲取並顯示我們可以按國家/地區過濾的大學列表。

關於 Flutter 工作原理的幾點說明: 該框架通過在創建項目時自動加載flutter_test庫來促進測試。 該庫使 Flutter 能夠讀取、運行和分析單元測試。 Flutter 還會自動創建用於存儲測試的test文件夾。 避免重命名和/或移動test文件夾至關重要,因為這會破壞其功能,從而影響我們運行測試的能力。 在我們的測試文件名中包含_test.dart也很重要,因為這個後綴是 Flutter 識別測試文件的方式。

測試目錄結構

為了在我們的項目中促進單元測試,我們使用乾淨的架構和依賴注入 (DI) 實現了 MVVM,正如為源代碼子文件夾選擇的名稱所證明的那樣。 MVVM 和 DI 原則的結合確保了關注點的分離:

  1. 每個項目類都支持一個目標。
  2. 類中的每個函數只滿足自己的作用域。

我們將為我們將要編寫的測試文件創建一個有組織的存儲空間,在這個系統中,測試組將具有易於識別的“家”。 鑑於 Flutter 要求在test文件夾中定位測試,讓我們鏡像我們的源代碼在test的文件夾結構。 然後,當我們編寫測試時,我們會將其存儲在適當的子文件夾中:就像乾淨的襪子放在梳妝台的襪子抽屜裡,折疊的襯衫放在襯衫抽屜裡一樣, Model類的單元測試放在一個名為model的文件夾中, 例如。

具有兩個一級文件夾的文件夾結構:lib 和 test。嵌套在 lib 下的是 features 文件夾,進一步嵌套的是 university_feed,進一步嵌套的是 data。數據文件夾包含存儲庫和源文件夾。嵌套在源文件夾下面的是網絡文件夾。嵌套在網絡下的是端點和模型文件夾,以及 university_remote_data_source.dart 文件。在模型文件夾中是 api_university_model.dart 文件。與前面提到的 university_feed 文件夾處於同一級別的是 domain 和 presentation 文件夾。嵌套在域下的是用例文件夾。嵌套在演示文稿下方的是模型和屏幕文件夾。前面提到的測試文件夾的結構模仿了 lib 的結構。嵌套在 test 文件夾下的是 unit_test 文件夾,其中包含 university_feed 文件夾。它的文件夾結構與上面的 university_feed 文件夾相同,其 dart 文件的名稱後附加了“_test”。
項目的測試文件夾結構鏡像源代碼結構

採用這個文件系統可以為項目建立透明度,並為團隊提供一種簡單的方法來查看我們的代碼的哪些部分具有相關的測試。

我們現在準備將單元測試付諸實踐。

一個簡單的 Flutter 單元測試

我們將從model類(在源代碼的data層中)開始,並將我們的示例限制為僅包含一個modelApiUniversityModel 。 這個類有兩個功能:

  • 通過使用Map模擬 JSON 對象來初始化我們的模型。
  • 建立University數據模型。

為了測試模型的每個功能,我們將自定義前面描述的通用步驟:

  1. 評估代碼。
  2. 設置數據模擬:我們將定義服務器對 API 調用的響應。
  3. 定義測試組:我們將有兩個測試組,每個功能一個。
  4. 為每個測試組定義測試功能簽名。
  5. 編寫測試。

在評估我們的代碼之後,我們準備完成我們的第二個目標:在ApiUniversityModel類中設置特定於兩個函數的數據模擬。

為了模擬第一個函數(通過使用Map模擬 JSON 來初始化我們的模型), fromJson ,我們將創建兩個Map對象來模擬函數的輸入數據。 我們還將創建兩個等效的ApiUniversityModel對象,以使用提供的輸入來表示函數的預期結果。

為了模擬第二個函數(構建University數據模型) toDomain ,我們將創建兩個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"], ); }

接下來,對於我們的第三個和第四個目標,我們將添加描述性語言來定義我們的測試組和測試函數簽名:

 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', () {}); }); }

我們定義了兩個測試的簽名來檢查fromJson函數,以及兩個檢查toDomain函數。

為了實現我們的第五個目標並編寫測試,讓我們使用 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); }); }); }

完成我們的五個目標後,我們現在可以從 IDE 或命令行運行測試。

屏幕截圖表明五分之五的測試通過了。標題為:運行:api_university_model_test.dart 中的測試。屏幕左側顯示:Test results---loading api_university_model_test.dart---api_university_model_test.dart---Test ApiUniversityModel從json初始化---Test using json one---Test using json two---Tests ApiUniversityModel toDomain ---Test toDomain using json one---Test toDomain using json 二。屏幕右側顯示:通過的測試:五個測試中的五個---flutter test 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); }

現在我們已經創建了攔截器來模擬我們的網絡響應,我們可以定義我們的測試組和測試函數簽名。

在我們的例子中,我們只有一個函數要測試( getUniversitiesByCountry ),所以我們將只創建一個測試組。 我們將測試我們的函數對三種情況的響應:

  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})的一部分,這表明UniversityRemoteDataSource使用UniversityEndpoint類來實現其範圍,因此這是我們將模擬的類。

用 Mockito 模擬

在我們之前的示例中,我們使用自定義NetworkInterceptor手動模擬了Dio客戶端的請求適配器。 在這裡,我們正在嘲笑整個班級。 手動操作——模擬一個類及其函數——會很耗時。 幸運的是,模擬庫旨在處理此類情況,並且可以輕鬆生成模擬類。 讓我們使用 mockito 庫,Flutter 中用於模擬的行業標準庫。

為了通過Mockito進行模擬,我們首先在測試代碼之前添加註釋“ @GenerateMocks([class_1,class_2,…]) ”——就在void main() {}函數上方。 在註釋中,我們將包含一個類名列表作為參數(代替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 單元測試:卓越用戶體驗的關鍵

如果您已經將單元測試整合到您的 Flutter 項目中,那麼本文可能已經介紹了一些您可以注入到您的工作流程中的新選項。 在本教程中,我們展示了將單元測試納入您的下一個 Flutter 項目是多麼簡單,以及如何應對更細微的測試場景的挑戰。 你可能再也不想跳過 Flutter 中的單元測試了。

Toptal 工程博客的編輯團隊感謝 Matija Becirevic 和 Paul Hoskins 審閱了本文中提供的代碼示例和其他技術內容。