前一节介绍了追溯关系的作用,现在落实到如何创建追溯关系。具体到一个App,就是需要知道一个用例执行过程中,它会覆盖哪些代码?创建用例与代码间的关系,保存下来就是追溯关系。那如何记录用例执行覆盖的代码呢?
4.2.1 覆盖率生成追溯关系
移动端的覆盖率通过控制App进入前端和后端进行生成的,要关联用例,首先要控制用例执行过程中覆盖率的生成,也就是执行完一个用例,生成一次覆盖率数据文件,同时将用例信息与覆盖率信息传递给精准测试平台,标注这个覆盖率数据文件是这个用例执行时产生的。
如果要向App传递信息,如用例ID,则需要通过一个技术:deeplink,通过调用deeplink链接,将CaseID接到链接中,app进行处理即可。如果不了解这个技术,请自行搜索,此处不再展开介绍。所以关联用例流程如下所示:

流程介绍:
- 在覆盖率SDK中添加关闻用例弹层,用于展示用例信息,控制用例执行过程中的覆盖率采集。
- 当单击弹层上的开始关联用例时,将用例ID传给App,同时清除原来的覆盖率数据,采集用例的覆盖率数据;
- 执行手工测试用例,执行过程中关联用例弹层显示,用例关联中;
- 用例执行完成后,单击结束关联用例,上传覆盖率文件和用例ID,实现关联用例操作。
- 如果是自动化用例,通过自动化测试脚本在setup函数中调用deeplink传递用例信息,但不弹出关联用例弹层;
- 结束用例执行时,在teardown函数中,执行结束关联用例操作。
注意:如果你的测试用例平台无法与手机发送deeplink命令,也可以通过接口来进行操作,在覆盖率SDK中调用测试用例管理平台接口,获取用例信息。
4.2.2 关联用例弹层开发
关联用例时候,最重要的是要准确记录用例开始执行和结束执行的时机,而且端上的用例有很多前置操作。如要测试设置相关的操作,需要打开应用,登录帐号,进入设置页,然后才能进行具体的测试操作。而前面的很多步骤都是前置操作,按正常用例与代码关联的逻辑,这些操作是不能关联到这个用例上的,否则就容易过多评估用例的覆盖范围,在用例推荐环节也就不准确,容易多推荐用例。
如何解决用例关联时前置操作的问题?
一,需要能准确告诉覆盖率SDK何时开始采集覆盖率数据,何时结束采集覆盖率数据;
二,关联用例时找业务熟练的同学,准确识别出哪些操作步骤是关键步骤。
1,添加关联用例弹层
为了达到准确定位关联用例的操作,对测试App添加如下弹层,这个弹层要显示在被测试应用所有页面之上,除非杀死应用,否则不会消失。

