Android自动化测试技术——Espresso的使用

3,150 阅读11分钟

配置

修改设置

先启用开发者选项,再在开发者选项下,停用以下三项设置:

  • 窗口动画缩放
  • 过渡动画缩放
  • Animator 时长缩放

添加依赖

app/build.gradle文件中添加依赖

androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0'

app/build.gradle文件中的android.defaultConfig中添加

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

注意:上面的依赖只能实现基本功能,如果你想使用所有的功能,则按下面的配置:

所有依赖

    androidTestImplementation 'androidx.test.ext:junit:1.1.1'
    androidTestImplementation 'androidx.test.ext:truth:1.2.0'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
    androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.2.0'
    androidTestImplementation 'androidx.test:runner:1.2.0'
    androidTestImplementation 'androidx.test:rules:1.2.0'
    androidTestImplementation 'androidx.test.espresso:espresso-intents:3.2.0'
    implementation 'androidx.recyclerview:recyclerview:1.1.0'
    implementation 'androidx.test.espresso:espresso-idling-resource:3.2.0'

下面调用的方法如onView()等都是静态方法,可以通过import static XXX来直接调用,所有需要导入的静态方法如下:

import static androidx.test.espresso.Espresso.*;
import static androidx.test.espresso.action.ViewActions.*;
import static androidx.test.espresso.assertion.ViewAssertions.*;
import static androidx.test.espresso.intent.Intents.intended;
import static androidx.test.espresso.intent.Intents.intending;
import static androidx.test.espresso.intent.matcher.ComponentNameMatchers.*;
import static androidx.test.espresso.intent.matcher.IntentMatchers.*;
import static androidx.test.espresso.matcher.ViewMatchers.*;
import static androidx.test.ext.truth.content.IntentSubject.assertThat;

Api组件

常用Api组件包括:

  • Espresso - 用于与视图交互(通过 onView() 和 onData())的入口点。此外,还公开不一定与任何视图相关联的 API,如 pressBack()。
  • ViewMatchers - 实现 Matcher<? super View> 接口的对象的集合。您可以将其中一个或多个对象传递给 onView() 方法,以在当前视图层次结构中找到某个视图。
  • ViewActions - 可传递给 ViewInteraction.perform() 方法的 ViewAction 对象(如 click())的集合。
  • ViewAssertions - 可传递给 ViewInteraction.check() 方法的 ViewAssertion 对象的集合。在大多数情况下,您将使用 matches 断言,它使用视图匹配器断言当前选定视图的状态。

大多数可用的 Matcher、ViewAction 和 ViewAssertion 实例如下图(来源官方文档):

常用的api实例pdf

使用

普通控件

示例:MainActivity 包含一个 Button 和一个 TextView。点击该按钮后,TextView 的内容会变为 "改变成功"

使用 Espresso 进行测试方法如下:

@RunWith(AndroidJUnit4.class)
@LargeTest
public class ChangeTextTest {
   @Rule
    public ActivityTestRule<MainActivity> activityRule =
            new ActivityTestRule<>(MainActivity.class);
    @Test
    public void test_change_text(){
        onView(withId(R.id.change))
                .perform(click());
        onView(withId(R.id.content))
              .check(matches(withText("改变成功")));
    }
}    

onView()方法用来获取匹配的当前视图,注意匹配的视图只能有一个,否则会报错。

withId()方法用来搜索匹配的视图,类似的还有withText()withHint()等。

perform()方法用来执行某种操作,例如点击click() 、长按longClick() 、双击doubleClick()

check()用来将断言应用于当前选定的视图

matches()最常用的断言,它断言当前选定视图的状态。上面的示例就是断言id为content的View它是否和text为"改变成功"的View匹配

AdapterView相关控件

与普通控件不同,AdapterView(常用的是ListView)只能将一部分子视图加载到当前视图层次结构中。简单的 onView() 搜索将找不到当前未加载的视图。Espresso 提供一个单独的 onData() 入口点,该入口点能够先加载相关适配器项目,并在对其或其任何子级执行操作之前使其处于聚焦状态。

示例:打开Spinner,选择一个特定的条目,然后验证 TextView 是否包含该条目。Spinner 会创建一个包含其内容的 ListView,因此需要onData()

