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命令

自动化获取测试覆盖率

上文的“手工获取测试覆盖率”在实际项目中发现存在几个弊端:

  • 每次启动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/微信/微博账号,分享方案到头条


结果:发现除了头条外,其他的分享渠道都没覆盖到。但是本期需求只有头条是新增的渠道,其他都是老功能,理论上并不需要覆盖。可是这个文件却是新增的。和开发沟通过,开发解释是将以前的代码迁移到了新文件(未告知测试)。

收益:补充以下用例:需要回归:分享投稿方案到所有渠道。

关注我们

酷家乐质量效能团队热衷于技术的成长和分享,几乎每个月都会举办技术分享活动(海星日),每半年举办一次技术专题竞赛分享(火星日),并将优秀内容写成技术文章。

我们尽可能保障分享到社区的内容,是我们用心编写、精心挑选的优质文章。如果您想更全面地阅读我们的文章,请您关注我们的微信公众号"酷家乐技术质量"。


comments powered by Disqus