有赞移动

用 Instrumentation 改良 Monkey 工具实战

这里 Monkey 不是猴子,而是 Android 系统中用来做自动化测试的工具,即盲点、压力测试。

在之前的移动端产品迭代中,Monkey 工具一直没有利用起来。开发同学忙于需求,测试同学资源较少,自动化测试工具欠缺,重视不够。版本发布的流程,压力测试这一环节是完全缺失的。crash 没有在发版前提前发现,也造成我们线上产品 crash 率较高。

App 不同于 H5,一旦发布版本,其更新成本、周期是比较高的。所以应当将发版前的质量保证作为第一要务,确保可靠性。

speed_fight.png

1. 问题及分析

1.1 现象

Monkey 工具的用法,网上有很多资料,在此不作介绍,可参考:UI/Application Exerciser Monkey

用法很简单,但是我们在初步使用 Monkey 的过程中,几乎必然进入一个较深的路径中,再也无法跳出来 —— 可能是在两个页面、或者 Dialog、Input 面板间不断的切换,始终没法关闭页面、逐级跳出。在我测试的过程中,发现几乎都是进入了一个 Webview 页面:

monkey_webview.jpeg

Monkey 走入了死胡同,一直在一个小圈子里、几个页面间打转,无法发挥作用。

1.2 探索

Monkey 的实现原理,参考源码:monkey

可以通过 adb shell 来启动 Monkey 测试:

adb shell monkey -p PACKAGE_NAME --throttle XX --pct-touch XX --pct-motion XX --pct-syskeys XX --pct-appswitch XX -s XX -v -v COUNT > monkey_text.txt

以上指令实际是通过执行一段 shell 脚本来启动 monkey.jar,入口在 Monkey.java:main() 方法当中。

monkey_cmd.jpeg

通过调整 –pct-touch, –pct-motion, –pct-syskeys, –pct-appswitch 等参数比例,monkey 会随机生成相应事件(MonkeySourceRandom.java::generateEvents()):

generate_events.jpeg

Monkey 产生 touch 事件的坐标位置是完全随机的(MonkeySourceRandom.java::generateMotionEvent()):

generate_motion_event.jpeg

1.3 结论分析

所以,到这里,基本上可以对上面的问题做一个解答,即:为什么 Monkey 会进入几个页面后无法跳出?

原因有以下几点:

  1. touch 事件点击的位置是全屏幕随机的;
  2. Webview 中页面几乎是每个地方都可以点击,并且点击后跳到另一个页面;
  3. 虽然页面左上角有返回键、也有物理 Back 键,但是返回键所占的区域只是屏幕上很小一部分,大约只占屏幕点击事件总数的 1/80(按面积计算), 物理 Back 键也只占所有 SYS_KEYS 中的 1/7。这里多么类似于生物蚁群算法,进入死循环就仿佛是找到了最短路径。但遗憾的是,Monkey 的目的是希望能够最大程度覆盖所有可能的执行路径。继续进入下一个页面的可能性永远比退出去更多,除非这个页面的有效点击区域变小才能增大退出来的可能性。

有赞微商城 App 中一个典型的 Webview 页面:

test_goods.jpg

2. 解决方案

如果监听每个 Activity 的启动过程,并且判断它的存活时间,当认为已经太长了,主动将其 finish 掉。这似乎是个可行的方案。由此想到用 Instrumentation,通过 Instrumentaion 启动 App,再开启 Monkey 测试,不就能控制页面深度及存活时间。

这里需要特别注意的是:关闭 Activity 的策略,该如何定制?如果策略不合理,很可能造成

  1. 比较深的页面跑不到
  2. 单页面的点击,测试完整度不够

目前我所使用的策略是:

  1. Top Activity 在没有切换的情况下最长存活时间为15s
  2. 当前 Activity 栈中,从上往下,第一层存活时间 30s,每层递增 30s,超过时间后依次 finish 弹出
  3. 每个 task 最长存活时间 10 分钟

