糖大夫(简称)是一款血糖仪(想了解更多的同学请看这里http://tdf.qq.com/),但不止血糖仪。血糖仪终端具备触屏、联网、高准度血糖检测单元。除了终端之外,和它配合的还有微信端、医生端。微信端重家属属性,因糖尿病不可治愈,长期的管理中,家庭关怀是重要的一个环节,在患者无法坚持的时候,家庭给予有力的支持。医生端供医生远程了解患者血糖数据,并给予专业指导。
故事的起源源自一次晨会,开发总监在会上主动提出把糖大夫测量流程性能做成日常监控,而碰巧我规划的下半年计划中就囊括了糖大夫自动化,那还在等什么?Just do it!
技术可行性预研
糖大夫测量流程操作步骤依次可以分解为:
1、进入测血糖页面
2、插入试纸
3、校验试纸(是否为已使用过试纸、是否为符合该血糖仪的试纸等)
4、校验通过后自动切换到采血页提示用户滴血
5、用户滴血
6、血糖模组计算血液中血糖浓度
7、血糖模组返回本次测量值给糖大夫app,app从测量过程页自动切换到结果页,并在结果页显示血糖测量值
其中只有步骤1、2、5是用户操作,步骤3、6由底层血糖硬件模组完成,步骤4、7的页面切换以及步骤3、6的检测都是由模组完成后,模组检测后,返回数据给糖大夫app,app根据返回的数据做页面切换
现在遇到了第一个问题---整个测量流程完全依赖于底层的硬件模组,单纯的自动化脚本是无法走完这个流程(单纯的自动化脚本只能进入测血糖页面),那能不能找到一种方法来mock硬件模组和糖大夫app通信,从而通过代码来跳过整个测量流程?
抱着这个目的,首先来看看源代码中糖大夫app和模组之间具体是如何进行通信的,通过阅读项目代码,具体的通信流程如下:
首先血糖模组是一个独立的硬件,模组把一些用户操作(试纸插入/滴血/)转换成数据,然后通过串口和糖大夫app进行通信
而糖大夫通信层负责数据接收,并提供了回调接口供业务层注册,业务层向通信层注册Handler,当通信层接收到模组传递的各类数据后(如试纸插入、采血等),通过注册Handler通知业务层各类事件的发生,而测血糖页面(单Activity+多Fragment)注册了对应的Handler,并在Handler.handleMessage回调函数中,处理各类页面跳转等页面切换操作
通过在采血页加入测试模拟代码拿到采血页注册的Handler,再通过Handler模拟发送各类硬件消息,可以完全跳过整个测量流程,看来从技术上来说是完全可行的!
那么现在又遇到新的问题,测试模拟代码在采血页内部执行,很容易拿到采血页注册的Handler对象,但是测试代码不在采血页内部,并且Activity的创建是由系统完成的,如何拿到测量页Activity实例内的Handler?
好的是(好吧,我承认之前做过类似的hook,所以这里跳转的比较突然),google已经帮我们想到了这个问题,在android4.0以上(4.0以下通过替换ActivityThread内静态Instrumentation类对象来实现),在Application类中提供了一个Activitylifecyclecallbacks接口,接口内回调函数和Activity各生命周期回调一一对应,并且每个回调函数均带有Activity参数,注册后,可以拿到本App内所有的Activity实例
public interface ActivityLifecycleCallbacks {
void onActivityCreated(Activity var1, Bundle var2);
void onActivityStarted(Activity var1);
void onActivityResumed(Activity var1);
void onActivityPaused(Activity var1);
void onActivityStopped(Activity var1);
void onActivitySaveInstanceState(Activity var1, Bundle var2); void onActivityDestroyed(Activity var1);
}
通过测试代码实现这个接口并在Application中注册,然后通过instance of判断是否在采血页,获取到采血页Activity实例后,拿到Handler,然后模拟试纸插入、滴血、测量结束三类消息,自动化跳过了整个测量流程
技术可行性验证通过,那么如何来设计整体架构,自动化脚本通过何种操作app内测试代码?它们之间又是如何通信?
对于自动化脚本来说,它并不关心通信的细节,而且如果暴露了通信细节,反而会加大自动化脚本的开发难度,所以通过封装成SDK的形式来屏蔽通信细节,自动化脚本只需要关注业务即可
在sdk设计中,把一次通信流程抽象为的Request/Response形式,并根据自动化测试业务场景,设计为同步请求(必须要等这个场景完成后才能进入自动化脚本下一步)、异步请求(如添加白名单这种不依赖返回值的操作)两种方式
而SDK本身架构设计,并没有太多东西,只需要做好分层设计,方便后续扩展以及维护即可
在稳定性上,反而需要重点关注,SDK内所有线程都实现了Thread.UncaughtExceptionHandler接口,防止未处理异常外逃到自动化脚本,从而导致自动化脚本crash
在糖大夫APP这一侧,结合已有工具并考虑到后续糖大夫项目会加入越来越多的自动化,通信的接口会越来越多,这一部分必须要易扩展,易维护
通过代码分层设计,测试代码从下到上设计成与业务无关的通信层、负责请求转发的控制层、与业务耦合的逻辑层这种常见架构
同时,考虑到测试代码和开发代码同处于一个工程,必须保证测试代码不影响正式代码的稳定性和安全性,其次糖大夫本身未做分包处理,如果测试代码以及测试代码引入的第三库代码过多,那么很容易超过64K方法数限制;针对上述问题,做了以下措施规避:
1、测试代码放到专门的test包下,通过测试代码和开发代码分属不同的包来实现物理隔离,再通过编译打包控制测试代码不被打进去
2、开发代码中调起测试代码部分(在Application onCreate中调起测试代码),全部使用基类接口引用,并通过反射的方式加载,以防止打正式包出现编译错误
3、除了必须暴露的接口,所有测试接口访问权限均为private,并添加对应的注释,以防止开发人员误调测试接口(这部分主要针对开发代码中调起测试接口部分)
4、所有测试代码,吃掉全部异常,防止触发app内crash上报机制,误报crash
自动化脚本和糖大夫app内测试代码,分处不同的进程,那么他们通过何种进程间通信方式来实现数据交换? 在android中,应用层app常见的通信方式有以下几种:
从编程角度来说,使用广播是最简单,但是广播的缺点很明显---只支持单向通信
不过,既然我们已经设计成sdk这种形式,完全可以通过让sdk和app各注册一个广播的形式来模拟双向通信(基于Uiautomator2的自动化脚本,能拿到Context对象,能很方便的注册广播)
android中,通过binder跨进程传递的数据,只能是基本类型、String、实现了Serializable接口或者Parcelable接口的复合类型,考虑到序列化/反序列化操作难以程度,读取/解析效率,以及后续的可扩展性,选用了json这一常见数据交换方式作为通信协议载体(json和String可以很容易相互转换,json增加一个字段后,除了更改增加字段接口的读取和发送外,其他地方均不需要更改),针对每一个测试接口(比如跳过血糖流程、导入血糖数据),分配一个固定的全局唯一的cmd号
具体的协议格式如下(协议格式)
请求格式:
{ "cmd": 1000, ##要访问的全局唯一接口号
"request data": {} ##请求的数据,可为空}
响应格式:
{ "cmd": 1000, ##本次响应的接口号
"status": 1, ##请求状态(成功为0,非0为失败)
"error msg": "", ##错误信息
"result data": {} ##响应的数据,可为空}
在自动化框架方面,因为糖大夫本身是基于android4.4.2编译,可以完美支持UIautomator2,所以选取UIautomator2作为自动化测试脚本框架
在性能监控以及调度展示平台方面,沿用测试组内部使用的工具/平台即可
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
private static UiDevice sDevice;
@BeforeClass
public static void beforeTest() {
SugarSdk.init(InstrumentationRegistry.getTargetContext().getApplicationContext());
sDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
sDevice.pressHome();
}
@Test
public void testSugar() {
UiObject testBtn = sDevice.findObject(new UiSelector().resourceId("com.tencent.sugardoctor:id/btn_blood_sugar_test")); try {
testBtn.click();
} catch (UiObjectNotFoundException e) {
e.printStackTrace();
}
SkipSugarTestFlow skipSugarTest = new SkipSugarTestFlow();
skipSugarTest.setRequestSugarResult(11.2);
skipSugarTest.waitForSkipSugarTestFlowFinished();
assertEquals(11.2, skipSugarTest.getSugarAppReturnedSugarResult(), 0.001);
}
针对糖大夫APP内接口更新,可以不更新SDK的情况下兼容新接口
/**针对糖大夫app已提供新接口,但SDK还未更新的情况下,自动化脚本可以兼容新接口
*
* 同步请求方式
* **/
@Test
public void testSugarSync() {
UiObject testBtn = sDevice.findObject(new UiSelector().resourceId("com.tencent.sugardoctor:id/btn_blood_sugar_test")); try {
testBtn.click();
} catch (UiObjectNotFoundException e) {
e.printStackTrace();
}
RequestQueue requestQueue = SugarSdk.getRequestQueue();
JSONObject requestData = new JSONObject(); try {
requestData.put("sugarResult", 11.2);
} catch (JSONException e) {
e.printStackTrace();
}
JsonObjectRequest request = new JsonObjectRequest(10001, requestData);
SyncRequest syncRequest = requestQueue.addSyncRequest(request);
Response resp = syncRequest.waitForResponse(); try { double sugarResult = resp.getResultData().getDouble("result");
assertEquals(11.2, sugarResult, 0.001);
} catch (JSONException e) {
e.printStackTrace();
}
} /**针对糖大夫app已提供新接口,但SDK还未更新的情况下,自动化脚本可以兼容新接口
*
* 异步请求方式
* **/
@Test
public void testSugarAsync() {
UiObject testBtn = sDevice.findObject(new UiSelector().resourceId("com.tencent.sugardoctor:id/btn_blood_sugar_test")); try {
testBtn.click();
} catch (UiObjectNotFoundException e) {
e.printStackTrace();
}
RequestQueue requestQueue = SugarSdk.getRequestQueue();
JSONObject requestData = new JSONObject(); try {
requestData.put("sugarResult", 11.2);
} catch (JSONException e) {
e.printStackTrace();
}
JsonObjectRequest request = new JsonObjectRequest(10001, requestData);
requestQueue.addAsyncRequest(request, new AsyncRequest.ResponseListener() { @Override
public void onResponse(Response response) { if(response.isSuccessed()) { try { double sugarResult = response.getResultData().getDouble("result");
assertEquals(11.2, sugarResult, 0.001);
} catch (JSONException e) {
e.printStackTrace();
}
}
}
});
}