查看原文
其他

Flutter代码覆盖率研究

张科 58技术 2022-08-28

导读

Android java代码覆盖有Jacoco等工具,iOS也有对应的原生代码覆盖率工具,然而,目前尚未有任何关于Flutter覆盖率的工具或者插件等,属于空白区域,因此需要从无到有的开发该工具,本文将详细说明Flutter代码覆盖率该工具的原理及其实现。


背景

由于flutter具有下等优点:
A、混合开发中,最接近原生开发的框架;
B、性能强大,流畅;
C、简单易学,Dart语言更具优势;
D、跨多种平台,减少开发成本;支持插件,可以访问原生系统的调用。
我们商家通App使用Flutter进行混合开发,阿姨端全量使用Flutter,随着业务与代码量的增长,相比于Native测试,在Android端已经配置代码覆盖率下,Flutter的测试工作出现了如下弊端:
A、Flutter测试效率低,测试难度大;
B、单端测试工作量有一定增加;
C、测试质量有所下降;
D、测试缺少覆盖率这一重要指标;
为了解决上述问题,增加Flutter代码覆盖率已经显的尤为重要。

原理

代码覆盖率的指标从细到粗有指令、分支、行、方法、类等各个指标,综合工作量和收益率等,我们只做到行级别的代码覆盖率。要做到行级别覆盖就需要记录每行代码是否执行,然而在每行代码前后去记录执行情况是不可取的,费时费力,包体积也将陡增,只需在每个代码块首末记录是否执行即可判断整个代码块的执行情况,下面是一个简单dart语言写的类:


 为实现代码覆盖率,我们修改代码为:


 我们在Main函数中使用Test(null).getTestInfo(),并得到输出内容为: 

    1.Test_Test:[true]
    2.Test_getTestInfo:[true, false, false, true]

从输出信息中,我们就能分析出原代码覆盖情况:构造函数已经覆盖,getTestInfo方法部分覆盖。

以上就是行覆盖的基本原理,但是,在实际情况中,代码结构远比上面的类复杂,各种嵌套函数,各种语句,都将影响我们的准确度,而且我们也不允许直接在代码中写这样逻辑,我们需要专业的类似于ASM工具,对编译的文件进行修改即插桩,然而ASM是对class文件进行修改,而Flutter则需要对编译的dill文件进行插桩。


插桩工具

工欲善其事必先利其器,代码覆盖基本就是插桩操作,java中插桩是对class文件进行操作,Flutter则是对中间Dill文件操作,目前能操作Dill文件的开源工具目前来说很少,目前来看AspectD是比较全面的且比较好用的一个。AspectD是阿里开源的一个项目,该工具接入Flutter生成Dill文件流程,对Dill文件进行增删改查,最终实现AOP功能,我们则是借助其对Dill文件的操作的功能,对Dill文件进行插桩。

下图是AspectD AOP详细流程图。



代码覆盖率实现

  

此工具由一个核心插桩模块、两个数据管理以及diff模块、HTML渲染、统计、日志记录组成,其中核心模块为插桩模块,主要负责AST代码插桩、各种语句处理以及输出插桩数据。数据生成层主要是生成原始数据和对简单处理;数据应用层则是对原始数据分析、统计、输出;日志模块主要记录各个模块异常情况,方便后面迭代升级。

  代码覆盖率的流程图:


红色是核心插桩模块,编译、打包由系统平台处理,运行输出需要用户触发,下面对各个模块详细说明。


1、代码插桩

AspectD对原有Flutter SDK进行了修改,把原有编译流程增加了一个AOP流程,首先读取Flutter编译好的Dill文件,然后遍历所有dart文件,接着修改需要AOP的地方,最后保存替换原有Dill文件,达到AOP的目的。

Dill文件,又称为Dart Intermediate Language,是Dart语言编译中的一个概念,无论是Script Snapshot还是AOT编译,都需要Dill作为中间产物。dart提供了一种Kernel to Kernel Transform的方式,可以通过对dill文件的递归式AST遍历,实现对Dill的内容的修改、添加等操作。

AST又是啥?AST称为抽象语法树(Abstract Syntax Tree),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。我们操作的Dill文件时候,实际是在操作由Dill文件生成的众多抽象语法树,每个dart文件就是一颗树。下图是Test.dart文件语法树结构:


可以看出抽象语法树包含源代码所有的信息,了解抽象语法树能更好的帮助我们进行插桩操作,有兴趣的同学可以深入了解抽象语法树,它是编译过程中必不可少中间产物。

