作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
大夏的Florea
验证专家 在工程
6 的经验

Dacian是一位资深的全栈移动应用程序开发人员, 颤振专家, 也是颤振框架的贡献者. 他帮助世界各地的公司设计和实施高质量的软件解决方案,并提供出色的用户体验.

专业知识

以前的角色

高级移动开发人员
分享

对颤振的兴趣正在下降 历史新高而且早就该这么做了. 谷歌的开源SDK兼容 安卓、iOS、macOS、web、Windows等 Linux. 一个颤振代码库就可以支持所有这些功能. 单元测试在交付一致和可靠的产品方面很有帮助 颤振 App,通过先发制人的改进来防止错误、缺陷和缺陷 代码质量 在组装之前.

在这个颤振测试教程中, 我们分享了颤振单元测试的工作流程优化, 演示一个基本的颤振单元测试示例, 然后转到更复杂的颤振测试用例和库.

颤振中的单元测试流程

我们在颤振中实现单元测试的方式与在其他技术栈中实现单元测试的方式大致相同:

  1. 评估代码.
  2. 设置数据模拟.
  3. 定义测试组.
  4. 为每个测试组定义测试功能签名.
  5. 编写测试.

为了演示如何进行颤振测试,我准备了一个 样本颤振项目 鼓励大家使用And 测试代码 有空的时候. 该项目使用 外部API 获取并显示我们可以按国家过滤的大学列表.

关于颤振如何工作的一些注意事项:该框架通过自动加载 flutter_t 图书馆 创建项目时. 该库使颤振能够读取、运行和分析单元测试. 颤振也会自动创建 测试 用于存储测试的文件夹. 方法的重命名和/或移动是至关重要的 测试 文件夹,因为这会破坏它的功能,从而破坏我们运行测试的能力. 它也必须包含 _t.飞镖 在我们的测试文件名中,因为这个后缀是颤振识别测试文件的方式.

测试目录结构

为了促进项目中的单元测试,我们实现了 具有干净架构的MVVM依赖注入(DI),如为源代码子文件夹选择的名称所示. 的结合 MVVM 和DI原则确保了关注点的分离:

  1. 每个项目类支持单个目标.
  2. 类中的每个函数只实现自己的作用域.

我们将为将要编写的测试文件创建一个有组织的存储空间, 在这个系统中,一组测试将有容易识别的“家”."鉴于颤振要求将测试定位在 测试 文件夹,让我们在下面镜像源代码的文件夹结构 测试. 然后, 当我们编写测试时, 我们会把它放在合适的子文件夹里:就像干净的袜子放在梳妆台的袜子抽屉里,叠好的衬衫放在衬衫抽屉里一样, 的单元测试 模型 类放在名为 模型例如:.

文件文件夹结构,有两个一级文件夹:lib和测试. 在lib下面嵌套着features文件夹, 进一步嵌套的是universities_feed, 进一步嵌套的是数据. 数据文件夹包含存储库和源文件夹. 在源文件夹下面嵌套的是网络文件夹. 在network下面嵌套的是端点和模型文件夹,以及university_remote_data_source.飞镖文件. 在模型文件夹中是api_university_模型.飞镖文件. 与前面提到的universties_feed文件夹处于同一级别的是域和表示文件夹. 在域下面嵌套的是用例文件夹. 嵌套在表示下面的是模型和屏幕文件夹. 前面提到的测试文件夹的结构模仿lib的结构. 在测试文件夹下嵌套的是unit_t文件夹,其中包含universties_feed文件夹. 它的文件夹结构与上面的universties_feed文件夹相同, with its 飞镖文件s having "_t" appended to their 名字s.
反映源代码结构的项目测试文件夹结构

采用这个文件系统可以在项目中建立透明度,并为团队提供一种查看代码的哪些部分具有相关测试的简单方法.

现在我们准备将单元测试付诸行动.

一个简单的颤振单元测试

我们从 模型 类(在 data 层),并将我们的示例限制为只包含一个 模型 class, Api大学模型. 这个类有两个功能:

  • 通过模拟JSON对象来初始化我们的模型 Map.
  • 构建 大学 数据模型.

为了测试模型的每个功能,我们将定制前面描述的通用步骤:

  1. 评估代码.
  2. 设置数据模拟:我们将定义对API调用的服务器响应.
  3. 定义测试组:我们将有两个测试组,每个函数一个.
  4. 为每个测试组定义测试函数签名.
  5. 编写测试.

