功能测试如何获得代码覆盖率?大佬解释得太详细了

代码覆盖率

代码覆盖率是软件测试中一个重要的评价指标,主要是指程序运行过程中,被执行到的代码在总代码中的占比程度。

现在有很多插件可以实现这个功能,应用比较广的就是JaCoCo,虽然好久没更新了,类似Jetbrain旗下的IDE。默认提供了三种获得代码覆盖率的方式:Intellij IDEA、JaCoCo、Emma。

功能测试代码覆盖率

常规的代码覆盖率通常是在单元测试中,通过编写测试用例,然后执行对应的单元测试,获得代码覆盖率。当然,现在也有挺多自动化生成单元测试的工具,比如EvoSuite,Randoop等。

但是,对于用户交互性比较强的应用,比如Android应用,这种单纯的单元测试显然是满足不了需要的。功能测试就是在真实的使用环境下,人工或者模拟真人对应用进行测试,在这种场景下,生成的代码覆盖率定义其为功能覆盖率。

JaCoCo与Gradle获取功能测试代码覆盖率

常见获取覆盖率的方法分两种,一种是在源码中,以打桩的形式,收集覆盖率数据,针对性较强,但是需要深入源码,另一种是改写应用入口,通过instrument启动应用,记录应用执行期间全局的代码覆盖率。这里,主要针对第二种,主要内容分为两个主要部分:

  • 覆盖率数据获取

  • 覆盖率数据解析

具体实现

入口文件改写

这里需要添加一个新的入口,接收instrument指令,启动代码覆盖率记录功能,并启动原始应用的入口Activity。涉及的一些代码,网上到处都是,我找了一些改写了一下,除去了一些冗余的代码,也去掉了一些bug。

这里主要包括2个java文件,为了方便管理,我们就单独创建一个package、test,避免和原始代码混淆。

一个Instrument启动器,目的是方便通过instrument指令启动带有coverage记录功能的activity。

  1. package test;

  2. import android.app.Instrumentation;

  3. import android.content.Intent;

  4. import android.os.Bundle;

  5. import android.util.Log;

  6. import java.io.File;

  7. import java.io.IOException;

  8. public class JacocoInstrumentation extends Instrumentation {

  9. public static String TAG = "JacocoInstrumentation:";

  10. private Intent mIntent;

  11. @Override

  12. public void onCreate(Bundle bundle) {

  13. Log.d(TAG, "onCreate(" + bundle + ")");

  14. super.onCreate(bundle);

  15. String DEFAULT_COVERAGE_FILE_PATH = getContext().getFilesDir().getPath() + "/coverage.ec";

  16. File file = new File(DEFAULT_COVERAGE_FILE_PATH);

  17. if (!file.exists()) {

  18. try {

  19. file.createNewFile();

  20. } catch (IOException e) {

  21. Log.d(TAG, "异常 : " + e);

  22. e.printStackTrace();

  23. }

  24. }

  25. mIntent = new Intent(getTargetContext(), InstrumentedActivity.class);

  26. mIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);

  27. start();//调用onStart

  28. }

  29. @Override

  30. public void onStart() {

  31. Log.d(TAG, "onStart()");

  32. super.onStart();

  33. startActivitySync(mIntent);

  34. }

  35. //adb shell am instrument com.tachibana.downloader/test.JacocoInstrumentation

  36. }

这个文件的主要功能有两点:

  • 在/data/data/应用包名/files目录下创建coverage.ec,这个文件就是用来记录覆盖率数据的;

  • 启动改写的Activity,这里是InstrumentedActivity。

为了不影响原始代码,创建一个原入口类的子类,实现记录覆盖率数据的功能,在实现目的的情况下,尽可能减少对原始代码的影响。

  1. package test;

  2. import android.util.Log;

  3. import com.tachibana.downloader.ui.main.MainActivity;

  4. import java.io.FileOutputStream;

  5. import java.io.OutputStream;

  6. public class InstrumentedActivity extends MainActivity {

  7. public static String TAG = "InstrumentedActivity";

  8. @Override

  9. public void onDestroy() {

  10. super.onDestroy();

  11. Log.d(TAG, "onDestroy()");

  12. generateCoverageReport();

  13. }

  14. private void generateCoverageReport() {

  15. String DEFAULT_COVERAGE_FILE_PATH = getFilesDir().getPath() + "/coverage.ec";

  16. Log.d(TAG, "generateCoverageReport():" + DEFAULT_COVERAGE_FILE_PATH);

  17. try {

  18. OutputStream out = new FileOutputStream(DEFAULT_COVERAGE_FILE_PATH, false);

  19. Object agent = Class.forName("org.jacoco.agent.rt.RT")

  20. .getMethod("getAgent")

  21. .invoke(null);

  22. out.write((byte[]) agent.getClass().getMethod("getExecutionData", boolean.class)

  23. .invoke(agent, false));

  24. out.close();

  25. } catch (Exception e) {

  26. Log.d(TAG, e.toString(), e);

  27. }

  28. }

  29. }

