kikki's tech note

技術ブログです。UnityやSpine、MS、Javaなど技術色々について解説しています。

EspressoでのUIテストを改善する

本章では、EspressoでのUIテスト方法を改善できたので紹介します。

前回までのあらまし

Espressoでのテスト方法を模索して、記事を記載してきました。

kikki.hatenablog.com kikki.hatenablog.com

ただ従来の方法だと、画面遷移で発生する待ちに対して、スリープして待つという非効率的な方法をとっていました。
そこで今回、Androidで標準で用意されている、待ち合わせ手段を利用して、テストの実施方法を改善したいと思います。

新しい手法

何を使うか

UIテスト自動化フレームワーク、UiAutomatorを利用します。
UiAutomatorの中には、画面遷移後のコンテンツの表示を待ち合わせる手段が用意されています。
UiDeviceクラスのwaitを利用します。

コード

ヘルパー関数
private const val middleTimeout: Long = 5000

/**
* 画面内のコンテンツの文言チェックを行います
*/
fun onAssertText(id: Int? = null, text: String) {
    onView(id?.let { allOf(withId(id), withText(text)) } ?: withText(text))
            .check(matches(isDisplayed()))
}

/**
* WebViewに表示されるコンテンツの文言チェックを行います
*/
fun onAssertWebText(locator: Locator, locatorValue: String, webText: String) {
    Web.onWebView()
            .withElement(findElement(locator, locatorValue))
            .check(webMatches(getText(), containsString(webText)))
}

/**
* 画面内の特定の要素へスクロールさせます
*/
fun onScrollTo(id: Int, text: String? = null) {
    onView(text?.let { allOf(withId(id), withText(text)) } ?: withId(id))
            .perform(ViewActions.scrollTo())
}

/**
* RecyclerView内の特定の要素へスクロールさせます
*/
fun onScrollRecyclerViewTo(id: Int, position: Int) {
    onView(withId(id))
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(position))
}

/**
* NestedScrollView内の特定要素へスクロールさせます
*/
fun onScrollNestedViewTo(id: Int) {
    onView(withId(id))
            .perform(nestedScrollTo())
}

/**
* NavigationView内でスワイプアップ操作を行います
*/
fun onSwipeUp() {
    onView(isAssignableFrom(NavigationView::class.java))
            .perform(ViewActions.swipeUp())
}

/**
* 画面内の特定要素をクリック操作します
*/
fun onClick(id: Int, text: String? = null) {
    onView(text?.let { allOf(withId(id), withText(text), isCompletelyDisplayed()) }
            ?: allOf(withId(id), isCompletelyDisplayed()))
            .perform(ViewActions.click())
}

/**
* 画面内の特定要素をクリック操作します
*/
fun onClick(text: String) {
    onView(withText(text))
            .perform(ViewActions.click())
}

/**
* 要素が表示されているかに関わらず、クリック操作を行います
*/
fun onNoCheckedClick(id: Int, text: String? = null) {
    onView(text?.let { allOf(withId(id), withText(text)) } ?: withId(id))
            .perform(NoCheckedViewAction())
}

/**
* アプリ外の機能、カメラやギャラリーに対して、クリック操作を行います
*/
fun onClickByUiSelector(device: UiDevice, resourceName: String) {
    device.wait(Until.findObject(By.res(resourceName)), middleTimeout)
    device.findObject(By.res(resourceName)).click()
}

/**
* 入力要素の文字を変更します
*/
fun onReplaceText(id: Int, text: String) {
    onView(withId(id))
            .perform(ViewActions.replaceText(text))
}

/**
* Spinnerにバインドされた要素を選択します
*/
fun onSelectSpinner(position: Int) {
    onData(allOf(`is`(instanceOf(String::class.java)))).atPosition(position).perform(ViewActions.click())
}

/**
* RecyclerViewにバインドされた要素を選択します
*/
fun onSelectRecyclerPosition(id: Int, position: Int) {
    onView(withId(id))
            .perform(actionOnItemAtPosition<RecyclerView.ViewHolder>(position, ViewActions.click()))
}

/**
* 画面内の特定の要素が表示・有効化されるまで、待ち合わせます
*/
fun wait(device: UiDevice, resources: Resources, resourceId: Int) {
    device.wait(Until.findObject(By.res(BuildConfig.APPLICATION_ID, resources.getResourceEntryName(resourceId)).enabled(true)), middleTimeout)
}