在评估了我们的代码之后, 我们已经准备好完成第二个目标:设置特定于的两个函数的数据模拟 Api大学模型 class.

来模拟第一个函数(通过模拟JSON来初始化模型) Map), fromJson,我们将创建两个 Map 对象来模拟函数的输入数据. 我们也会创建两个等价的 Api大学模型 对象来表示使用所提供的输入的函数的预期结果.

要模拟第二个函数(构建 大学 数据模型), toDomain,我们将创建两个 大学 对象,这是在先前实例化的对象中运行此函数后的预期结果 Api大学模型 对象:

Void main() {
    Map api大学OneAsJson = {
        “alpha_two_code”:“我们”,
        “域”:[" marywood.edu”),
        “国家”:“美国”;
        “状态省”:空,
        “web_pages”:[" http://www.marywood.edu”),
        "名字": "Marywood 大学"
    };
    Api大学模型 预计edApi大学One = Api大学模型(
        alphaCode:“我们”,
        国家:“美国”;
        状态:空,
        校名:“玛丽伍德大学”
        [" http://www网站:.marywood.edu”),
        域:[" marywood.edu”),
    );
    期望就读的大学
        alphaCode:“我们”,
        国家:“美国”;
        状态:“”,
        校名:“玛丽伍德大学”
        [" http://www网站:.marywood.edu”),
        域:[" marywood.edu”),
    );
 
    Map api大学TwoAsJson = {
        “alpha_two_code”:“我们”,
        “域”:[" lindenwood.edu”),
        “国家”:“美国”;
        “状态省”:“乔丹”,
        “web_pages”:空,
        "名字": "Lindenwood 大学"
    };
    Api大学模型 预计edApi大学Two = Api大学模型(
        alphaCode:“我们”,
        国家:“美国”;
        状态:“乔丹”,
        校名:林登伍德大学
        网站:空,
        域:[" lindenwood.edu”),
    );
    期望就读的大学
        alphaCode:“我们”,
        国家:“美国”;
        状态:“乔丹”,
        校名:林登伍德大学
        网站:[],
        域:[" lindenwood.edu”),
    );
}

下一个, 我们的第三和第四个目标, 我们将添加描述性语言来定义我们的测试组和测试函数签名:

    Void main() {
    //之前的声明
        group("Test Api大学模型 initialization from JSON", () {
            测试(' 测试 using json one', () {});
            测试(' 测试 using json two', () {});
        });
        group("Test Api大学模型 toDomain", () {
            测试(' 测试 toDomain using json one', () {});
            测试(' 测试 toDomain using json two', () {});
        });
}

我们定义了两个测试的签名来检查 fromJson 函数,以及两个检查 toDomain 函数.

要实现第五个目标并编写测试,让我们使用 flutter_t图书馆’s 预计 将函数结果与我们的期望进行比较的方法:

Void main() {
    //之前的声明
        group("Test Api大学模型 initialization from json", () {
            测试(' 测试 using json one', () {
                期望(Api大学模型.fromJson (api大学OneAsJson),
                    预计edApi大学One);
            });
            测试(' 测试 using json two', () {
                期望(Api大学模型.fromJson (api大学TwoAsJson),
                    预计edApi大学Two);
            });
        });

        group("Test Api大学模型 toDomain", () {
            测试(' 测试 toDomain using json one', () {
                期望(Api大学模型.fromJson (api大学OneAsJson).toDomain (),
                    预计ed大学One);
            });
            测试(' 测试 toDomain using json two', () {
                期望(Api大学模型.fromJson (api大学TwoAsJson).toDomain (),
                    预计ed大学Two);
            });
        });
}

完成了五个目标之后,我们现在可以从IDE或从 命令行.

显示五个测试中有五个通过的屏幕截图. 头读:运行:api_university_模型_t中的测试.飞镖. 屏幕左面板显示:测试结果—加载api_university_模型_t.飞镖——api_university_模型_t.Test Api大学模型 initialization from json——Test using json one——Test using json two——Test Api大学模型 toDomain——Test toDomain使用json one——Test toDomain使用json two. 屏幕的右面板显示:测试通过:五个测试中的五个——扑动测试测试/unit_t/universities_feed/data/source/network/模型/api_university_模型_t.飞镖

中包含的所有测试都可以在终端上运行 测试 文件夹中输入 颤振试验 命令,确保我们的测试通过.

或者,我们可以通过输入。来运行单个测试或测试组 颤振试验—纯名称“ReplaceWithName” 命令,将我们的测试或测试组的名称替换为 ReplaceWithName.

对颤振中的端点进行单元测试

完成了一个没有依赖项的简单测试, 让我们探索一个更有趣的颤振单元测试示例:我们将测试 端点 class,其范围包括:

  • 执行对服务器的API调用.
  • 将API JSON响应转换为不同的格式.

在对代码求值之后,我们将使用 flutter_t图书馆’s 设置 方法初始化测试组中的类:

group(“测试大学端点API调用”,(){
    设置((){
        baseUrl = "http://测试.url”;
        dioClient = 戴奥(BaseOptions());
        端点 = 大学Endpoint(dioClient, baseUrl: baseUrl);
    });
}

要向api发出网络请求,我更喜欢使用 改造图书馆,它生成大部分必需的代码. 要正确测试 大学Endpoint 同学们,我们将强制 戴奥库——这 改造 方法来执行API调用,从而返回所需的结果 戴奥 类的行为通过自定义响应适配器.

自定义网络拦截器模拟

嘲笑是可能的,因为我们已经建立了 大学Endpoint 直通DI类. (如果 大学Endpoint 类初始化 戴奥 类本身,我们将无法模拟类的行为.)

为了嘲弄 戴奥 类的行为,我们需要知道 戴奥 方法中使用的 改造 图书馆——但我们不能直接进入 戴奥. 因此,我们将嘲笑 戴奥 使用自定义网络响应拦截器:

类戴奥模拟ResponsesAdapter扩展HttpClientAdapter {
  最后的模拟AdapterInterceptor拦截器;

  戴奥模拟ResponsesAdapter(这.拦截器);

  @override
  无效关闭({bool force = false}) {}

  @override
  未来 fetch(RequestOptions 选项,
      Stream? requestStream,未来? cancel未来) {
    如果选项.方法==拦截器.类型.名字.toUpperCase () &&
        选项.baseUrl ==拦截器.uri &&
        选项.queryParameters.hasSameElementsAs(拦截器.查询) &&
        选项.路径==拦截器.路径){
      回到未来.值(ResponseBody.fromString (
        jsonEncode (拦截器.serializableResponse),
        拦截器.responseCode,
        标题:{
          “内容类型”:[" application / json ")
        },
      ));
    }
    回到未来.值(ResponseBody.fromString (
        jsonEncode (
              {"error": "请求与模拟拦截器细节不匹配!"}),
        -1,
        statusMessage: "请求与模拟拦截器细节不匹配!"));
  }
}

enum请求类型{GET、POST、PUT、PATCH、DELETE}

模拟AdapterInterceptor {
  最后的RequestType类型;
  最终字符串uri;
  最后的字符串路径;
  final Map query;
  最终对象serializable响应;
  int responseCode;

  模拟AdapterInterceptor(这.类型,这.uri,这.道路,这.查询,
      这.serializableResponse,这.responseCode);
}

现在我们已经创建了拦截器来模拟我们的网络响应, 我们可以定义我们的测试组和测试函数签名.

在本例中,我们只有一个函数要测试(getUniversitiesByCountry),所以我们只创建一个测试组. 我们将测试函数对三种情况的响应:

  1. 戴奥 类实际调用的函数 getUniversitiesByCountry?
  2. 如果我们的API请求返回一个错误,会发生什么?
  3. 如果我们的API请求返回预期的结果,会发生什么?

下面是我们的测试组和测试函数签名:

  group(“测试大学端点API调用”,(){

    测试('测试端点调用dio', () async {});

    测试('测试端点返回错误',()async {});

    测试('测试端点调用并返回2个有效的大学',()async {});
  });

我们已经准备好编写测试了. 对于每个测试用例,我们将创建的实例 戴奥模拟ResponsesAdapter 具有相应的配置:

group(“测试大学端点API调用”,(){
    设置((){
        baseUrl = "http://测试.url”;
        dioClient = 戴奥(BaseOptions());
        端点 = 大学Endpoint(dioClient, baseUrl: baseUrl);
    });

    测试('测试端点调用dio', () async {
        dioClient.httpClientAdapter = _create模拟AdapterFor搜索Request(
            200,
            [],
        );
        Var结果=等待端点.getUniversitiesByCountry(“我们”);
        期望(result, []);
    });

    测试('测试端点返回错误',()async {
        dioClient.httpClientAdapter = _create模拟AdapterFor搜索Request(
            404,
            {"error": "Not found .!"},
        );
        List? 反应;
        戴奥Error? 错误;
        尝试{
            响应=等待端点.getUniversitiesByCountry(“我们”);
        } on 戴奥Error catch (戴奥Error, _) {
            error = dioError;
        }
        期望(响应,null);
        期望(错误?.error, "Http状态错误[404]");
    });

    测试('测试端点调用并返回2个有效大学',()async {
        dioClient.httpClientAdapter = _create模拟AdapterFor搜索Request(
            200,
            generateTwoValidUniversities (),
        );
        Var结果=等待端点.getUniversitiesByCountry(“我们”);
        期望(因此,预计edTwoValidUniversities ());
    });
});

现在端点测试已经完成,让我们测试数据源类, 大学RemoteDataSource. 早些时候,我们观察到 大学Endpoint 类是构造函数的一部分 大学RemoteDataSource ({大学Endpoint? universityEndpoint}),这表明 大学RemoteDataSource 使用 大学Endpoint 类来完成它的作用域,所以这就是我们要模拟的类.

用5嘲笑

在前面的示例中,我们手动模拟了 戴奥 使用自定义的客户端请求适配器 NetworkInterceptor. 我们这是在嘲笑全班同学. 手动模拟类及其函数会非常耗时. 幸运的是, 模拟库就是为处理这种情况而设计的,并且可以以最小的工作量生成模拟类. 让我们使用 5图书馆在颤振中用于mock的行业标准库.

嘲笑 5,我们首先添加注释@Generate模拟s ([class_1, class_2,…),在测试代码之前——就在 Void main() {} 函数. 在注释中,我们将包含一个类名列表作为参数(代替 class_1 class_2…).

接下来,我们运行颤振 's 颤振 pub运行build_runner构建 命令,该命令在与测试相同的目录中为模拟类生成代码. 生成的模拟文件名将是测试文件名加上的组合 .模拟.飞镖,替换测试 .飞镖 后缀. 文件的内容将包括名称以前缀开头的模拟类 模拟. 例如, 大学Endpoint 就变成了 模拟大学Endpoint.

现在,我们导入 university_remote_data_source_t.飞镖.模拟.飞镖 (我们的模拟文件)放入 university_remote_data_source_t.飞镖 (测试文件).

然后,在 设置 函数,我们将进行模拟 大学Endpoint 通过使用 模拟大学Endpoint 初始化 大学RemoteDataSource 类:

进口的university_remote_data_source_t.模拟.飞镖”;

@Generate模拟s ([大学Endpoint])
Void main() {
    晚期大学Endpoint端点;
    remotedatasource

    group(“测试函数调用”,(){
        设置((){
            端点 = 模拟大学Endpoint();
            数据源 = 大学RemoteDataSource(universityEndpoint: 端点);
        });
}

我们成功地 大学Endpoint 然后初始化 大学RemoteDataSource class. 现在我们可以定义测试组和测试函数签名了:

group(“测试函数调用”,(){

  测试('测试数据源从端点调用getUniversitiesByCountry ', () {});

  测试('测试数据源映射getUniversitiesByCountry响应到流',(){});

  测试('测试数据源映射getUniversitiesByCountry响应到有错误的流',(){});
});

这样,我们的模拟、测试组和测试函数签名就设置好了. 我们已经准备好编写实际的测试了.

我们的第一个测试检查 大学Endpoint 函数在数据源初始化获取国家/地区信息时调用. 我们首先定义每个类在调用其函数时的反应. 既然我们嘲笑 大学Endpoint 类,这是我们要用到的类,使用 当(函数_that_will_be_called).然后(what_will_be_returned) 代码结构.

我们正在测试的函数是异步的(返回a 未来 对象),所以我们将使用 当(函数名).然后回答((_){修改后的函数结果}) 代码结构来修改我们的结果.

要检查 getUniversitiesByCountry 函数调用 getUniversitiesByCountry 内部的功能 大学Endpoint 类,我们将使用 当(...).然后回答((_){...} ) 嘲笑 getUniversitiesByCountry 内部的功能 大学Endpoint 类:

当(端点.getUniversitiesByCountry(“测试”))
    .thenAnswer((realInvocation) => 未来.value([]));

现在我们已经模拟了响应,我们调用数据源函数并检查——使用 验证 函数-whether的 大学Endpoint 函数被调用:

测试('测试数据源从端点调用getUniversitiesByCountry ', () {
    当(端点.getUniversitiesByCountry(“测试”))
        .thenAnswer((realInvocation) => 未来.value([]));

    数据源.getUniversitiesByCountry(“测试”);
    验证(端点.getUniversitiesByCountry(“测试”));
});

我们可以使用相同的原则编写额外的测试,检查函数是否正确地将端点结果转换为相关的数据流:

进口的university_remote_data_source_t.模拟.飞镖”;

@Generate模拟s ([大学Endpoint])
Void main() {
    晚期大学Endpoint端点;
    remotedatasource

    group(“测试函数调用”,(){
        设置((){
            端点 = 模拟大学Endpoint();
            数据源 = 大学RemoteDataSource(universityEndpoint: 端点);
        });

        测试('测试数据源从端点调用getUniversitiesByCountry ', () {
            当(端点.getUniversitiesByCountry(“测试”))
                    .thenAnswer((realInvocation) => 未来.value([]));

            数据源.getUniversitiesByCountry(“测试”);
            验证(端点.getUniversitiesByCountry(“测试”));
        });

        测试('测试数据源映射getUniversitiesByCountry对流的响应',
                () {
            当(端点.getUniversitiesByCountry(“测试”))
                    .thenAnswer((realInvocation) => 未来.value([]));

            期望(
                数据源.getUniversitiesByCountry(“测试”),
                emitsInOrder ([
                    const AppResult>.加载(),
                    const AppResult>.数据([])
                ]),
            );
        });

        测试(
                '测试数据源映射getUniversitiesByCountry响应到错误的流',
                () {
            mockApiError = ApiError(
                statusCode: 400,
                消息:“错误”,
                错误:空,
            );
            当(端点.getUniversitiesByCountry(“测试”))
                    .thenAnswer((realInvocation) => 未来.错误(mockApiError));

            期望(
                数据源.getUniversitiesByCountry(“测试”),
                emitsInOrder ([
                    const AppResult>.加载(),
                    AppResult>.apiError (mockApiError)
                ]),
            );
        });
    });
}

我们已经执行了大量的颤振单元测试,并演示了不同的模拟方法. 我邀请你继续使用我的 样本颤振项目 运行额外的测试.

颤振单元测试:获得卓越用户体验的关键

如果您已经将单元测试合并到您的颤振项目中, 本文可能介绍了一些您可以注入到工作流中的新选项. 在本教程中, 我们演示了在您的软件中使用单元测试最佳实践是多么简单 下一个颤振项目 以及如何应对更微妙的测试场景的挑战. 您可能再也不想跳过颤振中的单元测试了.

Toptal工程博客的编辑团队向 Matija是čirević 感谢Paul Hoskins审阅了本文中的代码示例和其他技术内容.

关于总博客的进一步阅读:

了解基本知识

  • 如何在颤振中进行单元测试?

    在颤振中进行单元测试的过程与在大多数框架中一样. 在定义了要测试的类和函数(测试用例)之后, (1)对规范进行评估, (2)建立数据模拟, (3)确定测试组, (4)定义每个测试组的测试功能签名, (5)编写并运行测试.

  • 为什么单元测试很重要?

    单元测试可以防止或大大减少应用程序中的错误, 在应用首次发布时就提供高质量的用户体验. 一个额外的好处是:阅读单元测试可以帮助新开发人员学习和理解代码.

  • MVVM对颤振有好处吗?

    MVVM(模型-视图-视图模型模式)增强了代码库的稳定性和可伸缩性. 代码增强是我们编写更干净的代码以符合MVVM的架构需求的自然结果.

  • 如何在颤振中使用MVVM模式?

    MVVM架构模块化了我们的代码:模型模块中的类提供我们的数据. 视图模块通过UI小部件呈现数据. 最后, 视图模型 类获取数据并向其关联的类提供数据 视图 类.

  • 什么是颤振中的单元测试?

    单元测试是测试单个代码片段的过程, 通常是非常小的代码单元,比如类, 方法, 和功能.

聘请Toptal这方面的专家.
现在雇佣
大夏的Florea

大夏的Florea

验证专家 在工程
6 的经验

布加勒斯特,罗马尼亚

2020年11月9日成为会员

作者简介

Dacian是一位资深的全栈移动应用程序开发人员, 颤振专家, 也是颤振框架的贡献者. 他帮助世界各地的公司设计和实施高质量的软件解决方案,并提供出色的用户体验.

作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

专业知识

以前的角色

高级移动开发人员

世界级的文章,每周发一次.

输入您的电子邮件,即表示您同意我们的 隐私政策.

世界级的文章,每周发一次.

输入您的电子邮件,即表示您同意我们的 隐私政策.

Toptal开发者

加入总冠军® 社区.