Android精准测试探索:测试覆盖率统计
背景
随着业务与需求的增长, 回归测试的范围越来越大,测试人员的压力也日益增加。但即使通过测试同学的保障,线上仍然会存在回归不到位或测试遗漏的地方导致出现线上故障。
因此我们需要通过类似jacoco的集成测试覆盖率统计框架,来衡量测试人员的回归范围是否精准、测试场景是否遗漏;保障上线的代码都已经经过测试人员验证。针对这一点,我们提出了Android测试覆盖率统计工具, 借此来提升测试人员精准测试的能力,借助覆盖率数据补充测试遗漏的测试用例。
工具选型
Android APP开发主流语言就是Java语言,而Java常用覆盖率工具为Jacoco、Emma和Cobertura。
根据上图的一些特点,我们选择jacoco作为测试覆盖率统计工具。
技术选型
众所周知, 获取覆盖率数据的前提条件是需要完成代码的插桩工作。而针对字节码的插桩方式,可分为两种 —— 1、On-The-Fly 2、Offliine
On-The-Fly在线插桩
- JVM中通过-javaagent参数指定特定的jar文件启动Instrumentation的代理程序
- 代理程序在每装载一个class文件前判断是否已经转换修改了该文件,如果没有则需要将探针插入class文件中。
- 代码覆盖率就可以在JVM执行代码的时候实时获取
优点:无需提前进行字节码插桩,无需考虑classpath 的设置。测试覆盖率分析可以在JVM执行测试代码的过程中完成
Offliine离线插桩
- 在测试之前先对字节码进行插桩,生成插过桩的class文件或者jar包,执行插过桩的class文件或者jar包之后,会生成覆盖率信息到文件,最后统一对覆盖率信息进行处理,并生成报告。
Offlline模式适用于以下场景:
- 运行环境不支持java agent,部署环境不允许设置JVM参数
- 字节码需要被转换成其他虚拟机字节码,如Android Dalvik VM 动态修改字节码过程中和其他agent冲突
- 无法自定义用户加载类。
Android项目只能使用JaCoCo的离线插桩方式。 为什么呢? 一般运行在服务器java程序的插桩可以在加载class文件进行,运用java Agent的机制,可以理解成"实时插桩"。 但是因为Android覆盖率的特殊性,导致 Android系统破坏了JaCoCo这种便利性,原因有两个:
(1)Android虚拟机不同与服务器上的JVM,它所支持的字节码必须经过处理支持Android Dalvik等专用虚拟机,所以插桩必须在处理之前完成,即离线插桩模式。
(2)Android虚拟机没有配置JVM 配置项的机制,所以应用启动时没有机会直接配置dump输出方式。
这里我们确定了androidjacoco覆盖率是采用离线插桩的方式。
手工获取测试覆盖率
为了不修改开发的核心代码,我们可以采用通过instrumentation调起被测APP,在instrumentation activity退出时增加覆盖率的统计(不修改核心源代码)。
这里简单介绍下方法。
step1:在不修改android源码的情况下,在src/main/java 里面新增一个test目录 里面存放3个文件:FinishListener、InstrumentedActivity、JacocoInstrumentation
FinishListener源码:
public interface FinishListener {
void onActivityFinished();
void dumpIntermediateCoverage(String filePath);
}
InstrumentedActivity源码:
import com.netease.coverage.jacocotest1.MainActivity;
public class InstrumentedActivity extends MainActivity {
public FinishListener finishListener ;
public void setFinishListener(FinishListener finishListener){
this.finishListener = finishListener;
}
@Override
public void onDestroy() {
if (this.finishListener !=null){
finishListener.onActivityFinished();
}
super.onDestroy();
}
}
JacocoInstrumentation源码:
import android.app.Activity;
import android.app.Instrumentation;
import android.content.Intent;
import android.os.Bundle;
import android.os.Looper;
import android.util.Log;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
public class JacocoInstrumentation extends Instrumentation implements
FinishListener {
public static String TAG = "JacocoInstrumentation:";
private static String DEFAULT_COVERAGE_FILE_PATH = "/mnt/sdcard/coverage.ec";
private final Bundle mResults = new Bundle();
private Intent mIntent;
private static final boolean LOGD = true;
private boolean mCoverage = true;
private String mCoverageFilePath;
public JacocoInstrumentation() {
}
@Override
public void onCreate(Bundle arguments) {
Log.d(TAG, "onCreate(" + arguments + ")");
super.onCreate(arguments);
DEFAULT_COVERAGE_FILE_PATH = getContext().getFilesDir().getPath().toString() + "/coverage.ec";
File file = new File(DEFAULT_COVERAGE_FILE_PATH);
if (file.isFile() && file.exists()){
if (file.delete()){
System.out.println("file del successs");
}else {
System.out.println("file del fail !");
}
}
if (!file.exists()) {
try {
file.createNewFile();
} catch (IOException e) {
Log.d(TAG, "异常 : " + e);
e.printStackTrace();
}
}
if (arguments != null) {
mCoverageFilePath = arguments.getString("coverageFile");
}
mIntent = new Intent(getTargetContext(), InstrumentedActivity.class);
mIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
start();
}
@Override
public void onStart() {
System.out.println("onStart def");
if (LOGD)
Log.d(TAG, "onStart()");
super.onStart();
Looper.prepare();
InstrumentedActivity activity = (InstrumentedActivity) startActivitySync(mIntent);
activity.setFinishListener(this);
}
private boolean getBooleanArgument(Bundle arguments, String tag) {
String tagString = arguments.getString(tag);
return tagString != null && Boolean.parseBoolean(tagString);
}
private void generateCoverageReport() {
OutputStream out = null;
try {
out = new FileOutputStream(getCoverageFilePath(), false);
Object agent = Class.forName("org.jacoco.agent.rt.RT")
.getMethod("getAgent")
.invoke(null);
out.write((byte[]) agent.getClass().getMethod("getExecutionData", boolean.class)
.invoke(agent, false));
} catch (Exception e) {
Log.d(TAG, e.toString(), e);
e.printStackTrace();
} finally {
if (out != null) {
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
private String getCoverageFilePath() {
if (mCoverageFilePath == null) {
return DEFAULT_COVERAGE_FILE_PATH;
} else {
return mCoverageFilePath;
}
}
private boolean setCoverageFilePath(String filePath){
if(filePath != null && filePath.length() > 0) {
mCoverageFilePath = filePath;
return true;
}
return false;
}
private void reportEmmaError(Exception e) {
reportEmmaError("", e);
}
private void reportEmmaError(String hint, Exception e) {
String msg = "Failed to generate emma coverage. " + hint;
Log.e(TAG, msg, e);
mResults.putString(Instrumentation.REPORT_KEY_STREAMRESULT, "\nError: "
+ msg);
}
@Override
public void onActivityFinished() {
if (LOGD)
Log.d(TAG, "onActivityFinished()");
if (mCoverage) {
System.out.println("onActivityFinished mCoverage true");
generateCoverageReport();
}
finish(Activity.RESULT_OK, mResults);
}
@Override
public void dumpIntermediateCoverage(String filePath){
// TODO Auto-generated method stub
if(LOGD){
Log.d(TAG,"Intermidate Dump Called with file name :"+ filePath);
}
if(mCoverage){
if(!setCoverageFilePath(filePath)){
if(LOGD){
Log.d(TAG,"Unable to set the given file path:"+filePath+" as dump target.");
}
}
generateCoverageReport();
setCoverageFilePath(DEFAULT_COVERAGE_FILE_PATH);
}
}
}
step2:app module的build.gradle 增加jacoco插件和打开覆盖率统计开关
apply plugin: 'jacoco'
jacoco {
toolVersion = "0.7.4+"
}
buildTypes {
debug {
/**打开覆盖率统计开关**/
testCoverageEnabled = true
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
step3:修改AndroidManifest.xml文件
- 在<application>中声明InstrumentedActivity
<activity android:label="InstrumentationActivity" android:name="com.netease.coverage.test.InstrumentedActivity" />
2. 声明使用SD卡权限
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
3、单独声明JacocoInstrumentation
<instrumentation
android:handleProfiling="true"
android:label="CoverageInstrumentation"
android:name="com.netease.coverage.test.JacocoInstrumentation"
android:targetPackage="com.netease.coverage.jacocotest1"/> <!-- 项目名称 -->
step4:在命令行下通过adb shell am instrument命令调起app,命令:adb shell am instrument com.qunhe.designer/com.coverage.JacocoInstrumentation
step5:拷贝手机目录的/data/data/xxx/coverage.ec文件至app工程根目录/build/outputs/code-coverage/connected下
step6:新增gradle task,修改app module的build.gradle文件
def coverageSourceDirs = [
'../app/src/main/java'
]
task jacocoTestReport(type: JacocoReport) {
group = "Reporting"
description = "Generate Jacoco coverage reports after running tests."
reports {
xml.enabled = true
html.enabled = true
}
classDirectories = fileTree(
dir: './build/intermediates/classes/debug',
excludes: ['**/R*.class',
'**/*$InjectAdapter.class',
'**/*$ModuleAdapter.class',
'**/*$ViewInjector*.class'
])
sourceDirectories = files(coverageSourceDirs)
executionData = files("$buildDir/outputs/code-coverage/connected/coverage.ec")
doFirst {
new File("$buildDir/intermediates/classes/").eachFileRecurse { file ->
if (file.name.contains('$')) {
file.renameTo(file.path.replace('$', '