kikki's tech note

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

EspressoでUIテストを自動化する

本章では、AndroidでEspressoを用いた、UIテストの作成方法について共有します。

最新の記事はこちら
kikki.hatenablog.com

Espressoとは

Espressoは、Google for Androidによって開発された、UI上でテストを実施できるテストフレームワークです。
Espressoは、シンプルなAPI設計で構成されていて、簡単な記述でテストコードを記述できます。
またAndroid Studioに内蔵された、記録ツールを利用することで、コードの作成なく画面操作だけでテストの作成ができます。

EspressoによるUIテスト作成

事前準備

まずは、Espressoをプロジェクトで利用できるように、テストのライブラリを設定ファイルに追記します。

[build.gradle]

dependencies {
    androidTestImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"
    androidTestImplementation 'androidx.test:rules:1.1.1'
    androidTestImplementation "androidx.test.ext:junit:1.1.0"
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
    androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.1.1'
    androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
}

また今回は、テストでカメラアプリを利用できるように、カメラアプリのパーミッションも追加で設定しました。
アプリに権限許可が必要な機能がある場合には、必要に応じてテスト用のmanifestにも追記しましょう。

[AndroidManifest.xml]

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.hogehoge.foo">

    <!-- enforce using camera2 -->
    <uses-feature
        android:name="android.hardware.camera2"
        android:required="true" />

    <!-- permit using camera -->
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

    <!-- other's settings -->

</manifest>

テストで使用する便利関数

通常Espressoのテストでは、アプリからの応答を待ち合わせる際にスリープをはさみます。
ただ記述が冗長になる上に、アプリの処理に合わせてスリープ時間を毎回指定する手間があります。
そのため本サンプルには、アプリとの同期処理をラップする便利関数を定義し、利用することとしました。

[Async.kt]

import androidx.test.espresso.*

private const val retry: Int = 300
private const val sleepTime: Long = 300

fun <T> wait(block: () -> T?, vararg viewActions: ViewAction) {
    for (times in 1..retry) {
        Thread.sleep(sleepTime)
        block()?.let {
            if (viewActions.isNotEmpty()) {
                when (it) {
                    is ViewInteraction -> it.perform(*viewActions)
                    is DataInteraction -> it.perform(*viewActions)
                }
            }
            return
        }
    }
}


[Fragment.kt]

import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.DataInteraction
import androidx.test.espresso.Espresso
import androidx.test.espresso.NoMatchingViewException
import androidx.test.espresso.ViewInteraction
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiSelector
import org.hamcrest.Matcher
import org.hamcrest.Matchers.*

private const val timeout: Long = 3000

fun onAssertText(id: Int, text: String) {
    wait({
        findViewInteraction(allOf(withId(id), withText(text)))
    })
}

fun onAssertEnableAndClickable(id: Int) {
    wait({
        findViewInteraction(allOf(withId(id), isClickable(), isEnabled()))
    })
}

fun onScrollTo(id: Int, text: String? = null) {
    val matcher = text?.let { allOf(withId(id), withText(text)) } ?: withId(id)
    wait({
        findViewInteraction(matcher)
    }, ViewActions.scrollTo())
}

fun onClick(id: Int, text: String? = null) {
    val matcher = text?.let { allOf(withId(id), withText(text), isCompletelyDisplayed()) }
            ?: allOf(withId(id), isCompletelyDisplayed())
    wait({
        findViewInteraction(matcher)
    }, ViewActions.click())
}

fun onReplaceText(id: Int, text: String) {
    wait({
        findViewInteraction(withId(id))
    }, ViewActions.replaceText(text))
}

fun onSelectSpinnerText(text: String, position: Int) {
    wait({
        findDataInteraction(`is`(text), position)
    }, ViewActions.click())
}

fun onSelectAdapter(id: Int, position: Int) {
    wait({
        findDataInteractionInAdapter(withId(id), position)
    }, ViewActions.click())
}

fun onSelectRecyclerPosition(id: Int, position: Int) {
    wait({
        findViewInteraction(withId(id))
    }, actionOnItemAtPosition<RecyclerView.ViewHolder>(position, ViewActions.click()))
}

