测试基础 jacoco 统计 Android 手工测试覆盖率并自动上报服务器

xiaoluosun · 2017年07月25日 · 最后由 剪烛 回复于 2018年11月22日 · 3810 次阅读
本帖已被设为精华帖!

改进了几个点

  1. 不用借助 Instrumentation 启动,正常启动即可;

  2. 测试代码不用 push 到主分支,主分支代码拉到本地后用 git apply patch 方式合并覆盖率代码;

  3. 测试完成后,连按两次 back 键把 app 置于后台,并自动上报覆盖率文件到服务器;

新增覆盖率代码

src 下新建一个 test package,放入下面两个测试类

import android.util.Log;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;

/**
 * Created by sun on 17/7/4.
 */

public class JacocoUtils {
    static String TAG = "JacocoUtils";

    //ec文件的路径
    private static String DEFAULT_COVERAGE_FILE_PATH = "/mnt/sdcard/coverage.ec";

    /**
     * 生成ec文件
     *
     * @param isNew 是否重新创建ec文件
     */
    public static void generateEcFile(boolean isNew) {
//        String DEFAULT_COVERAGE_FILE_PATH = NLog.getContext().getFilesDir().getPath().toString() + "/coverage.ec";
        Log.d(TAG, "生成覆盖率文件: " + DEFAULT_COVERAGE_FILE_PATH);
        OutputStream out = null;
        File mCoverageFilePath = new File(DEFAULT_COVERAGE_FILE_PATH);
        try {
            if (isNew && mCoverageFilePath.exists()) {
                Log.d(TAG, "JacocoUtils_generateEcFile: 清除旧的ec文件");
                mCoverageFilePath.delete();
            }
            if (!mCoverageFilePath.exists()) {
                mCoverageFilePath.createNewFile();
            }
            out = new FileOutputStream(mCoverageFilePath.getPath(), true);

            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));

            // ec文件自动上报到服务器
            UploadService uploadService = new UploadService(mCoverageFilePath);
            uploadService.start();
        } catch (Exception e) {
            Log.e(TAG, "generateEcFile: " + e.getMessage());
        } finally {
            if (out == null)
                return;
            try {
                out.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

上传 ec 文件和设计信息到服务器

import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.HashMap;
import java.util.Map;

import android.util.Log;

import com.x.x.x.LuojiLabApplication;
import com.x.x.x.DeviceUtils;

/**
 * Created by sun on 17/7/4.
 */

public class UploadService extends Thread{

    private File file;
    public UploadService(File file) {
        this.file = file;
    }

    public void run() {
        Log.i("UploadService", "initCoverageInfo");
        // 当前时间
        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        Calendar cal = Calendar.getInstance();
        String create_time = format.format(cal.getTime()).substring(0,19);

        // 系统版本
        String os_version = DeviceUtils.getSystemVersion();

        // 系统机型
        String device_name = DeviceUtils.getDeviceType();

        // 应用版本
        String app_version = DeviceUtils.getAppVersionName(LuojiLabApplication.getInstance());

        // 环境
        String context = "";

        Map<String, String> params = new HashMap<String, String>();
        params.put("os_version", os_version);
        params.put("device_name", device_name);
        params.put("app_version", app_version);
        params.put("create_time", create_time);

        try {
            post("http://x.x.x.x:8888/importCodeCoverage!upload", params, file);
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

    /**
     * 通过拼接的方式构造请求内容,实现参数传输以及文件传输
     *
     * @param url    Service net address
     * @param params text content
     * @param files  pictures
     * @return String result of Service response
     * @throws IOException
     */
    public static String post(String url, Map<String, String> params, File files)
            throws IOException {
        String BOUNDARY = java.util.UUID.randomUUID().toString();
        String PREFIX = "--", LINEND = "\r\n";
        String MULTIPART_FROM_DATA = "multipart/form-data";
        String CHARSET = "UTF-8";


        Log.i("UploadService", url);
        URL uri = new URL(url);
        HttpURLConnection conn = (HttpURLConnection) uri.openConnection();
        conn.setReadTimeout(10 * 1000); // 缓存的最长时间
        conn.setDoInput(true);// 允许输入
        conn.setDoOutput(true);// 允许输出
        conn.setUseCaches(false); // 不允许使用缓存
        conn.setRequestMethod("POST");
        conn.setRequestProperty("connection", "keep-alive");
        conn.setRequestProperty("Charsert", "UTF-8");
        conn.setRequestProperty("Content-Type", MULTIPART_FROM_DATA + ";boundary=" + BOUNDARY);

        // 首先组拼文本类型的参数
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> entry : params.entrySet()) {
            sb.append(PREFIX);
            sb.append(BOUNDARY);
            sb.append(LINEND);
            sb.append("Content-Disposition: form-data; name=\"" + entry.getKey() + "\"" + LINEND);
            sb.append("Content-Type: text/plain; charset=" + CHARSET + LINEND);
            sb.append("Content-Transfer-Encoding: 8bit" + LINEND);
            sb.append(LINEND);
            sb.append(entry.getValue());
            sb.append(LINEND);
        }


        DataOutputStream outStream = new DataOutputStream(conn.getOutputStream());
        outStream.write(sb.toString().getBytes());
        // 发送文件数据
        if (files != null) {
            StringBuilder sb1 = new StringBuilder();
            sb1.append(PREFIX);
            sb1.append(BOUNDARY);
            sb1.append(LINEND);
            sb1.append("Content-Disposition: form-data; name=\"uploadfile\"; filename=\""
                    + files.getName() + "\"" + LINEND);
            sb1.append("Content-Type: application/octet-stream; charset=" + CHARSET + LINEND);
            sb1.append(LINEND);
            outStream.write(sb1.toString().getBytes());


            InputStream is = new FileInputStream(files);
            byte[] buffer = new byte[1024];
            int len = 0;
            while ((len = is.read(buffer)) != -1) {
                outStream.write(buffer, 0, len);
            }

            is.close();
            outStream.write(LINEND.getBytes());
        }


        // 请求结束标志
        byte[] end_data = (PREFIX + BOUNDARY + PREFIX + LINEND).getBytes();
        outStream.write(end_data);
        outStream.flush();
        // 得到响应码
        int res = conn.getResponseCode();
        Log.i("UploadService", String.valueOf(res));
        InputStream in = conn.getInputStream();
        StringBuilder sb2 = new StringBuilder();
        if (res == 200) {
            int ch;
            while ((ch = in.read()) != -1) {
                sb2.append((char) ch);
            }
        }
        outStream.close();
        conn.disconnect();
        return sb2.toString();
    }
}

在 build.gradle 新增

apply plugin: 'jacoco'

jacoco {
    toolVersion = '0.7.9'
}
buildTypes {
    release {
     // 在release下统计覆盖率信息
        testCoverageEnabled = true
    }
}

最重要的一行代码,加在监听设备按键的地方,如果连续 2 次点击设备 back 键,app 已置于后台,则调用生成覆盖率方法。

@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
    if (keyCode == KeyEvent.KEYCODE_BACK) {
        ....

        JacocoUtils.generateEcFile(true);
    }

}

git apply patch

为了不影响工程代码,我这里用 git apply patch 的方式应用的上面的覆盖率代码
首先 git commit 上面的覆盖率代码
然后 git log 查看 commit

我提交覆盖率代码的 commit 是最近的一次,然后拿到上一次的 commit,并生成.patch 文件,-o 是输出目录

git format-patch 0e4c................... -o ~/Documents/jk/script/

把.patch 文件拷贝到自动打包的机器,
使用 Jenkins 自动打包,拉取最新代码后,在编译前 Execute shell 自动执行下面的命令,把覆盖率文件应用到工程内

git apply --reject ~/Documents/jk/script/0001-patch.patch

执行成功后的输出:

然后就可以安装包含统计覆盖率代码的测试包了,测试完后后,连按两次 back 键生成覆盖率文件并上传到服务器。

服务器生成 jacoco 覆盖率报告

在服务器我也拉了一个 Android 工程,专门用于生成报告
主要在 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('$$', '$'))
            }
        }
    }
}

