分享一個對所有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名,那我干脆反射大搞一通。

其實接下來的代碼就沒什么好放的了,關鍵就兩個:

  1. 寫一套變量生成的規則,依照自定義生成 -> 默認primitive/String構造的優先級來生成每個對象;
  2. 寫一套生成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/

 

 本文由用戶 CruzU70 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!