MonkeyInstrumentation 源码附上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
public class MonkeyInstrumentation extends Instrumentation {
private static final String TAG = "MONKEY_INSTRUMENT";
// config params
private long checkTaskInterval = 5000; // 5s
private long topActivitySurvivalTime = 15*1000; // 15s
private long stackActivitySurvivalTimeFirstLevel = 30*1000; // 30s
private long stackActivitySurvivalTimeIncremental = 30*1000; // 30s
private long taskSurvivalTime = 10*60*1000; // 10min
private Handler handler = null;
private ActivityManager activityManager = null;
private List<Activity> activityList = null;
private SparseArray<Long> survivalTimeMap = null;
private Activity currentActivity = null;
private long currentActivitySurvivalTime = 0;
private SparseArray<Long> taskSurvivalTimeMap = null;
public MonkeyInstrumentation() {
super();
}
@Override
public void callApplicationOnCreate(Application app) {
super.callApplicationOnCreate(app);
handler = new Handler();
activityList = new ArrayList<>();
survivalTimeMap = new SparseArray<>();
taskSurvivalTimeMap = new SparseArray<>();
Log.e(TAG, "call application on create, app:" + app);
postCheckTask();
}
@Override
public void callActivityOnCreate(final Activity activity, Bundle icicle) {
super.callActivityOnCreate(activity, icicle);
int index = activityList.size();
activityList.add(activity);
long now = System.currentTimeMillis();
survivalTimeMap.put(index, now);
int taskId = activity.getTaskId();
Log.e(TAG, "create activity, activity:" + activity + ", taskId:" + taskId + ", index:" + index + ", now:" + now);
if (taskSurvivalTimeMap.get(taskId, 0L) == 0) {
taskSurvivalTimeMap.put(taskId, now);
}
}
@Override
public void callActivityOnResume(Activity activity) {
super.callActivityOnResume(activity);
currentActivity = activity;
currentActivitySurvivalTime = System.currentTimeMillis();
}
@Override
public void callActivityOnPause(Activity activity) {
super.callActivityOnPause(activity);
}
@Override
public void callActivityOnDestroy(final Activity activity) {
super.callActivityOnDestroy(activity);
int index = activityList.indexOf(activity);
if (index >= 0) {
activityList.remove(index);
survivalTimeMap.remove(index);
}
}
private void postCheckTask() {
handler.postDelayed(new Runnable() {
@Override
public void run() {
Log.e(TAG, "post check task run");
checkActivityStatus();
postCheckTask();
}
}, checkTaskInterval);
}
private void checkActivityStatus() {
Log.e(TAG, "to checkActivityStatus");
checkCurrentActivity();
checkStackActivity();
checkCurrentStack();
}
private void checkCurrentActivity() {
Log.e(TAG, "checkCurrentActivity");
if (currentActivity != null){
if (System.currentTimeMillis() - currentActivitySurvivalTime > topActivitySurvivalTime) { // 15s
Log.e(TAG, "checkCurrentActivity, to finish a long time activity:" + currentActivity);
currentActivity.finish();
currentActivity = null;
currentActivitySurvivalTime = 0;
}
}
}
private void checkCurrentStack() {
Log.e(TAG, "checkCurrentStack");
if (activityManager == null) {
Context context = getContext();
if (context != null) {
activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
}
}
if (activityManager != null) {
long now = System.currentTimeMillis();
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
List<ActivityManager.AppTask> appTaskList = activityManager.getAppTasks();
if (appTaskList != null && appTaskList.size() > 0) {
ActivityManager.AppTask appTask = appTaskList.get(0);
int taskId = appTask.getTaskInfo().id;
Long taskTime = taskSurvivalTimeMap.get(taskId);
if (taskTime != null && now - taskTime > taskSurvivalTime) {
Log.e(TAG, "finish and remove appTask:" + appTask);
for (int i = activityList.size() - 1; i >= 0; --i) {
if (activityList.get(i).getTaskId() == taskId) {
activityList.remove(i);
survivalTimeMap.remove(i);
}
}
appTask.finishAndRemoveTask();
}
}
} else {
List<ActivityManager.RunningTaskInfo> runningTaskInfoList = activityManager.getRunningTasks(1);
if (runningTaskInfoList != null && runningTaskInfoList.size() > 0) {
ActivityManager.RunningTaskInfo runningTaskInfo = runningTaskInfoList.get(0);
int taskId = runningTaskInfo.id;
Long taskTime = taskSurvivalTimeMap.get(taskId);
if (taskTime != null && now - taskTime > taskSurvivalTime) {
Log.e(TAG, "finish and remove runningTask:" + runningTaskInfo);
for (int i = activityList.size(); i >= 0; --i) {
Activity activity = activityList.get(i);
if (activity.getTaskId() == taskId) {
activityList.remove(i);
survivalTimeMap.remove(i);
activity.finish();
}
}
}
}
}
} else {
Log.e(TAG, "checkActivityStatus, activityManager is null");
}
}
private void checkStackActivity() {
Log.e(TAG, "checkStackActivity");
int len = activityList.size();
long time = stackActivitySurvivalTimeFirstLevel;
long now = System.currentTimeMillis();
Activity needClearActivity = null;
for (int i = len - 1; i > 0; --i) {
if (now - survivalTimeMap.get(i, 0L) > time) {
needClearActivity = activityList.get(i);
break;
}
time += stackActivitySurvivalTimeIncremental; // increment every level
}
if (needClearActivity != null) {
Log.e(TAG, "needClearActivity:" + needClearActivity);
// to clear activity above needClearActivty in this task
int id = needClearActivity.getTaskId();
for (int i = len - 1; i > 0; --i) {
Activity activity = activityList.get(i);
if (activity.getTaskId() == id) {
Log.e(TAG, "clearStackActivity, activity:" + activity);
activityList.remove(i);
survivalTimeMap.remove(i);
activity.finish();
}
}
}
}
}

3. 使用

一、将 MonkeyInstrumentation 集成进 App 项目代码中,并在 AndroidManifest.xml 中声明:

1
2
3
4
<instrumentation
android:name="com.youzan.testtool.MonkeyInstrumentation"
android:targetPackage="${MONKEY_TEST_PACKAGE}" >
</instrumentation>

其中 MONKEY_TEST_PACKAGE 为待测包名,另注意修改 MonkeyInstrumentaion 所在包名。

二、编译安装好 Apk
三、启动 instrumentation, 目标进程启动并监听 Activity 栈存活状态

1
adb shell am instrument MONKEY_TEST_PACKAGE/RUNNER_CLASS

其中 RUNNER_CLASS 即为 MonkeyInstrumentation

四、启动monkey测试

1
adb shell monkey -p MONKEY_TEST_PACKAGE --throttle 300 --pct-touch 60 --pct-motion 15 --pct-syskeys 10 --pct-appswitch 15 -s `date +%H%M%S` -v -v -v --monitor-native-crashes --ignore-timeouts --hprof --bugreport COUNT > monkey_test.txt

五、结果查看

4. 总结

monkey这个工具,看起来很简单,但使用起来还是会遇到这样的坑。以前有专职的测试同学替我们完成monkey,测试,导致对遇到的问题也没有去深究。
发版前的自动化测试,包括UT、UI测试、monkey、内存、性能及流畅度、Apk Size等等,越来越成为上线发版流程中不可或缺的一环,我们在不断的建设完善当中。