Flutter 中的单元测试:从工作流基础到复杂场景-LMLPHP

对 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 识别测试文件的方式。

测试目录结构

为了在项目中进行单元测试,使用干净的架构实现了 MVVM和依赖注入 (DI) ,正如为源代码子文件夹选择的名称所证明的那样。MVVM 和 DI 原则的结合确保了关注点分离:

1.每个项目类都支持一个目标。

2.类中的每个函数只完成它自己的范围。

给编写的测试文件创建一个有组织的存储空间,在这个系统中,测试组将具有易于识别的“家”。鉴于 Flutter 要求在测试文件夹中定位测试,我们将test目录下test文件组织成和源码相同的结构。然后,编写测试时,将其存储在适当的子文件夹中:就像干净的袜子放在梳妆台的袜子抽屉里,折叠的衬衫放在衬衫抽屉里一样,Model类的单元测试放在名为 model 的文件夹中 , 例如。

Flutter 中的单元测试:从工作流基础到复杂场景-LMLPHP

项目的测试文件夹结构反映了源代码结构,采用此文件系统可以使项目透明化,并为团队提供一种简单的方法来查看代码的哪些部分具有相关测试。现在准备将单元测试付诸实践。

一个简单的 Flutter 单元测试

现在将从model类(在源代码的data层中)开始,并将示例限制为仅包含一个model类 ApiUniversityModel。此类拥有两个功能:

●通过使用 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 或命令行运行测试。

Flutter 中的单元测试:从工作流基础到复杂场景-LMLPHP

在终端,可以通过输入 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构建命令,在与测试相同的目录中为我们的模拟类生成代码。 生成的模拟文件的名称将是测试文件名加上.mocks.dart的组合,替换测试的 .dart后缀。
该文件的内容将包括名称以前缀 Mock开头的模拟类。 例如,UniversityEndpoint 变为 MockUniversityEndpoint。
现在,将 university_remote_data_source_test.dart.mocks.dart(模拟文件)导入 university_remote_data_source_test.dart(测试文件)。
然后,在 setUp 函数中,通过使用 MockUniversityEndpoint并初始化 UniversityRemoteDataSource类来模拟 UniversityEndpoint:

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>[]));

现在已经模拟了响应,调用数据源函数并使用验证函数检查是否调用了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 中的单元测试了。

最后感谢每一个认真阅读我文章的人,礼尚往来总是要有的,虽然不是什么很值钱的东西,如果你用得到的话可以直接拿走:

Flutter 中的单元测试:从工作流基础到复杂场景-LMLPHP

这些资料,对于【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴上万个测试工程师们走过最艰难的路程,希望也能帮助到你!   

09-14 07:59