8

Android精准测试探索:测试覆盖率统计

 3 years ago
source link: https://tech.kujiale.com/androidjing-zhun-ce-shi-tan-suo-ce-shi-fu-gai-lu-tong-ji/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

背景

随着业务与需求的增长, 回归测试的范围越来越大,测试人员的压力也日益增加。但即使通过测试同学的保障,线上仍然会存在回归不到位或测试遗漏的地方导致出现线上故障。

因此我们需要通过类似jacoco的集成测试覆盖率统计框架,来衡量测试人员的回归范围是否精准、测试场景是否遗漏;保障上线的代码都已经经过测试人员验证。针对这一点,我们提出了Android测试覆盖率统计工具, 借此来提升测试人员精准测试的能力,借助覆盖率数据补充测试遗漏的测试用例。

工具选型

Android APP开发主流语言就是Java语言,而Java常用覆盖率工具为Jacoco、Emma和Cobertura。

Ej6Jzmi.png!web

根据上图的一些特点,我们选择jacoco作为测试覆盖率统计工具。

技术选型

众所周知, 获取覆盖率数据的前提条件是需要完成代码的插桩工作。而针对字节码的插桩方式,可分为两种 —— 1、On-The-Fly 2、Offliine

nu6VRf2.png!web

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

vYRRvir.png!web

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

BVvAZfe.png!web

自动化获取测试覆盖率

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

  • 每次启动app都需要通过adb命令启动instrumentation,比较麻烦
  • 覆盖率报告需要通过编译器执行gradle命令来生成,这就意味着每次测试完成,都必须将ec文件上传到本地开发环境去执行,步骤过于繁琐

因此我们针对这几点,设计了测试覆盖率统计工具2.0版本即自动化获取测试覆盖率,解决方案:

1、为什么一定要通过adb命令启动app才能获得覆盖率数据呢?

我们通过查看代码可以发现,在JacocoInstrumentation类中有这么一段代码:

ueyMBvq.png!web

当InstrumentationActivity结束时,才会将内存中的jacoco覆盖率数据dump到ec文件中。因此我们必须要通过adb启动JacocoInstrumentation,然后杀掉进程后,此时activity会结束并执行输出ec文件的相关功能。

为了解决此问题,那么ec文件的输出触发行为就不能是通过InstrumentationActivity的结束。我们采取的方式是通过触发页面上的一个按钮来执行上述操作。具体后文介绍。

2、为了解决ec文件上传到本地开发环境的繁琐步骤,我们采取的方式是通过jenkins自身提供的jacoco插件去生成覆盖率报告。具体后文介绍。

流程模块设计

流程设计:

j2iiUny.png!web

模块设计:

UVRN73v.png!web

数据生成及上报

step1:手机本地目录生成ec文件

具体操作是:点击app上的按钮,触发dump内存到ec文件的操作

fYBZ3mR.png!web

此时覆盖率ec文件保存在手机sd卡目录下。

部分源码:

ieaArqN.png!webimage2020-4-28_20-11-21.png?version=1&modificationDate=1588075881000&api=v2

从上面的代码中可以看出,当监听到按钮点击事件后,会触发dump内存到ec文件的操作。这种方式可以避免上文提到的必须adb名启动instrumention才可以获取到覆盖率数据的弊端。

step2:触发jenkinspipeline,上报任务

点击Post按钮,自动请求 http://xxx/jenkins/job/jacoco-report-general/build 接口

Ij6N32e.png!web

部分源码:

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 '[email protected]' -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会自动生成一个覆盖率报告:

vyAjmmb.png!web

但是我们需要一个统一的平台来展示每一次报告的指标信息,如环境、代码分支、执行时间、覆盖/未覆盖行数、覆盖率等。酷家乐内部提供了一个覆盖率平台来统一展示,即上文提到的kuafu.qunhequnhe.com。kuafu平台是一个统一的覆盖率展示平台。它收集了各个业务线需要度量的环境和分支信息等。

NfimQjJ.png!web

业务实践

由于android覆盖率目前仅做了全量,尚未做到增量情况。所以报告提供的信息不够明显。后面讲解下怎么看一份全量的覆盖率报告。

1.首先需要有一份已经完成新功能测试(回归测试可以先不考虑)的报告

MNVzQja.png!web

只能看出全量覆盖了多少代码,不能看出本次改动的代码是否覆盖。

而覆盖率的意义就在于确认核心代码是否被测试用例覆盖,以补充测试用例完善测试场景。

因此我们需要确认本次改动的核心代码和需求是否被覆盖到。那么首先我们就需要拿到改动代码的范围,下文介绍。

2.获取当前版本与之前老版本的改动代码

因为目前我们没做增量覆盖率,因此还是手动获取改动代码。这可以借助于gitlab自身提供的compare。

首先,当前测试的app是5.5.0,测试分支是release/release-5.5.0,老版本是5.4.0,分支是release/release-5.4.0。

那么这里source就填写测试分支,target填写老版本分支。然后点击compare按钮进行比对。此时可以看到所有的commit以及代码改动:

Abyumma.png!web

3.获取核心需求的代码文件并进行排查

因为本期核心需求是xx功能,因此我们主要看下该需求的覆盖情况即可。(无法全部核对,因为未支持增量覆盖率,如果一一排查很费时间。)

根据第二步的代码提交相关信息,发现核心的文件如下:

  • 新增文件:app/src/main/java/xx/xx.kt

该文件的主要功能是将app的三方信息发送给头条,进行账号绑定。

查看覆盖率报告:

77NFBf6.png!web

结果:整个文件没有覆盖到。

收益:补充3条用例:app登录qq/微信/微博账号,分享方案到头条

feYNZbf.png!web

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK