分享一個對所有Activity做單元測試的思路
最近升級了一下我們的Support庫,這影響比較大,應該好好測試。這種情況下單元測試能幫助什么呢?我覺得有一定操作空間,于是想做一個“啟所有Activity看看會不會崩潰”的功能。
Idea 1 - 手動解析配合MonekyRunner
aapt有一個命令是解析一個apk的 AndroidManifest ,一開始我就從這上面下手:
aapt dump xmltree ${apkpath} AndroidManifest.xml
它會輸出類似如下字樣: (做了一定精簡)
N: android=http://schemas.android.com/apk/res/android
E: manifest (line=2)
A: android:versionCode(0x0101021b)=(type 0x10)0x68fb0
A: android:versionName(0x0101021c)="4.30.0-preview" (Raw: "4.30.0-preview")
A: package="tv.danmaku.bili" (Raw: "tv.danmaku.bili")
E: uses-permission (line=17)
A: android:name(0x01010003)="android.permission.READ_EXTERNAL_STORAGE" (Raw: "android.permission.READ_EXTERNAL_STORAGE")
E: application (line=41)
A: android:theme(0x01010000)=@0x7f0d0007
E: activity (line=51)
A: android:theme(0x01010000)=@0x7f0d0047
A: android:name(0x01010003)="com.desmond.test.MainActivity" (Raw: "tv.danmaku.bili.ui.splash.SplashActivity")
它會以一個類似 AndroidManifest.xml 樹的形式打出信息,這樣一來我們可以用python腳本來輕易地處理它的輸出,利用正則匹配去匹配帶有Activity名字的那一行,并解析出Activity名字列出來:
defparseActivities():
pattern = re.compile(r'A: android:name(?:\([^\)]*\))="([^"]*)"')
result = [] #存放所有Activity名字
output = os.popen('aapt dump xmltree ' + apk + ' AndroidManifest.xml')
content = output.readlines()
target_line = -1
for i in range(len(content)):
line = content[i]
strip = line.rstrip('\n').lstrip(' ')
if i == target_line:
match = pattern.match(strip)
if match:
activity = match.groups()[0]
print "Found activity : " + activity
result.append(activity)
if strip.startswith('E: activity'):
target_line = i + 2
return result
本來我是想配合Android的 MonkeyRunner 去做的,啟動每個Activity之后截屏保存,因為都是python寫也會比較方便。但是想法太天真,它 啟動不了非export的Activity。就放棄了 。
其實一開始我很天真地很自然地想到了這個方法,雖然后面沒用,但是也寫在這里好了。
Idea 2 - Instrument測試
我嘗試著使用Instrument Test來完成這個任務,在這個過程中找到了最終方案。
Android的test support庫提供了一個 ActivityTestRule ,它的作用是保證每個test執行前啟動指定Activity,執行后結束Activity。這下我們可以參考一下它的代碼,它是怎么 同步 啟動Activity的?
其實沒什么神秘面紗, Instrumentation 直接提供了同步啟Activity的辦法,我直接貼出關鍵代碼好了:
Activity activity = mInstrumentation.startActivitySync(intent);
if (activity == null) {
throw new ActivityNotFoundException("Cannot find activity for:" + intent.getComponent().getClassName());
}
mInstrumentation.waitForIdleSync();
實際上 startActivitySync 就是有一個對象鎖,在 startActivity 后讓它 wait ,然后在目標Activity啟動時會調用 Instrumentation.prePerformCreate ,在這里向主線程添加一個 IdleHandler ,在它里面 notify 這個鎖達到同步啟動的效果。
那我們就可以利用這段代碼來干點事情,在InstrumentTest里面我們可以拿到context,于是就能產出如下一段代碼:
@Test
publicvoidtestActivities(){
Context targetContext = InstrumentationRegistry.getTargetContext();
PackageManager pm = targetContext.getPackageManager();
PackageInfo info = null;
try {
info = pm.getPackageInfo(targetContext.getPackageName(), PackageManager.GET_ACTIVITIES);
} catch (PackageManager.NameNotFoundException e) {
fail(e.getMessage());
}
ActivityInfo[] activities = info.activities;
for (int i = 0, length = activities.length; i < length; i++) {
ActivityInfo aInfo = activities[i];
Log.i(TAG, "[" + i + "] Try launch activity:" + aInfo.name);
try {
tryStartActivity(targetContext, aInfo.name);
} catch (Exception e) {
Log.w(TAG, "Error in " + aInfo.name + " : " + e.getMessage());
}
}
}
我們在里面通過 PackageManager 的API來獲取APK包名里的所有Activity,通過 ActivityInfo 里面的name來拿到這個Activity的class名,然后可以構造一個Intent,利用之前說的方法來同步啟動它。
我們在 ActivityThread 里面看到, performLaunchActivity 等生命周期回調都是被包圍在try/catch里面的,如果目標Activity的 onCreate / onStart / onResume 里面崩潰了,會調用 Instrumentation.onException 函數,而Android的測試里面對應的Instrumentation是 AndroidJUnitRunner ,它繼承了這個方法,并使測試失敗,記錄堆棧:
//AndroidJUnitRunner
@Override
publicbooleanonException(Object obj, Throwable e){
InstrumentationResultPrinter instResultPrinter = getInstrumentationResultPrinter();
if (instResultPrinter != null) {
// report better error message back to Instrumentation results.
instResultPrinter.reportProcessCrash(e);
}
return super.onException(obj, e);
}
所以說,如果這個時候想啟的Activity崩了,我們能夠 立即拿到反饋,從而得到測試的效果 。
但是事實往往沒有這么簡單,這時候有一個難題了:我們的Activity通常需要在Intent里面傳入一些參數,如果不夠造就是非法Intent, 即使測試失敗不能證明有問題 。而這個時候的適配,往往就不是一個框架能夠解決得了,需要一個團隊里有良好的編碼習慣(代碼風格一致),或者足夠的時間去寫一些自定義注解做解析適配。
我們項目里的Activity基本都有一個static的 createIntent 方法,通過調用這個方法傳入參數來構建Intent啟動它。這時候我就又有一個小想法:既然能獲取到這個Activity的class名,那我干脆反射大搞一通。
其實接下來的代碼就沒什么好放的了,關鍵就兩個:
- 寫一套變量生成的規則,依照自定義生成 -> 默認primitive/String構造的優先級來生成每個對象;
- 寫一套生成Activity啟動Intent的規則機制,很多時候不是依靠隨機放幾個變量就能構造出Intent,有些Activity需要跳過(比如微信的 WXEntryActivity ),有些Activity只要簡單的start就好,有些Activity需要特殊變量構造,有些Activity就隨便放變量就行。
以上兩點通過一定時間的編碼應該能比較容易寫出,我這里大概放一下我的代碼:
for (Method method : activityClass.getDeclaredMethods()) {
if (method.getName().equals("createIntent") &&
!Modifier.isPrivate(method.getModifiers()) &&
Modifier.isStatic(method.getModifiers())) {
Log.d(TAG, "try start intent by method: " + method.toGenericString());
method.setAccessible(true);
haveCreateIntent = true;
Class[] clzs = method.getParameterTypes();
Object[] parameters = new Object[clzs.length];
for (int i = 0, length = clzs.length; i < length; i++) {
Class clz = clzs[i];
parameters[i] = GeneratorRegistry.newInstance(clz); // 根據class生成值的規則
}
Intent startIntent = (Intent) method.invoke(null, parameters);
startIntent.setClassName(mTargetContext.getPackageName(), activityClass.getName());
launchActivitySync(startIntent);
}
}
代碼也不太多,各位讀者可以自行實現,大概效果如下:
不過這個測試說實在話也無法保證很多東西,能測出一些比較低級的崩潰,還有能自動化測試所有Activity,要不是跑一下它我還不知道原來我們有一百多個Activity…就是適配麻煩了點,還有后續的改動要更新也比較麻煩,可以酌情應用。
來自:http://blog.desmondyao.com/super-activity-test/