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 审阅了本文中提供的代码示例和其他技术内容。