@RunWith(AndroidJUnit4.class)
@LargeTest
public class SpinnerTest {
   @Rule
    public ActivityTestRule<MainActivity> activityRule =
            new ActivityTestRule<>(MainActivity.class);
    @Test
    public void test_spinner(){
        String content = "学校";
        //点击Spnner,显示项目
        onView(withId(R.id.change)).perform(click());
        //点击指定的内容
        onData(allOf(is(instanceOf(String.class)), is(content))).perform(click());
        //判断TextView是否包含指定内容
        onView(withId(R.id.content))
                .check(matches(withText(containsString(content))));
    }
}

下图为AdapterView的继承关系图:

警告:如果 AdapterView 的自定义实现违反继承约定,那么在使用 onData() 方法(尤其是 getItem() API)时可能会出现问题。在这种情况下,最好的做法是重构应用代码。如果您无法执行此操作,则可以实现匹配的自定义 AdapterViewProtocol。

自定义Matcher和ViewAction

在介绍RecyclerView的操作之前,我们先要看看如何自定义MatcherViewAction

自定义Matcher

Matcher<T>是一个用来匹配视图的接口,常用的是它的两个实现类BoundedMatcher<T, S extends T>TypeSafeMatcher<T>

BoundedMatcher<T, S extends T>:一些匹配的语法糖,可以让你创建一个给定的类型,而匹配的特定亚型的只有过程项匹配。 类型参数:<T> - 匹配器的期望类型。<S> - T的亚型

TypeSafeMatcher<T>:内部实现了空检查,检查的类型,然后进行转换

示例:输入EditText值,如果值以000开头,则让内容为 "成功" 的TextView可见,否则让内容为 失败 的TextView可见.


@RunWith(AndroidJUnit4.class)
@LargeTest
public class EditTextTest {
   @Rule
    public ActivityTestRule<MainActivity> activityRule =
            new ActivityTestRule<>(MainActivity.class);

   @Test
    public void rightInput() {
        onView(withId(R.id.editText))
                .check(matches(EditMatcher.isRight()))
                .perform(typeText("000123"), ViewActions.closeSoftKeyboard());
        onView(withId(R.id.button)).perform(click());
        onView(withId(R.id.textView_success)).check(matches(isDisplayed()));
        onView(withId(R.id.textView_fail)).check(matches(not(isDisplayed())));
    }

    @Test
    public void errorInput() {
        onView(withId(R.id.editText))
                .check(matches(EditMatcher.isRight()))
                .perform(typeText("003"), ViewActions.closeSoftKeyboard());
        onView(withId(R.id.button)).perform(click());
        onView(withId(R.id.textView_success)).check(matches(not(isDisplayed())));
        onView(withId(R.id.textView_fail)).check(matches(isDisplayed()));
    }
       
   static class EditMatcher{
       static Matcher<View> isRight(){
           //自定义Matcher
           return new BoundedMatcher<View, EditText>(EditText.class) {
               @Override
               public void describeTo(Description description) {
                     description.appendText("EditText不满足要求");
               }

               @Override
               protected boolean matchesSafely(EditText item) {
                  //在输入EditText之前,先判EditText是否可见以及hint是否为指定值
                   if (item.getVisibility() == View.VISIBLE &&
                   item.getText().toString().isEmpty())
                       return true;
                   else
                   return false;
               }
           };
       }
   }
}   

自定义ViewAction

这个不太熟悉,这里就介绍一下实现ViewAction接口,要实现的方法的作用

 /**
   *符合某种限制的视图
   */
  public Matcher<View> getConstraints();

  /**
   *返回视图操作的描述。 *说明不应该过长,应该很好地适应于一句话
   */
  public String getDescription();

  /**
   * 执行给定的视图这个动作。
   *PARAMS:uiController - 控制器使用与UI交互。
   *view - 在采取行动的view。 不能为null
   */
  public void perform(UiController uiController, View view);
}

RecyclerView

RecyclerView 对象的工作方式与 AdapterView 对象不同,因此不能使用 onData() 方法与其交互。 要使用 EspressoRecyclerView 交互,您可以使用 espresso-contrib 软件包,该软件包具有 RecyclerViewActions的集合,定义了用于滚动到相应位置或对项目执行操作的方法。

添加依赖

androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.2.0'

