Flutter 中的單元測試:從工作流基礎到復雜場景
已發表: 2022-09-21對 Flutter 的興趣空前高漲,而且早就該出現了。 Google 的開源 SDK 與 Android、iOS、macOS、Web、Windows 和 Linux 兼容。 一個 Flutter 代碼庫支持所有這些。 單元測試有助於交付一致且可靠的 Flutter 應用程序,通過在組裝之前先發製人地提高代碼質量來確保防止錯誤、缺陷和缺陷。
在本教程中,我們將分享 Flutter 單元測試的工作流程優化,演示基本的 Flutter 單元測試,然後轉向更複雜的 Flutter 測試用例和庫。
Flutter 中的單元測試流程
我們在 Flutter 中實現單元測試的方式與在其他技術堆棧中的方式非常相似:
- 評估代碼。
- 設置數據模擬。
- 定義測試組。
- 為每個測試組定義測試功能簽名。
- 編寫測試。
為了演示單元測試,我準備了一個示例 Flutter 項目,並鼓勵您在閒暇時使用和測試代碼。 該項目使用外部 API 來獲取並顯示我們可以按國家/地區過濾的大學列表。
關於 Flutter 工作原理的幾點說明: 該框架通過在創建項目時自動加載flutter_test
庫來促進測試。 該庫使 Flutter 能夠讀取、運行和分析單元測試。 Flutter 還會自動創建用於存儲測試的test
文件夾。 避免重命名和/或移動test
文件夾至關重要,因為這會破壞其功能,從而影響我們運行測試的能力。 在我們的測試文件名中包含_test.dart
也很重要,因為這個後綴是 Flutter 識別測試文件的方式。
測試目錄結構
為了在我們的項目中促進單元測試,我們使用乾淨的架構和依賴注入 (DI) 實現了 MVVM,正如為源代碼子文件夾選擇的名稱所證明的那樣。 MVVM 和 DI 原則的結合確保了關注點的分離:
- 每個項目類都支持一個目標。
- 類中的每個函數只滿足自己的作用域。
我們將為我們將要編寫的測試文件創建一個有組織的存儲空間,在這個系統中,測試組將具有易於識別的“家”。 鑑於 Flutter 要求在test
文件夾中定位測試,讓我們鏡像我們的源代碼在test
的文件夾結構。 然後,當我們編寫測試時,我們會將其存儲在適當的子文件夾中:就像乾淨的襪子放在梳妝台的襪子抽屜裡,折疊的襯衫放在襯衫抽屜裡一樣, Model
類的單元測試放在一個名為model
的文件夾中, 例如。
採用這個文件系統可以為項目建立透明度,並為團隊提供一種簡單的方法來查看我們的代碼的哪些部分具有相關的測試。
我們現在準備將單元測試付諸實踐。
一個簡單的 Flutter 單元測試
我們將從model
類(在源代碼的data
層中)開始,並將我們的示例限制為僅包含一個model
類ApiUniversityModel
。 這個類有兩個功能:
- 通過使用
Map
模擬 JSON 對象來初始化我們的模型。 - 建立
University
數據模型。
為了測試模型的每個功能,我們將自定義前面描述的通用步驟:
- 評估代碼。
- 設置數據模擬:我們將定義服務器對 API 調用的響應。
- 定義測試組:我們將有兩個測試組,每個功能一個。
- 為每個測試組定義測試功能簽名。
- 編寫測試。
在評估我們的代碼之後,我們準備完成我們的第二個目標:在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 或命令行運行測試。
在終端,我們可以通過輸入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
),所以我們將只創建一個測試組。 我們將測試我們的函數對三種情況的響應:
-
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
客戶端的請求適配器。 在這裡,我們正在嘲笑整個班級。 手動操作——模擬一個類及其函數——會很耗時。 幸運的是,模擬庫旨在處理此類情況,並且可以輕鬆生成模擬類。 讓我們使用 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 審閱了本文中提供的代碼示例和其他技術內容。