fun findViewInteraction(viewMatcher: Matcher<View>): ViewInteraction? {
    return try {
        Espresso.onView(viewMatcher)
                .check(matches(isEnabled()))
    } catch (e: NoMatchingViewException) {
        null
    }
}

fun <T> findDataInteraction(viewMatcher: Matcher<T>, position: Int): DataInteraction? {
    return try {
        Espresso.onData(viewMatcher)
                .atPosition(position)
    } catch (e: NoMatchingViewException) {
        null
    }
}

fun findDataInteractionInAdapter(viewMatcher: Matcher<View>, position: Int): DataInteraction? {
    return try {
        Espresso.onData(anything())
                .inAdapterView(viewMatcher)
                .atPosition(position)
    } catch (e: NoMatchingViewException) {
        null
    }
}

fun onClickUiSelector(device: UiDevice, vararg ids: String): Boolean {
    var exists = false
    for (id in ids) {
        val obj = device.findObject(UiSelector().resourceId(id))
        if (obj.waitForExists(timeout)) {
            exists = true
            obj.click()
        }
    }
    return exists
}


[Http.kt]

import java.net.HttpURLConnection
import java.net.URL

fun getHttpResponseBody(url: String): String {
    val con: HttpURLConnection = URL(url).openConnection() as HttpURLConnection
    try {
        con.requestMethod = "GET"
        con.connect()
        if (HttpURLConnection.HTTP_OK == con.responseCode) {
            return con.inputStream.bufferedReader().use {
                it.readText()
            }
        }
    } finally {
        con.disconnect()
    }
    return ""
}

テストコード本体

それでは、テストファイルを記述していきます。
自前で用意した「on○○○」関数を利用してクリック操作や入力操作を行いテストを進行させます。
注意点として、クリック操作ができる要素が画面外にある場合には、「onScrollTo」等を利用して要素まで移動する操作を忘れずに追加しておきましょう。

import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onData
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import androidx.test.rule.ActivityTestRule
import androidx.test.rule.GrantPermissionRule
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiSelector
import org.hamcrest.Matchers.*
import org.junit.Before
import org.junit.FixMethodOrder
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
import java.util.*

@LargeTest
@RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
class ActivityTest {
    private lateinit var device: UiDevice

    @get:Rule
    var permissionRule: GrantPermissionRule = GrantPermissionRule.grant(
            android.Manifest.permission.CAMERA
            , android.Manifest.permission.ACCESS_COARSE_LOCATION
    )
    @Rule
    @JvmField
    var mActivityTestRule = ActivityTestRule(SplashActivity::class.java)

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

    @Test
    fun test01() {
        // Clicks after finding elements which contains texts "ほげほげ".
        onClick(R.id.hogehoge, "ほげほげ")

        // Scroll to elements which want to tap.
        onScrollTo(R.id.notification_card_upload_layout)
        // Clicks for uploading images.
        onClick(R.id.upload_images)
        // Chooses whether taking pictures or upload images.
        onSelectAdapter(R.id.select_dialog_listview, 0)
        // Resolves camera application's asks to permit.
        onClickUiSelector(
                device
                , "com.android.packageinstaller:id/permission_allow_button"
                , "com.android.camera2:id/confirm_button").let {
            // Re-execute a previous operation because of Camera runs as other activity
            // when the activity ask me to permit to allow using location information.
            if (it) {
                device.pressBack()
                onClick(R.id.upload_images)
                onSelectAdapter(R.id.select_dialog_listview, 0)
            }
        }
        // Click device's shutter button.
        // And decide a taken picture.
        onClickUiSelector(
                device
                , "com.android.camera2:id/shutter_button"
                , "com.android.camera2:id/done_button"
        )
    }
}

筆休め

アプリでのテストは、主にUTであることが多いです。
UIを伴うITテストは、業務要件が頻繁に変更となるアプリではテストコードの遺産化が早く進みます。
そのためUIテストは、作成するシチュエーションが少ないと思われますが、リグレッションテスト向けに作成しておくと、変更箇所以外のデグレを自動で検知できるようになります。
アプリの品質を担保するためにも、なるべくコストが少ない方法で仕組みを改善したいですね。

以上、「EspressoでUIテストを自動化する」でした。


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