/**
* 画面内の特定文字を含む要素を対象が表示・有効化されるまで、待ち合わせます
*/
fun wait(device: UiDevice, text: String) {
    device.wait(Until.findObject(By.text(text)), middleTimeout)
}

/**
* 画面内の特定要素が存在するかどうかを待ち合わせつつ確認します
*/
fun waitForExists(device: UiDevice, resourceName: String): Boolean {
    return device.findObject(UiSelector().resourceId(BuildConfig.APPLICATION_ID + ":id/" + resourceName).enabled(true)).waitForExists(middleTimeout)
}

/**
* 戻る操作を行います
*/
fun onBack(device: UiDevice) {
    device.pressBack()
}

/**
* NestedScrollViewへのViewActionを定義します
*/
class NestedScrollToAction : ViewAction {

    override fun getConstraints(): Matcher<View> {
        return allOf<View>(
                withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE),
                isDescendantOfA(
                        anyOf<View>(
                                isAssignableFrom(NestedScrollView::class.java),
                                isAssignableFrom(ScrollView::class.java),
                                isAssignableFrom(HorizontalScrollView::class.java),
                                isAssignableFrom(ListView::class.java)
                        )
                )
        )
    }

    override fun perform(uiController: UiController, view: View) {
        if (isDisplayingAtLeast(90).matches(view)) {
            Log.i("View is already displayed. Returning.")
            return
        }
        val rect = Rect()
        view.getDrawingRect(rect)
        if (!/* immediate */view.requestRectangleOnScreen(rect, true)) {
            Log.w("Scrolling to view was requested, but none of the parents scrolled.")
        }
        uiController.loopMainThreadUntilIdle()
        if (!isDisplayingAtLeast(90).matches(view)) {
            throw PerformException.Builder()
                    .withActionDescription(this.description)
                    .withViewDescription(HumanReadables.describe(view))
                    .withCause(
                            RuntimeException(
                                    "Scrolling to view was attempted, but the view is not displayed"
                            )
                    )
                    .build()
        }
    }

    override fun getDescription(): String {
        return "scroll to"
    }
}

class NestedViewActions {
    companion object {
        fun nestedScrollTo(): ViewAction {
            return ViewActions.actionWithAssertions(NestedScrollToAction())
        }
    }
}

/**
* 特定要素の表示・有効を確認することなく、クリック操作を行うViewActionを定義します
*/
class NoCheckedViewAction : ViewAction {
    override fun getConstraints(): Matcher<View> {
        return ViewMatchers.isEnabled()
    }

    override fun getDescription(): String {
        return "click button"
    }

    override fun perform(uiController: UiController?, view: View?) {
        view!!.performClick()
    }
}
テストコード
@LargeTest
@RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
class HogeHogeTest {
    private lateinit var device: UiDevice
    private lateinit var resources: Resources

    @Rule
    @JvmField
    var mActivityTestRule = ActivityTestRule(FooActivity::class.java)

    @Before
    fun setUp() {
        AppDataPreference(InstrumentationRegistry.getInstrumentation().targetContext).clear()
        device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
        resources = mActivityTestRule.activity.resources
    }

    /**
     * 【成功】ログインテスト
     */
    @Test
    fun test01_SignIn_Pass() {
        wait(device, resources, R.id.login)
        onReplaceText(R.id.email, email)
        onReplaceText(R.id.password, password)
        onClick(R.id.login, "ログイン")

        wait(device, resources, R.id.title)
        onAssertText(R.id.title, "ホーム")
    }
}

反省

元々、Android Studioでアプリを操作して得られる、自動生成されたテストコードを解析して、テストコードを作成していました。
しかし生成して得られるテストコードは、非効率なコードなので手直しする必要がありました。
本来であれば公式ドキュメントを熟読してから着手すべきでしたが、時間がなかったこともあり、そのまま作成始めました。
今回たまたま経験者から話を伺い、Androidですでに用意されている機構を取り入れる機会があったため、振り返りを込めて記事におこしました。

筆休め

0から1を行う際には、非効率な手法や後で廃棄すべき成果物を作りがちです。ただ、1から10、100にスケールさせる際には、保守性や性能も求められるため、定期的に見直すべきですね。

以上、「EspressoでのUIテストを改善する」でした。


※無断転載禁止 Copyright (C) kikkisnrdec All Rights Reserved.