对抽象语法树遍历需要,继承"package:kernel/ast.dart"下的Visitor类,覆盖想要修改数据对应的方法,如visitvisitBlock,如有代码块,将回调该方法。

下图为详细插桩流程:


 其中:

1、变单行为block操作:某些情况下方法或者构造函数没有实现,在AST中是单行语句,是不能再插入语句的,因此需要改成block。

2、block插桩包含常规blick处理与各种语句的特殊处理,如while、async标记、for循环,这些语句在AST中都是经过处理的,需要跳过处理的行,为的是能精确记录代码对应的行号。

3、需要先声明coverage_insert数组,当前函数插桩完成过后,得到确切的数组长度过后,再进行插入操作。

操作AST与编写代码是不相同的,在上述流程中,声明类型为List<bool>的变量,在AST中的表示变得复杂很多。

///声明 List<bool> coverage_insert = CoverageImpl.getLibData(lib名字, 类名_方法名);void createListDeclaration(Member procedure, Block node){ VariableDeclaration declaration = VariableDeclaration( CoverageConstant.declarationListName, isFinal:true); declaration.type = InterfaceType(CoverageConstant.dartList.parent as Class, Nullability.legacy,[InterfaceType(CoverageConstant.dartBool, Nullability.legacy, null)]);Arguments arguments = Arguments.empty(); String lib_key = library.importUri.toString();arguments.positional.add(StringLiteral(lib_key)); String class_fun_key = getClassName(procedure) + CoverageConstant.class_fun_spile + procedure.name.name; arguments.positional.add(StringLiteral(class_fun_key)); declaration.initializer = StaticInvocation(CoverageConstant.procedureGetListBool, arguments); declaration.parent = node; procedureListDeclaration = declaration; recordInsertInfo.insertStart(this, library, lib_key, class_fun_key);}

可以看出插入声明语句是比较复杂的,首先需要一个名字,这个简单,第二需要指定变量类型,这个类型我们得遍历dart:core拿到,包括list和bool类型;最后就是这个变量的初始化,这里的初始化是一个静态调用,组装好参数赋给变量的initializer就行了;细心的同学可能会看出,这里并未把改声明变量插入的Block中,是的,这里并未插入,只是创建了这个变量,为的是后面我们执行插入coverage_insert[procedureInsertTime] = true,并统计插入的次数,最后一并插入声明语句以及引入相关的dart文件,并且这样做可以减少插入赋值语句和记录插入位置信息的复杂度。

再来看看流程中的插入赋值代码:

///插入coverage_insert[procedureInsertTime] = true;void insertStatement(Block node, {int position = 0}){ if(procedureListDeclaration == null){ return;} Arguments arguments = Arguments.empty(); Expression arg = IntLiteral(procedureInsertTime); arg.parent = arguments; arguments.positional.add(arg); arg = BoolLiteral(true); arg.parent = arguments; arguments.positional.add(arg); VariableGet get = VariableGet(procedureListDeclaration);MethodInvocation invocation = MethodInvocation(get, CoverageConstant.dartListDengYu.name, arguments, CoverageConstant.dartListDengYu); ExpressionStatement statement = ExpressionStatement(invocation); statement.parent = node; if(position >= node.statements.length){ node.addStatement(statement); }else { node.statements.insert(position, statement); }

上面只是插入赋值语句的代码,插入前的一些判断检测并未在此方法中。插入与声明的调用方法都差不多,只是组装的数据,类型等不相同。

下面再看看,真实的Dill文件插入前后对比。原始Dill转换成方便阅读的代码:


 插桩过后的代码:


红框中便是我们插入的代码,可以看出我们已经正确插桩。查看转换过后的文件可以检查插桩是否正确,某些情况下插桩不正确会导致我们的App不能正常启动运行,这种情况多半是由于插桩不对引起的。


2、记录插桩位置

上面的类在插桩过后,每个方法需要生成一个数组,并在运行时在对应数组位置赋值true,表示此位置已经运行过了,达到检测覆盖的目的, 同时记录该插入位置的信息,以getTestInfo方法为例,记录的插入数据如下:


在插桩的同时我们记录插桩的行号、代码块深度、方法深度、类型等,最后得到所有类和方法插入数据,输出保存,以便后面分析处理使用。

3、运行APP,输出覆盖数据

Dill文件插桩并替换原文件过后,就可以打包生成apk或者依赖aar,运行app,并且执行对应的一些功能过后,退出app,得到如下数据(部分):

