分享一个对所有Activity做单元测试的思路

MableBollin 8年前
   <p>最近升级了一下我们的Support库,这影响比较大,应该好好测试。这种情况下单元测试能帮助什么呢?我觉得有一定操作空间,于是想做一个“启所有Activity看看会不会崩溃”的功能。</p>    <h2><strong>Idea 1 - 手动解析配合MonekyRunner</strong></h2>    <p>aapt有一个命令是解析一个apk的 AndroidManifest ,一开始我就从这上面下手:</p>    <p>aapt dump xmltree ${apkpath} AndroidManifest.xml</p>    <p>它会输出类似如下字样: (做了一定精简)</p>    <pre>  <code class="language-java">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")  </code></pre>    <p>它会以一个类似 AndroidManifest.xml 树的形式打出信息,这样一来我们可以用python脚本来轻易地处理它的输出,利用正则匹配去匹配带有Activity名字的那一行,并解析出Activity名字列出来:</p>    <pre>  <code class="language-java">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  foriinrange(len(content)):   line = content[i]   strip = line.rstrip('\n').lstrip(' ')  ifi == target_line:   match = pattern.match(strip)  ifmatch:   activity = match.groups()[0]  print"Found activity : "+ activity   result.append(activity)    ifstrip.startswith('E: activity'):   target_line = i + 2  returnresult  </code></pre>    <p>本来我是想配合Android的 MonkeyRunner 去做的,启动每个Activity之后截屏保存,因为都是python写也会比较方便。但是想法太天真,它 <strong>启动不了非export的Activity。就放弃了</strong> 。</p>    <p>其实一开始我很天真地很自然地想到了这个方法,虽然后面没用,但是也写在这里好了。</p>    <h2><strong>Idea 2 - Instrument测试</strong></h2>    <p>我尝试着使用Instrument Test来完成这个任务,在这个过程中找到了最终方案。</p>    <p>Android的test support库提供了一个 ActivityTestRule ,它的作用是保证每个test执行前启动指定Activity,执行后结束Activity。这下我们可以参考一下它的代码,它是怎么 <strong>同步</strong> 启动Activity的?</p>    <p>其实没什么神秘面纱, Instrumentation 直接提供了同步启Activity的办法,我直接贴出关键代码好了:</p>    <pre>  <code class="language-java">Activity activity = mInstrumentation.startActivitySync(intent);  if(activity ==null) {  thrownewActivityNotFoundException("Cannot find activity for:"+ intent.getComponent().getClassName());  }  mInstrumentation.waitForIdleSync();  </code></pre>    <p>实际上 startActivitySync 就是有一个对象锁,在 startActivity 后让它 wait ,然后在目标Activity启动时会调用 Instrumentation.prePerformCreate ,在这里向主线程添加一个 IdleHandler ,在它里面 notify 这个锁达到同步启动的效果。</p>    <p>那我们就可以利用这段代码来干点事情,在InstrumentTest里面我们可以拿到context,于是就能产出如下一段代码:</p>    <pre>  <code class="language-java">@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(inti =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());   }   }   }  </code></pre>    <p>我们在里面通过 PackageManager 的API来获取APK包名里的所有Activity,通过 ActivityInfo 里面的name来拿到这个Activity的class名,然后可以构造一个Intent,利用之前说的方法来同步启动它。</p>    <p>我们在 ActivityThread 里面看到, performLaunchActivity 等生命周期回调都是被包围在try/catch里面的,如果目标Activity的 onCreate / onStart / onResume 里面崩溃了,会调用 Instrumentation.onException 函数,而Android的测试里面对应的Instrumentation是 AndroidJUnitRunner ,它继承了这个方法,并使测试失败,记录堆栈:</p>    <pre>  <code class="language-java">//AndroidJUnitRunner  @Override  publicbooleanonException(Object obj, Throwable e){   InstrumentationResultPrinter instResultPrinter = getInstrumentationResultPrinter();  if(instResultPrinter !=null) {  // report better error message back to Instrumentation results.   instResultPrinter.reportProcessCrash(e);   }  returnsuper.onException(obj, e);  }  </code></pre>    <p>所以说,如果这个时候想启的Activity崩了,我们能够 <strong>立即拿到反馈,从而得到测试的效果</strong> 。</p>    <p>但是事实往往没有这么简单,这时候有一个难题了:我们的Activity通常需要在Intent里面传入一些参数,如果不够造就是非法Intent, <strong>即使测试失败不能证明有问题</strong> 。而这个时候的适配,往往就不是一个框架能够解决得了,需要一个团队里有良好的编码习惯(代码风格一致),或者足够的时间去写一些自定义注解做解析适配。</p>    <p>我们项目里的Activity基本都有一个static的 createIntent 方法,通过调用这个方法传入参数来构建Intent启动它。这时候我就又有一个小想法:既然能获取到这个Activity的class名,那我干脆反射大搞一通。</p>    <p>其实接下来的代码就没什么好放的了,关键就两个:</p>    <ol>     <li>写一套变量生成的规则,依照自定义生成 -> 默认primitive/String构造的优先级来生成每个对象;</li>     <li>写一套生成Activity启动Intent的规则机制,很多时候不是依靠随机放几个变量就能构造出Intent,有些Activity需要跳过(比如微信的 WXEntryActivity ),有些Activity只要简单的start就好,有些Activity需要特殊变量构造,有些Activity就随便放变量就行。</li>    </ol>    <p>以上两点通过一定时间的编码应该能比较容易写出,我这里大概放一下我的代码:</p>    <pre>  <code class="language-java">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 = newObject[clzs.length];  for(inti =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);   }  }  </code></pre>    <p>代码也不太多,各位读者可以自行实现,大概效果如下:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/d30dffdaa43c8c00bfca5038f385095c.gif"></p>    <p>不过这个测试说实在话也无法保证很多东西,能测出一些比较低级的崩溃,还有能自动化测试所有Activity,要不是跑一下它我还不知道原来我们有一百多个Activity…就是适配麻烦了点,还有后续的改动要更新也比较麻烦,可以酌情应用。</p>    <p> </p>    <p>来自:http://blog.desmondyao.com/super-activity-test/</p>    <p> </p>