- 被应用添加了关联用例弹层后,通过deeplink请求将用例ID传给应用,弹层显示出用例信息。
- 用例信息为用例ID,每次deeplink请求,用例ID会随之而变化;
- 当用户操作了前置步骤,进入关键步骤时,单击开始录制按钮,开始采集覆盖率数据,录制按钮变成停止按钮;
- 当用例操作执行完成后,单击结束录制按钮,停止覆盖率的采集,并将覆盖率数据文件,用例ID和高力信息上传到精准测试平台,完成用例关联。
- 开始关联下一个用例时,用例ID变化,重新走关联用例操作即可。
2,弹层设计
弹层layout布局代码如下所示:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="300dp"
android:layout_height="wrap_content"
android:background="@null">
<LinearLayout
android:id="@+id/linear_cen"
android:layout_width="280dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="20dp"
android:layout_marginHorizontal="18dp"
android:background="@drawable/bg_dialog">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/textView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginTop="16dp"
android:layout_marginRight="10dp"
android:layout_marginBottom="8dp"
android:text="关联用例:"
android:textColor="#FFFFFF"
android:textSize="16sp"/>
<ImageView
android:id="@+id/btn"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_centerVertical="true"
android:layout_marginHorizontal="9dp"
android:layout_marginTop="11dp"
android:layout_marginBottom="8dp"
android:layout_alignParentRight="true"
android:src="@drawable/ic_start"/>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_toRightOf="@+id/textView4"
android:layout_toLeftOf="@+id/iv_refresh">
<EditText
android:id="@+id/testcase"
android:hint="用例信息"
android:inputType="text"
android:layout_width="120dp"
android:layout_height="40dp"
android:textSize="16sp"
/>
</RelativeLayout>
</RelativeLayout>
</LinearLayout>
</RelativeLayout>
再添加上弹层的显示控制,移动控制等相关操作,注意对弹层上元素的操作添加上相关控制函数。
弹层移动控制类:
import android.annotation.SuppressLint
import android.content.Context
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.customview.widget.ViewDragHelper
const val TAG = "FloatView"
/**
* 记录滑动过的最后的位置
*/
var lastX = -1f
var lastY = -1f
class DragViewParent(context: Context) : FrameLayout(context) {
private lateinit var dragHelper: ViewDragHelper
private lateinit var dragView: View
private val viewWidth = 0
private val viewHeight = 0
fun setDragViewChild(view: View, width: Float, height: Float) {
isClickable = false
dragView = view
var layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT
)
addView(view, layoutParams)
setViewDragHelper()
}
private fun setViewDragHelper() {
dragHelper = ViewDragHelper.create(this, 1.0f, object : ViewDragHelper.Callback() {
override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int {
val leftBound = paddingLeft
val rightBound: Int = width - dragView.width - leftBound
return Math.min(Math.max(left, leftBound), rightBound)
}
override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int {
val leftBound = paddingTop
val rightBound: Int = height - dragView.height - dragView.paddingBottom
return Math.min(Math.max(top, leftBound), rightBound)
}
//在边界拖动时回调
override fun onEdgeDragStarted(edgeFlags: Int, pointerId: Int) {}
override fun getViewHorizontalDragRange(child: View): Int {
return measuredWidth - child.measuredWidth
}
override fun getViewVerticalDragRange(child: View): Int {
return measuredHeight - child.measuredHeight
}
override fun tryCaptureView(child: View, pointerId: Int): Boolean {
return dragView === child
}
override fun onViewPositionChanged(
changedView: View,
left: Int,
top: Int,
dx: Int,
dy: Int
) {
super.onViewPositionChanged(changedView, left, top, dx, dy)
lastX = changedView.x
lastY = changedView.y
}
})
dragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT)
}
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
return dragHelper.shouldInterceptTouchEvent(event)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
dragHelper.processTouchEvent(event)
return true
}
override fun computeScroll() {
if (dragHelper.continueSettling(true)) {
invalidate()
}
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
super.onLayout(changed, l, t, r, b)
restorePosition()
}
private fun restorePosition() {
if (lastX == -1f && lastY == -1f) { // 初始位置
lastX = (measuredWidth-dragView.measuredWidth) * FloatViewManger.proportionX
lastY = (measuredHeight-dragView.measuredHeight) * FloatViewManger.proportionY
}
dragView.layout(
lastX.toInt(),
lastY.toInt(),
lastX.toInt() + dragView.measuredWidth,
lastY.toInt() + dragView.measuredHeight
)
}
}
注意:关联用例弹层需求添加到覆盖率SDK中,直接调用前端覆盖率生成逻辑进行控制即可。
3,deeplink响应处理
我们需要在覆盖率SDK中添加对deeplink请求的处理,解析出deeplink传递过来的用例信息,如下所示:
import android.app.AlertDialog;
import android.app.Application;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.net.Uri;
import android.util.Log;
import android.widget.Toast;
import com.yiqixie.androidcodecoverage.getcodecoverage.JacocoGenerateCoverage;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class DeepLinkForManualTest {
/**
* 通过DeepLink关联手工用例
* @param intent
*/
public void bindManualTestCases(Intent intent){
Log.d("DeepLink","开始启动DeepLink,关联手工用例");
if(intent==null){
Log.d("DeepLink","intent==null");
}
Uri data = intent.getData();
if (data != null) {
String action = data.getQueryParameter("action");
String caseid = data.getQueryParameter("caseid");
if(action.indexOf("setUp")>-1){
//开始关联手工测试用例弹窗
LinkManualTestCases.INSTANCE.show(caseid);
Log.d("DeepLink","开始收集覆盖率数据,用例ID:"+caseid);
}else {
//结束关联手工用例,Toast提醒
LinkManualTestCases.INSTANCE.showtoast(caseid);
Log.d("DeepLink","弹出结束用例提醒!");
}
}else{
Log.d("DeepLink","Data为空!!!");
}
}
}
4.2.3 添加关联用例功能
当关联用例功能开发完成后,被测应用要关联用例时,需要按如下步骤进行集成。
1,集成覆盖率SDK
将覆盖率SDK添加到被测应用中,注意对应用和模块的引用,这是采集覆盖率数据必须的。
2,添加deeplink响应
在App 的AndroidManifest.xml文件中添加如下代码:
<!--添加 deeplink测试 url跳转格式为:open://app.test.com/game-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:scheme="recase"
android:host="example.test"
android:pathPrefix="/autotest"
/>
</intent-filter>
打包后,就可以通过如下调用传递参数:
adb shell am start -W -a android.intent.action.VIEW -d "recase://example.test/autotest?action=setUp\&caseid=LoginTest"
3,覆盖率调用
在App最底层的Activity中添加上添加弹层的操作,保证应用打开的时候完成弹层的初始化工作。在合适的位置添加上对deeplink的调用,以便应用能正确处理deeplink请求。具体的位置因应用不同,要修改的文件位置也不一样,所以此处无法给出具体的位置和示例。
配置完成后,打包即可,打出的包就能响应关联用例操作。
4.2.4 用例管理平台关联用例
在大多数公司都会有一个用例管理平台,无论你使用的是开源的,商业化的还是自研的,用来管理每个需求对应的手工用例。而精准测试的追溯关系,就是要建立手工用例与代码的关联关系。所以如何把用例信息传给覆盖率SDK,创建关联关系,这个是非常重要。
1,手工输入用例ID
在关联用例弹层中,如果把用例信息输入框变成可编辑的,就可以直接输入用例ID,然后关联用例。如果采用此方案,就不需要deeplink相关功能,但是存在着很多用例平台的ID非常长,或是比较复杂,如果用户输入错误,就无法做后面用例解析,从而实现用例与代码的关联关系。
2,deeplink传参
用例管理平台可以添加一个功能,通过向手机传递deeplink请求,将用例ID传递给应用。这有两个方法,如果手机能连接电话,可以直接通过adb请求deeplink;如果不想连接手机,可以通过atx-agent启动的服务,请求Agent相关接口发送deeplink。
3,接口传用例信息
如果用例管理平台无法与手机进行通信,存在内外网不通的问题,就需要优化一下关联用例逻辑。借助于精准测试平台与用例平台互动,获取用例信息,关联用例弹层调用精准测试平台的接口,获取用例ID,同时进行用例ID的切换。注意要以手机设备号区分关联用例操作的人员,以便兼容多个人员同时关联用例。
上面的方案存在着优化用例管理平台,安装atx-agent以及添加精准测试平台用例相关功能,请结合自己的业务选择合适的方案。
4.2.5 覆盖率解析
现在我们可以得到覆盖率数据文件和用例ID信息,那么如何解析出覆盖率数据对应的代码信息呢?
1,解析覆盖率ec文件
通过解析覆盖率ec文件,解析出覆盖率文件中覆盖的文件以及对应的行号。具体方法是,使用jacoco开源的项目,编写解析ec文件的工具,核心代码如下:
import com.google.gson.Gson
import org.jacoco.core.analysis.Analyzer
import org.jacoco.core.analysis.CoverageBuilder
import org.jacoco.core.analysis.ICounter
import org.jacoco.core.tools.ExecFileLoader
import java.io.File
/*******************************************************************************
* Read jacoco exec file, java class file, and source file to produce coverage lines.
*/
data class CoverageInfo(
val filename: String,
var covered: Set<Int>,
var nocovered: Set<Int>
)
class JacocoParserOperation {
val Covered = setOf(ICounter.FULLY_COVERED, ICounter.PARTLY_COVERED)
val NoCovered = setOf(ICounter.NOT_COVERED)
fun readJacocoECFileContent(ecFileNames:Array<File>,classesDir:File,sourceDir: Array<File>,output: File){
val ecFileLoader = ExecFileLoader()
for (file in ecFileNames) {
ecFileLoader.load(file)
}
val coverageBuild = CoverageBuilder()
val analyzer = Analyzer(ecFileLoader.executionDataStore, coverageBuild)
analyzer.analyzeAll(classesDir)
val sourceFileGroup = sourceDir.map {
it.walk().filter { file -> file.isFile }.toSet()
}.fold(setOf<File>()) { s, e -> s + e}.groupBy { it.name }
val bundle = coverageBuild.getBundle("")
val coverageMap = mutableMapOf<String, CoverageInfo>()
bundle.packages.map { pkg ->
for (c in pkg.classes) {
val sourceFile =
sourceFileGroup[c.sourceFileName]?.findLast { f -> f.path.indexOf(c.packageName) > 0 } ?: continue
var coverageInfo = coverageMap[sourceFile.toString()]
if (coverageInfo == null) {
coverageInfo = CoverageInfo(sourceFile.toString(), setOf(), setOf())
}
for (i in c.firstLine..c.lastLine) {
if (c.getLine(i).status in Covered) {
coverageInfo.covered += i
} else if (c.getLine(i).status in NoCovered) {
coverageInfo.nocovered += i
}
}
coverageMap[sourceFile.toString()] = coverageInfo
}
}
val gson = Gson()
val content = gson.toJson(coverageMap)
output.writeText(content)
}
}
2,通过文件和行号查询函数
通过文件与覆盖的行号列表,调用调用链路CCG服务,通过javaparser-ast.jar解析出相应的行对应该的函数列表;最后得到如下所示的数据:
{"类文件1":["fun1","fun2"...],"类文件2":["fun1","fun2"...],"类文件3":["fun1","fun2"...]}
获取到类覆盖的文件和函数列表后,就可以写到追溯关系表中,创建用例和代码的追溯关系了。后续就要以根据追溯关系,根据需求修改代码的影响面,推荐出最小用例集,达到最大的覆盖范围。