"package:example/test.dart": { "Test&**&": [ true, true], "Test&**&getTestInfo": [ true, true, true, false]


数据说明:运行数据与插桩位置数据一一对应,true代表插桩位置已经执行,false则未执行。


4、DIFF获取

获取这一版本的变更数据,以便在覆盖报告中输出对应指标。 通过git diff tag_branch  file_name获取变更数据:


   分析输出,生成json数据(部分):

[{ "add": false, "line": 8, "src": " return "无数据";" }, { "add": true, "line": 8 }]

数据说明:add代表是添加删除,line行号,src删除的代码,添加的不需要src。


5、分析处理运行输出数据与插桩位置信息生成覆盖详细数据

在得到运行时输出覆盖数据与对应的插桩位置信息过后,经过一系列分析处理,我们就能得到行覆盖详细数据;


 以下为分析处理过程:


输出的具体数据如下(部分):

"package:example/test.dart": { "Test&**&": { "start": 4, "end": 4,"isCoverage": true }, "Test&**&getTestInfo": { "start": 6, "end": 11, "isCoverage": true,"disCoverage": [ { "end": 11,"start": 9 } ] }}

数据说明:如果该方法所有行都覆盖了则没有disCoverage数组,如果该方法未执行则isCoverage未false;disCoverage为该方法里面未被执行的数据,包含了起始行号。


6、统计计算

基于以上输出数据:插桩位置、覆盖详细数据、Diff变更数据,做统计汇总,汇总数据指标如下:A、Lines与Missed Lines;B、Methods与Missed Methods;C、Clesses与Missed Clesses;D、Line Diff与Missed Line Diff。


7、报告输出

到这一步输出html报告的所有数据都已准备好,汇总数据+html模板,生成汇总报告:  

覆盖详细数据+Diff变更数据+源代码,生成代码覆盖详情:


其中:A、深红色代表源代码修改但是测试的时候未覆盖;B、浅红色代表未修改未覆盖;

C、 绿色代表已覆盖;D、 没有底色代表不需要覆盖。

    

成果展示

目前该工具已成功在商家通App中使用,商家通是Flutter和原生开发混合开发,其中商机、我的、新商机、我的收藏、全部服务等页面由Flutter开发,在没有该工具之前,测试人员是没有确切的直观的数据来查看测试的覆盖情况,只能通过测试用例来大概覆盖代码。加入该工具过后,我们可以报告中直观的看到覆盖情况,其中整体覆盖情况,如下(其中Diff 对比是上一个版本代码):


从图中可看到整体覆盖率是30%,而代码变更部分的测试覆盖率已经达到70%,显然我们是将中测试重点放在修改代码功能上面,这无疑降低了测试的工作难度与工作量。

上图从widget的覆盖率较低,再看看哪些地方未覆盖,可以将测试重点放该功能模块上面:


上图中,widget下面的只有custom_tab_bar.dart有修改,进入查看(部分)


详细查看变更未覆盖代码,方便下次针对性测试。

自接入该工具以来,目前已正常迭代2个版本,下面是这两个版本统计到的数据:

A、提升了提测质量,提测平均bug数由4.3减少至3.0,需求bug数由1.5降至1.0左右;

B、Flutter模块平均测试周期减少0.5day,平均提效8%以上;

C、Flutter错误率由3.5%降至3.2%以下(由于Flutter页面不会出现崩溃,只能统计所有错误率)。

由于目前只用于商家通项目,而且迭代次数较少,数据不一定准确。


总结

我们实现了Flutter代码覆盖率,填补了Flutter这一空白,从上面的数据来看,已经到达了开发这一工具的目的,同时,我们也收到了不少建议,总结如下:

 A、需要配置本地环境,对测试人员来说相对困难;
 B、多次测试的数据不能合并,影响使用效果;
 C、未适配Flutter其它版本;
 D、覆盖粒度没有Jacoco细;

 E、最好能实时显示覆盖情况。

后续我们会针对以上问题不断迭代,提升此工具的实用性,方便大家使用。 


作者简介:

张科:本地服务事业群-终端技术部-无线技术部 Android开发工程师


参考文献:

1.https://github.com/alibaba-flutter/aspectd 

2.https://github.com/jacoco/jacoco    


推荐阅读:
基于next.js的服务端渲染解决方案
58信息安全—营销反作弊业务的算法实践
58信息安全-图神经网络在业务反欺诈中的应用实践
58同城商业生态与智能发展中心反作弊测试平台建设

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存