操作RecyclerView的方法有:

  • scrollTo() - 滚动到匹配的视图。
  • scrollToHolder() - 滚动到匹配的视图持有者。
  • scrollToPosition() - 滚动到特定位置。
  • actionOnHolderItem() - 对匹配的视图持有者执行视图操作。
  • actionOnItem() - 对匹配的视图执行视图操作。
  • actionOnItemAtPosition() - 在特定位置对视图执行视图操作。

示例:选中删除功能:点击 编辑 ,TextView内容转为 删除 ,同时RecycleView的条目出现选中框,勾选要删除的项,点击 删除 ,删除指定项,RecycleView的条目的选中框消失。

@RunWith(AndroidJUnit4.class)
@LargeTest
public class RecyclerViewTest {
   @Rule
    public ActivityTestRule<RecyclerActivity> activityRule =
            new ActivityTestRule<>(RecyclerActivity.class);
   

    static class ClickCheckBoxAction implements ViewAction{
        
        @Override
        public Matcher<View> getConstraints() {
            return any(View.class);
        }

        @Override
        public String getDescription() {
            return null;
        }

        @Override
        public void perform(UiController uiController, View view) {
            CheckBox box = view.findViewById(R.id.checkbox);
            box.performClick();//点击
        }
    }
    
    static class MatcherDataAction implements ViewAction{
        
        private String require;

        public MatcherDataAction(String require) {
            this.require = require;
        }

        @Override
        public Matcher<View> getConstraints() {
            return any(View.class);
        }

        @Override
        public String getDescription() {
            return null;
        }

        @Override
        public void perform(UiController uiController, View view) {
            TextView text = view.findViewById(R.id.text);
            assertThat("数据值不匹配",require,equalTo(text.getText().toString()));
        }
    }
    
    public void delete_require_data(){
        //获取RecyclerView中显示的所有数据
        List<String> l = new ArrayList<>(activityRule.getActivity().getData());
        //点击 编辑 ,判断text是否变成 删除
        onView(withId(R.id.edit))
                .perform(click())
                .check(matches(withText("删除")));
        //用来记录要删除的项,
        Random random = new Random();
        int time = random.nextInt(COUNT);
        List<String> data = new ArrayList<>(COUNT);
        for (int i = 0; i < COUNT; i++) {
            data.add("");
        }
        for (int i = 0; i < time; i++) {
            //随机生成要删除的位置
            int position = random.nextInt(COUNT);
            //由于再次点击会取消,这里用来记录最后确定要删除的项
            if (data.get(position).equals(""))
                data.set(position,"测试数据"+position);
            else data.set(position,"");
            //调用RecyclerViewActions.actionOnItemAtPosition()方法,滑到指定位置
            //在执行指定操作
           onView(withId(R.id.recycler)).
                  perform(RecyclerViewActions.actionOnItemAtPosition(position,new ClickCheckBoxAction()));
        }
        //点击 删除 ,判断text是否变成 编辑
        onView(withId(R.id.edit))
                .perform(click(),doubleClick())
                .check(matches(withText("编辑")));
        //删除无用的项
        data.removeIf(s -> s.equals(""));
        //获取最后保存的项
        l.removeAll(data);
        //依次判断保留的项是否还存在
        for (int i = 0; i < l.size(); i++) {
            final String require = l.get(i);
            onView(withId(R.id.recycler))
                    .perform(RecyclerViewActions.
                            actionOnItemAtPosition(i,new MatcherDataAction(require)));
        }
    }
}

注意:在MatcherDataAction中调用了assertThat(),这种方式是不建议的。这里是我没有找到更好的方式来实现这个测试。

Intent

Espresso-Intents 是 Espresso 的扩展,支持对被测应用发出的 Intent 进行验证和打桩。

添加依赖:

androidTestImplementation 'androidx.test.ext:truth:1.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.2.0'

在编写 Espresso-Intents 测试之前,要先设置 IntentsTestRule。这是 ActivityTestRule 类的扩展,可让您在功能界面测试中轻松使用 Espresso-Intents的API。IntentsTestRule 会在带有 @Test 注解的每个测试运行前初始化Espresso-Intents,并在每个测试运行后释放 Espresso-Intents

 @Rule
 public IntentsTestRule<MainActivity> mActivityRule = new IntentsTestRule<>(
            MainActivity.class);

验证 Intent

