本章では、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" ) } }