一款面向Android端的开源自动化测试框架,,Robotium是基于Instrumentation的测试框架,器测试用例的编写框架是基于Junit的 - 优势 - 同时支持Native应用和Hybrid(混合) - 支持黑盒测试和白盒 - 基于Instrumentation,测试用例执行速度快 - 运行时识别的是控件,测试用例更健壮 - 可以通过Maven,Gradle或Ant运行 - 可以没有项目代码,只有APK文件 - 可以截图 - 缺点 - 无法跨应用 - 代码运行在被测进程,会影响性能,无法同时监控性能
关于Eclipse的使用在Robotium的GitHub主页中可以找到文档,以下均是基于AndroidStudio的
要完成对手机的模拟操作,应包含以下几个基本操作: 1. 需要知道所要控件的坐标 2. 对要操作的控件进行模拟操作 3. 判断操作完成后的结果是否符合预期
在需要测试modle中添加依赖
androidTestCompile 'com.jayway.android.robotium:robotium-solo:5.6.3'AndroidStudio 在创建项目的时候,会默认的创建出androidTest路径,如果没有的话,需要在想要测试的module中的src文件夹下创建一个androidTest/java的包,然后配置module的build.gradle来指向它
android { sourceSets { androidTest { java.srcDirs = ['androidTest/java'] } } }然后就可以写测试代码了,在java中建一个和项目根包名相同的包,我们带代码都放到这里面写一个简单的求和功能
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/activity_main" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingBottom="@dimen/activity_vertical_margin" android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingRight="@dimen/activity_horizontal_margin" android:paddingTop="@dimen/activity_vertical_margin" android:orientation="vertical" tools:context="com.lanou.chenfengyao.robotiumdemo.MainActivity"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"> <EditText android:id="@+id/num1_et" android:layout_width="wrap_content" android:layout_height="wrap_content" android:hint="a"/> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="+"/> <EditText android:id="@+id/num2_et" android:layout_width="wrap_content" android:layout_height="wrap_content" android:hint="b"/> <TextView android:id="@+id/result_tv" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="=结果"/> </LinearLayout> <Button android:id="@+id/get_result_btn" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="求和"/> </LinearLayout>MainActivity代码
package com.lanou.chenfengyao.robotiumdemo; import android.os.Bundle; import android.support.v7.app.AppCompatActivity; import android.view.View; import android.widget.Button; import android.widget.EditText; import android.widget.TextView; public class MainActivity extends AppCompatActivity { private EditText num1Et, num2Et; private Button getResultBtn; private TextView resultTv; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); num1Et = (EditText) findViewById(R.id.num1_et); num2Et = (EditText) findViewById(R.id.num2_et); getResultBtn = (Button) findViewById(R.id.get_result_btn); resultTv = (TextView) findViewById(R.id.result_tv); getResultBtn.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { int a = Integer.valueOf(num1Et.getText().toString()); int b = Integer.valueOf(num2Et.getText().toString()); //故意写错了 resultTv.setText("= " + a + b); } }); } }可以看到点击按钮的时候就会求和,并写回TextView上,可以看到我们的求和结果是故意写错了,直接这么写会是字符串的拼接而不是求和的,我们会通过测试代码来找到这个错误
在androidTest/java/包名下新建一个Java class,开始写我们的测试代码,测试代码类需要继承ActivityInstrumentationTestCase2,这是测试单独Activity时需要继承的泛型填写我们需要测试的Activity类名,接下来是代码
import android.test.ActivityInstrumentationTestCase2; import android.widget.Button; import android.widget.EditText; import android.widget.TextView; import com.robotium.solo.Solo; /** * If there is no bug, then it is created by ChenFengYao on 2017/3/3, * otherwise, I do not know who create it either. */ public class TestFirst extends ActivityInstrumentationTestCase2<MainActivity> { private Solo solo; public TestFirst() { super(MainActivity.class); } @Override protected void setUp() throws Exception { super.setUp(); solo = new Solo(getInstrumentation()); getActivity(); } public void testRun() { EditText num1Et = (EditText) solo.getView(R.id.num1_et); EditText num2Et = (EditText) solo.getView(R.id.num2_et); Button resultBtn = (Button) solo.getView(R.id.get_result_btn); TextView resultTv = (TextView) solo.getView(R.id.result_tv); solo.enterText(num1Et,"1"); solo.enterText(num2Et,"2"); solo.clickOnView(resultBtn); solo.sleep(200); assertEquals("= 3",resultTv.getText().toString()); } }这段代码我们复写了setUp方法,在setUp方法里我们初始化了Robotium中最重要的对象,Solo对象,几乎所有对于UI的操作都是针对这个Solo对象的,然后通过调用getActivity来启动MainActivity
下面 是testRun方法,这个方法并不是重写出来的,而是就写成这个名字,写成testXX的都能被识别,在testRun里,通过solo.getView方法来获得界面中我们所有需要的View元素,然后通过solo.enterText对EditText进行赋值,接下来调用button的点击事件,值得注意的是,测试代码并 不是运行在主线程中 的,所以我们没有办法直接操作view元素来进行更改ui,也正因为这样,我们在点击过后让solo等待一下,方便让点击事件生效,最后开始断言,textview中的文字是否是我们期望的文字,如果是的话,就证明我们的程序是没有问题的.
点击类名左边的绿色箭头就可以直接运行,也可以在运行项目那选择测试用例点击运行按钮
可以看到程序运行后自动的填写了数组,并点击了确定,AndroidStudio的控制台也自动变成到了测试的界面,而测试页面也出现了错误日志 第一句就告诉了我们 我们期待的结果是 = 3,而 出现的结果是 = 12,我们把程序代码改正再来运行一下 这回出现了我们期待的结果,测试就通过了
Android也提供了通过注解的形式来运行测试代码,要比之前的形式更优雅一点
import android.support.test.InstrumentationRegistry; import android.support.test.rule.ActivityTestRule; import android.support.test.runner.AndroidJUnit4; import android.widget.Button; import android.widget.EditText; import android.widget.TextView; import com.robotium.solo.Solo; import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import static org.junit.Assert.assertEquals; /** * If there is no bug, then it is created by ChenFengYao on 2017/3/3, * othrwise, I do not know who create it either. */ @RunWith(AndroidJUnit4.class) public class TestFirst{ private Solo solo; @Rule public ActivityTestRule<MainActivity> activityTestRule = new ActivityTestRule<>(MainActivity.class); @Before public void setUp() throws Exception { solo = new Solo(InstrumentationRegistry.getInstrumentation(), activityTestRule.getActivity()); } @Test public void testRun() { EditText num1Et = (EditText) solo.getView(R.id.num1_et); EditText num2Et = (EditText) solo.getView(R.id.num2_et); Button resultBtn = (Button) solo.getView(R.id.get_result_btn); TextView resultTv = (TextView) solo.getView(R.id.result_tv); solo.enterText(num1Et,"1"); solo.enterText(num2Et,"2"); solo.clickOnView(resultBtn); solo.sleep(200); assertEquals("= 3",resultTv.getText().toString()); } @After public void tearDown() throws Exception { solo.finishOpenedActivities(); } }效果与之前的测试代码是一样的,就不额外演示了,与之前的代码不同,我们想要获得Activity对象则需要通过@Rule注解来获得,@Before和@After都是junit框架提供的,代表测试代码运行前和运行后,而测试方法则需要是用@Test注解进行修饰
Robotium中获取控件主要有两大方式: - 根据被测应用的控件ID来获取 - 先获取当前页面所有的控件,对这些控件进行过滤封装后再提供相应获取控件的API
在Android中,所有的控件都会继承View,所以该方法能获取项目中所有的View,如果被测试的应用中控件有唯一ID的话,是一定能通过这种形式来获取所要操作的控件的,获取View之后再向下转型成我们需要的类型即可, 当View拥有唯一ID的时候,优先使用这种方式
根据索引获取 Robotium会先将当前界面中所有控件全部获取,然后按控件类型,索引进行过滤后获取指定的控件
//获取页面中第一个类型是Button的控件 Button button = (Button) solo.getButton(0);而基本上常用的控件Robotium都会提供这样的API,例如EditText,TextView等都是这样,但是如果是自定义组件就不行了
根据文本获取控件
//返回界面中text属性是"登陆"的Button Button loginBtn = (Button) solo.getButton("登陆");这种方法则局限性更大,只能找到具有text属性的一些控件
根据控件类型获取控件 Robotium可以获取 页面中/指定View下 所有的控件,或同一类型的控件
//获取当前页面或dialog中所有的控件 ArrayList<View> getCurrentViews(); //获取当前页面或Dialog中所有类型为classToFilterBy的控件 ArrayList<T> getCurrentViews(Class<T> classToFilterBy); //获取父控件parent下所有控件类型为classToFilterBy的控件 ArrayList<T> getCurrentViews(class<T> classToFilterBy, View parent);获取相同id的控件 在遇到ListView这种组件的时候,要想对item内部的View进行操作会发现这些View无论是Id还是文本还是类型都是一致的,而ListView本身是可以很方便的获得这些item的,最简单的可以通过listView.getChildAt(index)的形式,而在获取到Item之后,我们又可以通过调用findViewById来获取该Item下的组件了
//先获取ListView ListView listView = (ListView) solo.getView("main_lv"); //获取指定的item View convertView = listView.getChildAt(0); //从指定item中获取指定view TextView itemTv = (TextView) listView.findViewById(R.id.item_tv);这种通过findViewById的方式来获取控件,也适用于从Activity等页面中来获取指定控件.
注意: - 当没找到想要的控件时,Robotium就会抛出运行时异常,我们可以手动捕获该异常来防止测试代码崩溃 - 尽量不适用通过索引的方式获得控件,这种方式过于依赖页面的布局结构
对Android端控件的操作大概有点击,长按,文本输入,滑动,滚动,截图等,而为了页面真实性,有时还需要加入等待等操作
注意: - Robotium 对控件的操作是不能够跨进程的,所以它是不能够点击到例如通知栏等区域,如果编写了这样的代码,程序是会崩溃的 - 在调试时建议打开 “指针位置” 功能,这样在编写测试代码的时候,就可以知道点击的位置坐标了
点击和长按操作可以是找到组件然后点击该组件,而如果是自定义View时根据位置去处理手势的话,还可以根据指定的坐标去点击或者长按
//点击/长按指定的View控件 void clickOnView(View view); void clickLongOnView(View view); //点击/长按指定的屏幕坐标 void clickOnScreen(float x, float y); void clickLongOnScreen(float x, float y);Robotium 还提供了点击文本,图片的api,例如 clickOnButton(String text),它会先根据text找到Button,然后再去点击这个Button,目前Robotium也提供了点击RecyclerView的item的方法clickInRecyclerView(int itemIndex)这样的方法,来方便对ListView,RecyclerView进行操作
clearEditText比较好理解,而enterText和typeText的区别是enter是直接对EditText进行赋值,而type则是像用户一样的一个一个文本的输入
EditText editText = (EditText) solo.getView(R.id.main_et); solo.enterText(editText, "editText");根据坐标法进行滑动的时候坐标不需要多说,而步长的意思是滑动的速度,例如从A点滑动到B点,如果步长为1,那么将直接产生从A到B的滑动手势,如果步长为100,则会将A到B之间均分成100份,然后依次滑动
滚动到顶部/底部的方法是将当前屏幕滚动到顶部或底部,什么能滚,滚什么,例如:如果是ListView就会直接滚动到ListView的顶部,如果是WebView就会滚动到WebView的顶部
向上滑/向下滑则也是根据当前屏幕控件来走的,基本上是滑动一屏的距离,而与drag不同的是,这些都是调用控件自身的api来进行滚动的,例如drag能触发下拉刷新等操作,但是scroll则不能,所以drag更贴近用户的操作
对新的RecyclerView,同样提供了Api,但是不能指定滑动到第几行,只有向上/向下滑动,和滚动到顶端/底端,并且也只能通过RecyclerView的索引来找到RecyclerView
boolean scrollUpRecyclerView(index); boolean scrollDownRecyclerView(index); boolean scrollRecyclerViewToTop(index); boolean scrollRecyclerViewToBotton(index);UI自动化测试常常遇到的问题就是项目快速迭代导致界面经常变更,脚本经常出错,控件的搜索与等待则可以缓解这一问题
//休眠指定时间 void sleep(int time); //从当前页面搜索指定文本 boolean searchText(String text); //等待控件/文本的出现 boolean waitForView(int id); boolean waitForText(String text); //等待指定Activity的出现 boolean waitForActivity(String name); //等待指定Log的出现 boolean waitForLogMessage(String logMessage); //等待对话框的打开/关闭 boolean waitForDialogToOpen(); boolean waitForDialogToClose(); //等待某种加载条件的达成 boolean waitForCondition (Condition condition, int timeout); //等待Fragment的加载 boolean waitForFragmentById (String id); boolean waitForFragmentByTag (String tag);大多数API都是比较好理解的,这个Condition是一个接口,里面只提供了一个isSatisfied()方法,该方法返回boolean类型的值,需要我们自己写的,我们可以设置一些复杂的条件来实现这个接口
solo.waitForCondition(new Condition() { @Override public boolean isSatisfied() { return solo.isCheckBoxChecked(0); } },200);而几乎所有的等待操作都可以设置超时时间,需要注意的是,Robotium中查找控件,点击控件等API都默认使用了搜索与等待机制,所以非必要的情况下一般是不需要我们主动设置等待的
剩下的一些操作包括截图,对软键盘的操作,点击物理按键等等
//截图,name为图片的参数,默认路径是/sdcard/Robotium-Screenshots/ void takeScreenshot(); void takeScreenshot(String name); //截取某段时间内一个序列 void takeScreenshotSequence(String name); //关闭当前已打开的所有Activity void finishOpenedActivities(); //点击返回键 void goBack(); //不断点击返回键直至返回到指定Activity void goBackToActivity(String name); // 收起键盘 void hideSoftKeyboard(); // 设置Activity转屏方向 void setActivityOrientation(int orientation);在自动化测试中,当执行失败是,除了Log,最方便解决定位问题的就是运行时的截图,Robotium中提供了棘突功能,
在自动化测试中,我们获取控件,执行操作后,接下来就是要对操作后的场景进行断言了,断言就是我们在程序时的一些假设,就是我们断定在程序运行到某个时候,某个属性一定是多少,我们写程序的时候,大多数都是我们事先知道输入与输出的,断言就是来测试程序的输出与我们的输入是否相符
Junit中的断言都在AndroidSdk中的 junit.framework.Assert包下的Assert类中, 常用的只有第一个,在使用的时候应该明确说明message参数的作用,方便我们后期查看
Button loginBtn = (Button) solo.getView("loginBtn"); assertTrue("登录按钮应该显示", loginBtn.isShown());Robotium基于Junit中的断言,也封装了几个方便在Android端自动化的时候使用,这些方法都是sole对象的方法
这些断言都是Android提供的,可以方便的判断一些Android中的UI信息他们在android.test.ViewAsserts包下,但是目前(API level 24)这个类已经被标注成Deprecated了,目前Android提供了一个ViewMatchers的类来处理View的断言,而处理的思路和以前有了变化,核心方法是
static <T> void assertThat(String message T actual, Matcher<T> matcher);几乎所有的判断用的都是这个方法,message 是抛出的异常信息,Matcher 是断言规则,之前的在屏幕上显示,底部对齐等都变成了一种断言规则,我们也可以写自己的规则,而T则是需要的View,这里使用的是泛型,也就是该断言框架可以不仅局限于用来断言View了 也可以用来断言任何东西,例如想要判读一个TextView是否显示某种文字
TextView textView = (TextView) solo.getView(R.id.main_tv); ViewMatchers.assertThat("显示: Welcome",textView,ViewMatchers.withText("Welcome"));我们来看一看solo对象通过view的id来获取控件的方法
public View getView(int id) { if(this.config.commandLogging) { Log.d(this.config.commandLoggingTag, "getView(" + id + ")"); } return this.getView(id, 0); }Log先不用看,这个方法最后又调用了getView(int id, int index)的方法,那么我们再来看一看这个方法
public View getView(int id, int index) { if(this.config.commandLogging) { Log.d(this.config.commandLoggingTag, "getView(" + id + ", " + index + ")"); } View viewToReturn = this.getter.getView(id, index); if(viewToReturn == null) { String resourceName = ""; try { resourceName = this.instrumentation.getTargetContext().getResources().getResourceEntryName(id); } catch (Exception var6) { Log.d(this.config.commandLoggingTag, "unable to get resource entry name for (" + id + ")"); } int match = index + 1; if(match > 1) { Assert.fail(match + " Views with id: \'" + id + "\', resource name: \'" + resourceName + "\' are not found!"); } else { Assert.fail("View with id: \'" + id + "\', resource name: \'" + resourceName + "\' is not found!"); } } return viewToReturn; }这段代码中重要的是View viewToReturn = this.getter.getView(id,index);这一句,其他的都是生成log,或者报错等,这个getter对象就是Robotium中专门用来获取View的类,那么再来看看这里面的方法
public View getView(int id, int index) { return this.getView(id, index, 0); } public View getView(int id, int index, int timeout) { return this.waiter.waitForView(id, index, timeout); } public View waitForView(int id, int index, int timeout) { if(timeout == 0) { timeout = Timeout.getSmallTimeout(); } return this.waitForView(id, index, timeout, false); }在getView(int id, int index)方法里,又调用了 有timeout的方法重载,而在有timeout的方法重在里,又调用了waitForView,这样找到控件的方法,最后,就和等待的方法回合了,他们最后都调用了 waitForView(int id, int index, int timeout, boolean scroll)方法 一起来看一下这个方法
public View waitForView(int id, int index, int timeout, boolean scroll) { HashSet uniqueViewsMatchingId = new HashSet(); long endTime = SystemClock.uptimeMillis() + (long)timeout; while(SystemClock.uptimeMillis() <= endTime) { this.sleeper.sleep(); Iterator i$ = this.viewFetcher.getAllViews(false).iterator(); while(i$.hasNext()) { View view = (View)i$.next(); Integer idOfView = Integer.valueOf(view.getId()); if(idOfView.equals(Integer.valueOf(id))) { uniqueViewsMatchingId.add(view); if(uniqueViewsMatchingId.size() > index) { return view; } } } if(scroll) { this.scroller.scrollDown(); } } return null; }这段代码就是最后来找到View的代码了,首先会创建出一个HashSet来存放View,接着会通过迭代器,从ViewFetcher中获取所有的View,这里传入的boolean值得意思是是否只获取可见的View,去除所有View之后,拿到他们的ID,再与我们传入的id进行对比,如果匹配成功就返回我们的View.那么拿到View的关键就是Robotium怎么获取的我们所有的View,接着一路看源码下去
static { try { String e; if(VERSION.SDK_INT >= 17) { e = "android.view.WindowManagerGlobal"; } else { e = "android.view.WindowManagerImpl"; } windowManager = Class.forName(e); } catch (ClassNotFoundException var1) { throw new RuntimeException(var1); } catch (SecurityException var2) { var2.printStackTrace(); } } public ArrayList<View> getAllViews(boolean onlySufficientlyVisible) { View[] views = this.getWindowDecorViews(); ArrayList allViews = new ArrayList(); View[] nonDecorViews = this.getNonDecorViews(views); View view = null; if(nonDecorViews != null) { for(int ignored = 0; ignored < nonDecorViews.length; ++ignored) { view = nonDecorViews[ignored]; try { this.addChildren(allViews, (ViewGroup)view, onlySufficientlyVisible); } catch (Exception var9) { ; } if(view != null) { allViews.add(view); } } } if(views != null && views.length > 0) { view = this.getRecentDecorView(views); try { this.addChildren(allViews, (ViewGroup)view, onlySufficientlyVisible); } catch (Exception var8) { ; } if(view != null) { allViews.add(view); } } return allViews; } public View[] getWindowDecorViews() { try { Field viewsField = windowManager.getDeclaredField("mViews"); Field instanceField = windowManager.getDeclaredField(this.windowManagerString); viewsField.setAccessible(true); instanceField.setAccessible(true); Object e = instanceField.get((Object)null); View[] result; if(VERSION.SDK_INT >= 19) { result = (View[])((ArrayList)viewsField.get(e)).toArray(new View[0]); } else { result = (View[])((View[])viewsField.get(e)); } return result; } catch (Exception var5) { var5.printStackTrace(); return null; } }跟踪到最后发现,它通过反射拿到了一个叫WindowManagerGlobal的对象,并从这个对象中获得了顶级ViewDecorView,但是仔细观察它的反射方式,是直接通过类名构建了一个 新的WindowManagerGlobal对象 ,那么是如何从这个全新的WindowManagerGlobal中拿到DecorView的呢,只能从Android的源码入手分析了
在所有的Activity创建的过程中,我们知道都会有一个Window对象,当然真正的使用的是其子类PhoneWindow,PhoneWindow中会为Activity创建出一个DecorView对象,而DecorView是Activity的顶级View,在创建完成之后,还要通过WindowManager对象去将DecorView显示到屏幕上,这一步会调用WindowManager的makeVisible()方法
void makeVisible() { if (!mWindowAdded) { ViewManager wm = getWindowManager(); wm.addView(mDecor, getWindow().getAttributes()); mWindowAdded = true; } mDecor.setVisibility(View.VISIBLE); }在这一步中,会调用WindowManagerImpl的addView方法,WindowManagerImpl就是WindowManager真正的实现类,
@Override public void addView(View view, ViewGroup.LayoutParams params{ mGlobal.addView(view, params, mDisplay, mParentWindow); }会将DecorView添加到一个叫WindowManagerGlobal的对象中,这个对象中有一个叫sDefaultWindowManager的静态的WindowManagerGlobal,所有的DecorView都会添加到这个sDefaultWindowManager的一个成员变量mViews中,它是一个专门用来存放所有的DecorView的ArrayList,同时又因为sDefaultWindowManager是静态的,所以在全局无论通过什么途径拿到它,拿到的都是同一个对象
Robotium获取控件后,调用clickOnView(View view)方法就可以完成点击操作,这个方法可以分为两步实现 1. 根据View获取控件在屏幕中的位置 2. 根据坐标模拟点击事件
Robotium 在 4.0 开始 全面支持WebView,它支持通过ID,className等方式来获取WebElement元素
//获取当前WebView所有的WebElement元素 ArrayList<WebElement> getCurrentWebElements(); //通过By根据指定的元素获取当前WebView的所有WebElement元素 ArrayList<WebElement> getCurrentWebElements(By by); //通过by根据指定的元素属性点击WebElement void clickOnWebElement(By by); //点击指定的WebElement void clickOnWebElement(WebElement webElement); //根据by找到WebElement,并输入指定的文本text void enterTextInWebElement(By by, String text); //等待根据by获得的WebElement出现 boolean waitForWebElement(By by);By的其实就是当我们知道了Web元素的某种属性之后来通过该属性来获取控件的,例如
ArrayList<WebElement> webElements = solo.getCurrentWebElements(); ArrayList<WebElement> webElements = solo.getCurrentWebElements(By.id("example_id"));在使用Robotium操作WebView的时候需要注意的是,它只支持原生的WebView而不支持第三方浏览器内核