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文件
1、在<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('$$', '$'))
}
}
}
}
step7:在命令行执行gradle jacocoTestReport或者将AS切换至gradle试图点击jacocoTestReport
这一步需要确保第六步的dir对应的目录下有有编译后的class文件。然后执行gradle命令
step8:在app\build\reports\jacoco\jacocoTestReport\html目录下看到html报告
自动化获取测试覆盖率
上文的“手工获取测试覆盖率”在实际项目中发现存在几个弊端:
每次启动app都需要通过adb命令启动instrumentation,比较麻烦
覆盖率报告需要通过编译器执行gradle命令来生成,这就意味着每次测试完成,都必须将ec文件上传到本地开发环境去执行,步骤过于繁琐
因此我们针对这几点,设计了测试覆盖率统计工具2.0版本即自动化获取测试覆盖率,解决方案:
1、为什么一定要通过adb命令启动app才能获得覆盖率数据呢?
我们通过查看代码可以发现,在JacocoInstrumentation类中有这么一段代码:
当InstrumentationActivity结束时,才会将内存中的jacoco覆盖率数据dump到ec文件中。因此我们必须要通过adb启动JacocoInstrumentation,然后杀掉进程后,此时activity会结束并执行输出ec文件的相关功能。
为了解决此问题,那么ec文件的输出触发行为就不能是通过InstrumentationActivity的结束。我们采取的方式是通过触发页面上的一个按钮来执行上述操作。具体后文介绍。
2、为了解决ec文件上传到本地开发环境的繁琐步骤,我们采取的方式是通过jenkins自身提供的jacoco插件去生成覆盖率报告。具体后文介绍。
流程模块设计
流程设计:
模块设计:
数据生成及上报
step1:手机本地目录生成ec文件
具体操作是:点击app上的按钮,触发dump内存到ec文件的操作
此时覆盖率ec文件保存在手机sd卡目录下。
部分源码:
从上面的代码中可以看出,当监听到按钮点击事件后,会触发dump内存到ec文件的操作。这种方式可以避免上文提到的必须adb名启动instrumention才可以获取到覆盖率数据的弊端。
step2:触发jenkinspipeline,上报任务
点击Post按钮,自动请求http://xxx/jenkins/job/jacoco-report-general/build接口
部分源码:
import android.Manifest
import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.karumi.dexter.Dexter
import com.karumi.dexter.PermissionToken
import com.karumi.dexter.listener.PermissionDeniedResponse
import com.karumi.dexter.listener.PermissionGrantedResponse
import com.karumi.dexter.listener.PermissionRequest
import com.karumi.dexter.listener.single.PermissionListener
import okhttp3.MediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import retrofit2.Retrofit
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
/**
* fragment for coverage test
*
*
*/
class CoverageTestFragment : BaseNewFragment<BasePresenter<IBaseView>>() {
companion object {
private const val GIT_URL = "git地址"
private const val JENKINS_USER_NAME = "jenkins_user_name"
private const val JENKINS_PWD = "jenkins_pwd"
}
private lateinit var mBinding: FragmentCoverageTestBinding
override fun inflateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
mBinding =
DataBindingUtil.inflate(inflater, R.layout.fragment_coverage_test, container, false)
return mBinding.root
}
override fun initView(view: View) {
val builder = OkHttpClient.Builder()
.addNetworkInterceptor(StethoInterceptor())
.build()
val api = Retrofit.Builder()
.baseUrl("jenkins地址")
.client(builder)
.addConverterFactory(StringConverterFactory())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(TestApi::class.java)
mBinding.gitHash.text = BuildConfig.GIT_HASH
mBinding.gitUrl.setText(GIT_URL)
updateEcFileView()
mBinding.deleteExec.setOnClickListener {
TestUtils.deleteEcFile()
updateEcFileView()
}
SharedPreferencesUtil.getString(context, JENKINS_USER_NAME)?.let {
mBinding.userName.setText(it)
}
SharedPreferencesUtil.getString(context, JENKINS_PWD)?.let {
mBinding.password.setText(it)
}
mBinding.post.setOnClickListener {
if (checkParams()) {
mBinding.loading.show()
val userName = mBinding.userName.text.toString()
val password = mBinding.password.text.toString()
val authorization = "Basic ${getBase64String("$userName:$password")}"
val ecFile = TestUtils.getEcFile()
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("json", covertParamString())
.addFormDataPart("name", "execFile")
.addFormDataPart(
"file0",
ecFile.name,
RequestBody.create(
MediaType.parse("application/octet-stream"),
ecFile
)
)
// TODO: 现在没有 mergerFile 先传空的数据 后面有了再加上
.addFormDataPart("name", "mergerFile")
.addFormDataPart(
"file1",
"",
RequestBody.create(
MediaType.parse("application/octet-stream"),
""
)
)
.build()
api.postCoverageParams(authorization, requestBody)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
QhLog.d(it)
showToast("请求成功")
mBinding.loading.dismiss()
}, {
it.printStackTrace()
showToast("请求失败 请检查参数")
mBinding.loading.dismiss()
})
// 保存用户名密码
SharedPreferencesUtil.setString(
context,
JENKINS_USER_NAME,
userName
)
SharedPreferencesUtil.setString(context, JENKINS_PWD, password)
}
}
mBinding.generateEcFile.setOnClickListener {
TestUtils.generateEcFile()
updateEcFileView()
}
checkPermission()
}
private fun updateEcFileView() {
if (TestUtils.existEcFile()) {
mBinding.execFilePath.text = TestUtils.getEcFile().absolutePath
} else {
mBinding.execFilePath.setText(R.string.coverage_test_exec_file_miss)
}
}
private fun checkParams(): Boolean {
if (mBinding.gitUrl.text.toString().isBlank()) {
mBinding.gitUrlLayout.error = "git url 不能为空"
return false
}
if (mBinding.userName.text.toString().isBlank()) {
mBinding.userNameLayout.error = "用户名不能为空"
return false
}
if (mBinding.password.text.toString().isBlank()) {
mBinding.passwordLayout.error = "密码不能为空"
return false
}
if (!TestUtils.existEcFile()) {
showToast("ec 文件未生成")
return false
}
return mBinding.gitUrl.text.toString().isNotBlank() && TestUtils.existEcFile()
}
private fun getBase64String(str: String): String {
return Base64.encodeToString(str.toByteArray(), Base64.NO_WRAP)
}
private fun covertParamString(): String {
val list = ArrayList<Map<String, String>>(6)
list.add(createMap("execFile", null, "file0"))
list.add(createMap("mergerFile", null, "file1"))
list.add(createMap("gitUrl", mBinding.gitUrl.text.toString(), null))
list.add(createMap("branch", "", null))
list.add(createMap("commitHash", mBinding.gitHash.text.toString(), null))
// 这里参数直接写死
val map = HashMap<String, String>()
map["name"] = "gitCredential"
map["credentialType"] = ""
map["required"] = "false"
list.add(map)
val paramMap = HashMap<String, List<Map<String, String>>>()
paramMap["parameter"] = list
return ObjectMapperSingleton.getInstance().writeValueAsString(paramMap)
}
private fun createMap(name: String, value: String?, file: String?): Map<String, String> {
val map = HashMap<String, String>()
map["name"] = name
if (value != null) {
map["value"] = value
} else {
map["file"] = file!!
}
return map
}
private fun checkPermission() {
if (!Dexter.isRequestOngoing()) {
Dexter.checkPermission(object : PermissionListener {
override fun onPermissionGranted(response: PermissionGrantedResponse?) {
}
override fun onPermissionRationaleShouldBeShown(
permission: PermissionRequest?,
token: PermissionToken?
) {
token?.continuePermissionRequest()
}
override fun onPermissionDenied(response: PermissionDeniedResponse?) {
showToast("授权失败")
checkPermission()
}
}, Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
}
}
从上面代码的“mBinding.post.setOnClickListener”方法
中可以看出,当监听到“post”按钮点击事件后,会自动触发jenkinspipeline,去上报任务并生成报告。这种方式可以避免上文提到的本地开发环境生成报告的繁琐步骤。
报告生成
当jenkinspipeline被触发后,会自动生成报告。以下是触发build后的运行脚本:
pipeline {
agent {
label "android-jacoco-slave"
}
parameters {
file(description: 'execFile', name: 'execFile')
file(description: 'mergerFile', name: 'mergerFile')
string(defaultValue: "git地址", description: 'gitUrl', name: 'gitUrl')
string(defaultValue: "分支", description: 'branch', name: 'branch')
string(defaultValue: "commithash", description: 'commitHash', name: 'commitHash')
credentials(defaultValue: "gitCredential的值", description: 'gitCredential', name: 'gitCredential')
}
stages {
stage('clean out') {
steps {
cleanWs()
}
}
stage('checkout') {
steps {
script {
if("${branch}"){
checkout([$class: 'GitSCM', branches: [[name: '*/${branch}']],userRemoteConfigs: [[credentialsId: '${gitCredential}', url: '${gitUrl}']]])
}else{
checkout([$class: 'GitSCM', branches: [[name: '${commitHash}']],userRemoteConfigs: [[credentialsId: '${gitCredential}', url: '${gitUrl}']]])
}
echo "${execFile}"
}
// script {
// println("check start...")
// git branch:'${branch}', credentialsId: '${gitCredential}', url: '${gitUrl}'
// }
}
}
stage('gernal exec') {
steps {
script {
library "jenkinsci-unstashParam-library"
def execFile = unstashParam "execFile"
def commitShortHash = commitHash[0..7]
sh "mkdir classes"
sh "cp -r /Users/git2/designerclass/${commitShortHash}/* classes/"
sh "jar cvf classes.jar classes/"
sh "ls"
sh "pwd"
if("${mergerFile}"){
sh "ls"
def mergerFile = unstashParam "mergerFile"
sh "cat ${mergerFile}"
sh "curl 存储jar包地址 -o jacococli.jar"
sh "cat ${execFile}"
sh "cat ${mergerFile}"
sh "java -jar jacococli.jar merge ${execFile} ${mergerFile} --destfile all.ec"
sh "ls"
sh "cat all.ec"
if (fileExists("${execFile}")) {
sh "rm ${execFile}"
} else {
println "${execFile} not found"
}
if (fileExists("${mergerFile}")) {
sh "rm ${mergerFile}"
} else {
println "${mergerFile} not found"
}
}
}
println("------------------------------")
}
}
stage('Build') {
steps {
// sh "mvn clean"
// sh "mvn clean package -U"
// echo "${execFile}"
println("------------------------------")
}
}
stage('Jacoco report') {
steps {
sh "ls"
sh "pwd"
jacoco(
execPattern: '**/**.ec',
classPattern: '**/classes',
sourcePattern: '**/app/src/main/java',
exclusionPattern: '**/*$InjectAdapter.class,**/*$ModuleAdapter.class,**/*$ViewInjector*.class,**/*Binding.class,**/*BindingImpl.class'
)
}
}
//这一步是将jenkins的覆盖率数据传给kuafu平台,可以忽略
stage('after generate report') {
steps {
echo "${BUILD_ID}"
script {
def branchInfo = "null"
def commitId = "null"
if("${branch}"){
branchInfo = "${branch}"
}
if("${commitHash}"){
commitId = "${commitHash}"
}
def result = sh(script:"curl http://kuafu.qunhequnhe.com/api -X POST -d '{\"repo\": \"git地址\", \"serviceUuid\":\"项目名称\", \"branch\":\"${branchInfo}\", \"env\":\"dev\" , \"userName\":\"appJenkins\", \"tag\":\"null\", \"vip\":\"\", \"imageName\":\"null\", \"commitId\":\"${commitId}\"}' -H 'Content-Type:application/json' ", returnStdout: true).trim()
echo "${result}"
def taskId = null
try {
def resJson = readJSON text: "${result}"
taskId = "${resJson.data.taskId}"
} catch (e){
echo "Error: " + e.toString()
}
echo "${taskId}"
if ("${taskId}") {
sh "curl -XPOST http://kuafu.qunhequnhe.com/api -F 'file=@${execFile}' -H "
sh "curl -XPOST http://kuafu.qunhequnhe.com/api -F 'file=@classes.jar' -H "
}
}
}
}
stage('clear unuseful class') {
steps {
script {
sh "pwd"
def path="/Users/git2/designerclass"
sh "ls ${path}"
def result = sh(script: "find ${path} -maxdepth 1 -mtime +10 -type d", returnStdout: true).trim()
echo "${result}"
sh "find ${path} -maxdepth 1 -mtime +10 -type d -exec rm -Rf {} \\;"
echo "clear ${path} 10 days ago' files done"
}
}
}
}
post {
always {
script {
sh "date"
sh "ls"
sh "pwd"
}
}
}
}
构建面板有以下参数,现在具体介绍下:
execFile:本地上传一个ec文件
mergerFile:默认不上传文件时,即生成execfile参数对应的ec文件覆盖率报告;若同时上传了execfile参数对应的ec1,mergerfile上传了对应ec2,那么脚本会先将ec1和ec2merge成all.ec文件,然后对all.ec生成覆盖率报告。
gitUrl:app repo地址
branch: 填写当前测试包的源码所对应的repo的分支,branch和commitHash仅填一个,建议填写hash
commitHash:填写当前测试包的源码所对应的git 提交hash值,branch和commitHash仅填一个。建议填写hash值,因为一旦branch提交了新的代码,那源码就和ec文件不匹配了。而hash值是唯一的。
gitCredential:认证账号密码,不用特意选择,默认全局通用账号就可以
build按钮
stage('gernal exec') 介绍
execFile:本地上传一个ec文件
mergerFile:默认不上传文件时,即生成execfile参数对应的ec文件覆盖率报告;若同时上传了execfile参数对应的ec1,mergerfile上传了对应ec2,那么脚本会先将ec1和ec2merge成all.ec文件,然后对all.ec生成覆盖率报告。
主要是根据以上两个参数来判断是否需要mergec文件。
由于酷家乐这边app的ci服务器打包app时会自动生成class文件。所以我们把每次生成的class文件copy到/designerclass/{gitcommithash}文件下,gitcommithash是git提交时的hash值。那么/designerclass/{gitcommithash}下可能会有hash1文件夹,hash2文件夹。然后通过参数commitHash取对应的hash文件夹再copy到/Jenkins/class文件夹下。
stage('after generate report')介绍
触发覆盖率平台,把覆盖率相关指标信息传给kuafu.qunhequnhe.com平台。kuafu平台具体会在页面展示中介绍。
stage('clear unuseful class')介绍
由于每次打包都会生成一个/designerclass/{gitcommithash}文件夹,里面包含class文件。一个迭代结束后,那么很多文件势必会无用或已过期。因此这里做了一次删除操作,如果是10天前创建的文件,我们就把他删除掉。
find ${path} -maxdepth 1 -mtime +10 -type d -exec rm -Rf {} \\;意思是,删除designerclass文件下的10天前修改的子文件夹。页面展示
当pipeline执行完成后,jenkins会自动生成一个覆盖率报告:
但是我们需要一个统一的平台来展示每一次报告的指标信息,如环境、代码分支、执行时间、覆盖/未覆盖行数、覆盖率等。酷家乐内部提供了一个覆盖率平台来统一展示,即上文提到的kuafu.qunhequnhe.com。kuafu平台是一个统一的覆盖率展示平台。它收集了各个业务线需要度量的环境和分支信息等。
全量覆盖率展示
业务实践
由于android覆盖率目前仅做了全量,尚未做到增量情况。所以报告提供的信息不够明显。后面讲解下怎么看一份全量的覆盖率报告。
1.首先需要有一份已经完成新功能测试(回归测试可以先不考虑)的报告
报告:
只能看出全量覆盖了多少代码,不能看出本次改动的代码是否覆盖。
而覆盖率的意义就在于确认核心代码是否被测试用例覆盖,以补充测试用例完善测试场景。
因此我们需要确认本次改动的核心代码和需求是否被覆盖到。那么首先我们就需要拿到改动代码的范围,下文介绍。
2.获取当前版本与之前老版本的改动代码
因为目前我们没做增量覆盖率,因此还是手动获取改动代码。这可以借助于gitlab自身提供的compare。
首先,当前测试的app是5.5.0,测试分支是release/release-5.5.0,老版本是5.4.0,分支是release/release-5.4.0。
那么这里source就填写测试分支,target填写老版本分支。然后点击compare按钮进行比对。此时可以看到所有的commit以及代码改动:
3.获取核心需求的代码文件并进行排查
因为本期核心需求是xx功能,因此我们主要看下该需求的覆盖情况即可。(无法全部核对,因为未支持增量覆盖率,如果一一排查很费时间。)
根据第二步的代码提交相关信息,发现核心的文件如下:
新增文件:app/src/main/java/xx/xx.kt
该文件的主要功能是将app的三方信息发送给头条,进行账号绑定。
查看覆盖率报告:
结果:整个文件没有覆盖到。
收益:补充3条用例:app登录qq/微信/微博账号,分享方案到头条
新增了文件:app/src/main/java/xx/xx.kt
该功能主要是方案的各个分享渠道,包括分享到qq、微信等。
查看覆盖率报告:
结果:发现除了头条外,其他的分享渠道都没覆盖到。但是本期需求只有头条是新增的渠道,其他都是老功能,理论上并不需要覆盖。可是这个文件却是新增的。和开发沟通过,开发解释是将以前的代码迁移到了新文件(未告知测试)。
收益:补充以下用例:需要回归:分享投稿方案到所有渠道。