然后设备上传 ec 文件到 Android 工程的 $buildDir/outputs/code-coverage/connected 目录下,并依次执行

gradle createDebugCoverageReport
gradle jacocoTestReport

最后把 $buildDir/reports/jacoco/目录下的覆盖率报告拷贝到展现的位置

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 10 条回复 时间 点赞
思寒_seveniruby 将本帖设为了精华贴 07月25日 19:42

赞,我们也是类似的实现方式,想到一起了~

git apply 其实不用 commit 也可以,只需要把想修改的部分通过 git add 加到暂存区,然后 git diff --cached > xxx.patch 就可以生成 patch 文件了。

另外,分享下 patch 的坑。我们用 patch 这种方式用了 3 个月左右,期间由于被 patch 的部分前后代码有改动导致 patch 失败的出现概率大概是 50%,导致耗费了非常多时间来修正 patch 文件。最后还是选择了 debug 包固定开覆盖率,release 固定关覆盖率的方式取代 patch 这种方式。

http 请求部分建议用 okhttp 库,现在的写法感觉比较累赘。

手动收藏

先 mark 一下

mark 以下,这个比较不错

陈恒捷 回复

确实,git apply 的坑已经遇到了,经常有冲突。后来又优化了下,只在 build.gradle 和一个 activity 里加两行代码。冲突的可能性已经很低了。

http 请求部分直接用的以前的代码,没有优化,有时间改成 okhttp 试下。

如果打包的频率提高,比如两个小时一个包,会影响测试覆盖率的统计结果吧

@xiaoluosun @chenhengjie123
请问这个方案适用于组件化版本吗,可有应用过?我现在遇到的情况是用 task jacocoTestReport 将 coverage.ec 转换为报告时出错了.

aar 包可以覆盖吗

请问一下,我按照流程做下来,在 AS 中直接运行 app,是生成时会找不到 jacoco 的类的,但是新建一个 InstrumentationTest,然后通过 Instrumentation 运行,是可以正常生成覆盖率文件的,请问这个是正常的吗?是怎么实现不通过 Instrumentation 运行,手动运行就可以生成覆盖率文件的呢?
多谢。

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册