示例:在EditText中,输入电话号码,点击拨打按键,拨打电话。

@RunWith(AndroidJUnit4.class)
@LargeTest
public class IntentTest {
   
    //设置拨打电话的权限的环境
    @Rule
    public GrantPermissionRule grantPermissionRule = GrantPermissionRule.grant("android.permission.CALL_PHONE");

    @Rule
    public IntentsTestRule<MainActivity> mActivityRule = new IntentsTestRule<>(
            MainActivity.class);

    @Test
    public void test_start_other_app_intent(){
         String phoneNumber = "123456";
         //输入电话号码
         onView(withId(R.id.phone))
                 .perform(typeText(phoneNumber), ViewActions.closeSoftKeyboard());
        //点击拨打
         onView(withId(R.id.button))
                 .perform(click());
         //验证Intent是否正确
         intended(allOf(
                hasAction(Intent.ACTION_CALL),
                hasData(Uri.parse("tel:"+phoneNumber))));
    }
}

intended():是Espresso-Intents 提供的用来验证Intent的方法

除此之外,还可以通过断言的方式来验证Intent

Intent receivedIntent = Iterables.getOnlyElement(Intents.getIntents());
assertThat(receivedIntent)
      .extras()
      .string("phone")
      .isEqualTo(phoneNumber);

插桩

上述方式可以解决一般的Intent验证的操作,但是当我们需要调用startActivityForResult()方法去启动照相机获取照片时,如果使用一般的方式,我们就需要手动去点击拍照,这样就不算自动化测试了。

Espresso-Intents 提供了intending()方法来解决这个问题,它可以为使用 startActivityForResult() 启动的 Activity 提供桩响应。简单来说就是,它不会去启动照相机,而是返回你自己定义的Intent。

@RunWith(AndroidJUnit4.class)
@LargeTest
public class TakePictureTest {
       
        public static BoundedMatcher<View, ImageView> hasDrawable() {
            return new BoundedMatcher<View, ImageView>(ImageView.class) {
                @Override
                public void describeTo(Description description) {
                    description.appendText("has drawable");
                }

                @Override
                public boolean matchesSafely(ImageView imageView) {
                    return imageView.getDrawable() != null;
                }
            };
        }
        
    @Rule
    public IntentsTestRule<MainActivity> mIntentsRule = new IntentsTestRule<>(
            MainActivity.class);

    @Rule
    public GrantPermissionRule grantPermissionRule = GrantPermissionRule.grant(Manifest.permission.CAMERA);

    @Before
    public void stubCameraIntent() {
        Instrumentation.ActivityResult result = createImageCaptureActivityResultStub();
        intending(hasAction(MediaStore.ACTION_IMAGE_CAPTURE)).respondWith(result);
    }

    @Test
    public void takePhoto_drawableIsApplied() {
        //先检查ImageView中是否已经设置了图片
        onView(withId(R.id.image)).check(matches(not(hasDrawable())));
        // 点击拍照
        onView(withId(R.id.button)).perform(click());
        // 判断ImageView中是否已经设置了图片
        onView(withId(R.id.image)).check(matches(hasDrawable()));
    }

    private Instrumentation.ActivityResult createImageCaptureActivityResultStub() {
        //自己定义Intent
        Bundle bundle = new Bundle();
        bundle.putParcelable("data", BitmapFactory.decodeResource(
                mIntentsRule.getActivity().getResources(), R.drawable.ic_launcher_round));
        Intent resultData = new Intent();
        resultData.putExtras(bundle);
        return new Instrumentation.ActivityResult(Activity.RESULT_OK, resultData);
    }
}

空闲资源

空闲资源表示结果会影响界面测试中后续操作的异步操作。通过向 Espresso 注册空闲资源,可以在测试应用时更可靠地验证这些异步操作。

添加依赖

implementation 'androidx.test.espresso:espresso-idling-resource:3.2.0'

下面以Google的官方示例来介绍,如何使用:

第一步:创建SimpleIdlingResource类,用来实现IdlingResource

public class SimpleIdlingResource implements IdlingResource {

    @Nullable
    private volatile ResourceCallback mCallback;

    private AtomicBoolean mIsIdleNow = new AtomicBoolean(true);

    @Override
    public String getName() {
        return this.getClass().getName();
    }