如代码里展示的,我这里原始的入口类是com.tachibana.downloader.ui.main.MainActivity,我们继承这个Activity,并实现了一个generateCoverageReport方法,用来记录JaCoCo的数据,在这个Activity退出的时候,调用方法记录,实现覆盖率数据获取的功能。

这个函数也是从网上找的,从字面上来看,逻辑就是每次这个Activity的生命周期执行到onDestroy的时候,就记录一次数据,并且是覆盖式的记录。(对于覆盖式的记录,我是有点困惑的,这些数据是从应用启动就被一直存在内存,增量式增加还是记录在日志,每次调用这个方法的时候,jacoco去日志里面解析暂且认为这种方式没有问题吧,毕竟网上都是这么做的)。

这个Activity的作用也是2个:

  • 记录ExecutionData,也就是覆盖率的原始数据;

  • Fake入口,继承了原始的入口Activity,在实现原始功能的同时,不影响原来代码。

AndroidManifest.xml改写

配置文件的改写主要分为3个部分:

增加权限,既然读写文件了,那读写文件的权限肯定是要有的:

  1. <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

  2. <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

Activity索引,我们新建了一个Fake入口,需要将这个Activity加入配置文件中,就加在<application>标签下,和其他的Activity一样。

  1. <activity

  2. android:name="test.InstrumentedActivity"

  3. android:label="InstrumentationActivity" />

配置Instrument,<instrumentation>标签与<application>标签同级,配置了才能通过adb指令执行到,需要自定义的属性有两个。name,就是上面写的那个,targetPackage,就是应用的包名,配置在AndroidManifest.xml里的那个。

  1. <instrumentation

  2. android:name="test.JacocoInstrumentation"

  3. android:handleProfiling="true"

  4. android:label="CoverageInstrumentation"

  5. android:targetPackage="com.tachibana.downloader" />

build.gradle改写

这里的配置主要针对JaCoCo,在应用构建过程,生成JaCoCo必须的一些数据,以及获取ec文件后的转义处理。

  1. apply plugin: 'com.android.application'

  2. apply plugin: 'jacoco'//引用插件

  3. jacoco {//插件版本设置

  4. toolVersion = "0.8.5"

  5. }

  6. android {

  7. compileSdkVersion 30

  8. buildToolsVersion "30.0.1"

  9. defaultConfig {

  10. applicationId "com.morn.aaa"

  11. minSdkVersion 23

  12. targetSdkVersion 30

  13. versionCode 1

  14. versionName "1.0"

  15. testInstrumentationRunnerArguments clearPackageData: 'true'//instrument设置

  16. }

  17. buildTypes {

  18. debug {

  19. testCoverageEnabled = true//JaCoCo功能启动

  20. }

  21. }

  22. }

  23. //ec文件解析函数

  24. def coverageSourceDirs = [

  25. '../app/src/main/java'

  26. ]

  27. task jacocoTestReport(type: JacocoReport) {

  28. group = "Reporting"

  29. description = "Generate Jacoco coverage reports after running tests."

  30. reports {

  31. xml.enabled = true

  32. html.enabled = true

  33. csv.enable = true

  34. }

  35. classDirectories.from = fileTree(

  36. dir: './build/intermediates/javac/debug',

  37. excludes: ['**/R*.class',

  38. '**/*$InjectAdapter.class',

  39. '**/*$ModuleAdapter.class',

  40. '**/*$ViewInjector*.class'

  41. ])

  42. sourceDirectories.from = files(coverageSourceDirs)

  43. executionData.from = files("$buildDir/outputs/code-coverage/connected/coverage.ec")

  44. doFirst {

  45. new File("$buildDir/intermediates/javac/").eachFileRecurse { file ->

  46. if (file.name.contains('$$')) {

  47. file.renameTo(file.path.replace('$$', '$'))

  48. }

  49. }

  50. }

  51. }

  52. dependencies {

  53. implementation fileTree(dir: "libs", include: ["*.jar"])

  54. }

这是app/build.gradle的一个例子,关键部分我都加了注释。

得到ec文件之后,需要将Android设备下的ec文件,放置到$buildDir/outputs/code-coverage/connected/coverage.ec然后运行jacocoTestReport这个task,运行成功后会在$buildDir/reports/jacoco目录下生成根据eoverage.ec转义的html等文件,html目录下的index.html可以可视化展示代码覆盖率数据。

一些bug

jacoco.exec
W/System.err: java.io.FileNotFoundException: /jacoco.exec (Read-only file system)

这应该是JaCoCo库包的一些问题,由于Android平台的特殊性导致的,JaCoCo的开发者也没提出明确的解决方案。但是,不影响我们这里要求的功能。

切页面闪退

网上一些例子中会出现切换Activity,应用闪退的问题。我一开始也是照着这个例子来的,也出现了这个问题,分析是代码思路有问题,一是只有由Instrument开启的Activity才设置了listener,二是,切换Activity会调用finish方法,直接就结束了应用。反正我一通删删删之后,可以正常运行了。

Instrument命令无反应

可能是targetPackage配置的问题,有些时候AndroidManifest.xml里设置的package不一定是实际安装时应用的package名。

 
  1. <manifest xmlns:android="http://schemas.android.com/apk/res/android"

  2. package="org.glucosio.android">

  3. ······

  4. <instrumentation

  5. android:name="test.JacocoInstrumentation"

  6. android:handleProfiling="true"

  7. android:label="CoverageInstrumentation"

  8. android:targetPackage="org.glucosio.android.daily" />//实际安装的包名变化了

  9. </manifest>

比如这个例子,在构建文件build.gradle里面对debug和release版本的包名做了修改,所以,实际安装的时候,应用对应的包名并不是AndroidManifest.xml里配置的那样。

解决方案就是编译完知道真实的包名后再配置,还有,在AS里面,targetPackage属性无论配置正确与否都会飘红报错,但是,这并不影响编译运行。

启动应用闪退

在设置了假入口类,并通过Instrument启动的时候,可能会遇到应用启动闪退的情况,这是因为很多应用会改写Theme,然后Fake入口类继承的真实入口类需要对应的Theme配置,这里出现了配置,或者资源不兼容的情况,造成了闪退。

 
  1. <activity

  2. android:name=".activity.SplashActivity"

  3. android:label="@string/app_name"

  4. android:theme="@style/SplashTheme">

  5. <intent-filter>

  6. <action android:name="android.intent.action.MAIN" />

  7. <category android:name="android.intent.category.LAUNCHER" />

  8. </intent-filter>

  9. <meta-data

  10. android:name="android.app.shortcuts"

  11. android:resource="@xml/shortcuts" />

  12. </activity>

  13. <activity

  14. android:name="test.InstrumentedActivity"

  15. android:label="@string/app_name"

  16. android:theme="@style/SplashTheme">

  17. <meta-data

  18. android:name="android.app.shortcuts"

  19. android:resource="@xml/shortcuts" />

  20. </activity>

上面例子里的InstrumentedActivity就是继承了真实入口类SplashActivity的Fake入口类。

解决方案,继承一切真实入口类可以被继承的属性,比如theme什么的,这样在FakeActivity实例化加载资源的时候,可以避免出现资源申请失败的问题,防止出现应用启动闪退的现象。

进阶

本方法的主要功能就是generateCoverageReport,所以,何时调用,是最主要的问题。

最理想的状态,在测试退出的时候调用,写入一次,节省时间,性能也高。但是大多数场景下,这种情况是行不通的。以下我整理几个场景,以及解决方案,提供参考:

  • 入口类是一次性的,比如加载页面、广告页面等等;

  • 入口类显性调用,指定了Activity跳转的目标类名,无法重回我们重写的Fake入口类。

以下可以提供一些解决方案:

  • Application的onTerminate()方法重写,在这里打桩调用记录函数,但是Application的生命周期监听在不同设备上有区别,貌似只在模拟器上有用;

  • BaseActivity的onDestroy()方法重写,一般大型的Android项目都会有一个自定义的顶级Activity,重写销毁监听函数,在这里打桩,这样每次Activity退出都会记录一次数据,当然,这也是在其子类重写但没有覆盖父类这个操作的情况下;

  • MainActivity的onDestroy()方法重写,这种方法是最保险的方法,这里的MainActivity指代应用首页对应的Activity。虽然,很多时候这个MainActivity不一定是入口类,但是只要在测试的时候能够最终回到MainActivity所在的页面,然后通过Android的回退键退出,调用onDestrooy()记录数据。

总结

通过JaCoCo记录覆盖率的核心是调用记录函数,重点是保证记录函数能被成功调用。所以,Instrument也好,Service也好,直接写在原来的Activity里也好,目的都是成功记录数据。没有通用的万能方法,还是得因地制宜。在复杂的项目环境下,还是需要对原生代码进行适当的打桩修改的。

  • 9
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值