    /**
     *false 表示这里有正在进行的任务,而true表示异步任务完成
     */
    @Override
    public boolean isIdleNow() {
        return mIsIdleNow.get();
    }

    @Override
    public void registerIdleTransitionCallback(ResourceCallback callback) {
        mCallback = callback;
    }

    public void setIdleState(boolean isIdleNow) {
        mIsIdleNow.set(isIdleNow);
        if (isIdleNow && mCallback != null) {
           //调用这个方法后,Espresso不会再检查isIdleNow()的状态,直接判断异步任务完成
            mCallback.onTransitionToIdle();
        }
    }
}

第二步:创建执行异步任务的类MessageDelayer

class MessageDelayer {

    private static final int DELAY_MILLIS = 3000;

    interface DelayerCallback {
        void onDone(String text);
    }

    static void processMessage(final String message, final DelayerCallback callback,
                               @Nullable final SimpleIdlingResource idlingResource) {
        if (idlingResource != null) {
            idlingResource.setIdleState(false);
        }
        Handler handler = new Handler();
        new Thread(()->{
            try {
                Thread.sleep(DELAY_MILLIS);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            handler.post(new Runnable() {
                @Override
                public void run() {
                    if (callback != null) {
                        callback.onDone(message);
                        if (idlingResource != null) {
                            idlingResource.setIdleState(true);
                        }
                    }
                }
            });
        }).start();
    }
}

第三步:在MainActivity中通过点击按钮开启任务

public class MainActivity extends AppCompatActivity implements View.OnClickListener,
        MessageDelayer.DelayerCallback {

    private TextView mTextView;
    private EditText mEditText;

    @Nullable
    private SimpleIdlingResource mIdlingResource;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        findViewById(R.id.changeTextBt).setOnClickListener(this);
        mTextView = findViewById(R.id.textToBeChanged);
        mEditText = findViewById(R.id.editTextUserInput);
    }

    @Override
    public void onClick(View view) {
        final String text = mEditText.getText().toString();
        if (view.getId() == R.id.changeTextBt) {
            mTextView.setText("正在等待");
            MessageDelayer.processMessage(text, this, mIdlingResource);
        }
    }

    @Override
    public void onDone(String text) {
        mTextView.setText(text);
    }

    /**
     * 仅测试能调用,创建并返回新的SimpleIdlingResource
     */
    @VisibleForTesting
    @NonNull
    public IdlingResource getIdlingResource() {
        if (mIdlingResource == null) {
            mIdlingResource = new SimpleIdlingResource();
        }
        return mIdlingResource;
    }
}

第四步:创建测试用例

@RunWith(AndroidJUnit4.class)
@LargeTest
public class ChangeTextBehaviorTest {

    private static final String STRING_TO_BE_TYPED = "Espresso";

    private IdlingResource mIdlingResource;


    /**
     *注册IdlingResource实例
     */
    @Before
    public void registerIdlingResource() {
        ActivityScenario activityScenario = ActivityScenario.launch(MainActivity.class);
        activityScenario.onActivity((ActivityScenario.ActivityAction<MainActivity>) activity -> {
            mIdlingResource = activity.getIdlingResource();
            IdlingRegistry.getInstance().register(mIdlingResource);
        });
    }

    @Test
    public void changeText_sameActivity() {
        onView(withId(R.id.editTextUserInput))
                .perform(typeText(STRING_TO_BE_TYPED), closeSoftKeyboard());
        onView(withId(R.id.changeTextBt)).perform(click());
        //只需要注册IdlingResource实例,Espresso就会自动在这里等待,直到异步任务完成
        //在执行下面的代码
        onView(withId(R.id.textToBeChanged)).check(matches(withText(STRING_TO_BE_TYPED)));
    }
    //取消注册
    @After
    public void unregisterIdlingResource() {
        if (mIdlingResource != null) {
            IdlingRegistry.getInstance().unregister(mIdlingResource);
        }
    }
}

不足:Espresso提供了一套先进的同步功能。不过,该框架的这一特性仅适用于在 MessageQueue 上发布消息的操作,如在屏幕上绘制内容的 View 子类。

其他

Espresso还有在多进程、WebView、无障碍功能检查、多窗口等内容,这些我不太熟悉,建议自己看 安卓官方文档或者下面的官方示例。

官方示例

参考