MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Kotlin Android UI测试

2021-08-113.8k 阅读

Kotlin Android UI 测试基础

在 Android 开发中,UI 测试对于确保应用的用户界面功能正常、交互流畅至关重要。Kotlin 作为 Android 开发的首选语言之一,提供了强大的工具和库来进行 UI 测试。

1. 测试框架选择

Android 开发中常用的 UI 测试框架有 Espresso 和 UI Automator。

Espresso

  • 专为 Android 应用的 UI 测试设计,适用于测试应用内的 UI 交互。它能够快速定位视图并执行操作,如点击按钮、输入文本等。
  • 优点是简洁易用,测试代码直观,与应用的 UI 紧密结合。

UI Automator

  • 更侧重于跨应用的 UI 测试以及对系统级 UI 的操作。它可以在不同应用之间切换,操作系统级别的控件。
  • 优点是功能强大,适用于测试复杂的场景,如多应用交互。

在 Kotlin 中,我们可以方便地使用这两个框架进行 UI 测试。

2. 项目配置

要在 Kotlin 项目中进行 UI 测试,首先需要在 build.gradle 文件中添加相关依赖。

对于 Espresso,在 androidTestImplementation 中添加:

androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0'

espresso-core 是核心库,espresso-contrib 则包含了对一些常用 UI 组件(如 RecyclerView)的支持。

对于 UI Automator,添加:

androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'

同时,确保 androidTest 源集的 AndroidManifest.xml 中配置了正确的权限和 instrumentation 信息。

<instrumentation
    android:name="androidx.test.runner.AndroidJUnitRunner"
    android:targetPackage="com.example.yourpackage" />

使用 Espresso 进行 UI 测试

1. 定位视图

Espresso 使用 ViewMatchers 来定位视图。例如,要定位一个按钮,可以使用 withId 方法:

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.matcher.ViewMatchers.withId
import org.junit.Test
import com.example.yourpackage.R

class EspressoUITest {
    @Test
    fun clickButtonTest() {
        onView(withId(R.id.button_id)).perform(click())
    }
}

这里 R.id.button_id 是按钮在布局文件中的 ID。除了 withId,还有其他常用的 ViewMatchers,如 withText(通过文本内容定位视图)、withClassName(通过类名定位视图)等。

2. 执行操作

定位到视图后,就可以使用 ViewActions 执行操作。常见的操作有 click()(点击视图)、typeText()(输入文本)等。

例如,在一个输入框中输入文本并点击提交按钮:

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.typeText
import androidx.test.espresso.matcher.ViewMatchers.withId
import org.junit.Test
import com.example.yourpackage.R

class EspressoUITest {
    @Test
    fun inputAndSubmitTest() {
        onView(withId(R.id.edit_text_id)).perform(typeText("Hello World"))
        onView(withId(R.id.submit_button_id)).perform(click())
    }
}

在执行 typeText 操作时,要注意输入框的焦点问题。如果输入框没有焦点,可能需要先调用 ViewActions.click 操作获取焦点。

3. 断言结果

使用 ViewAssertions 可以对 UI 操作的结果进行断言。例如,验证某个视图是否可见:

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import org.junit.Test
import com.example.yourpackage.R

class EspressoUITest {
    @Test
    fun buttonVisibilityTest() {
        onView(withId(R.id.button_id)).perform(click())
        onView(withId(R.id.result_view_id)).check(matches(isDisplayed()))
    }
}

这里点击按钮后,验证结果视图 result_view_id 是否可见。常用的断言方法还有 matches(withText("expected text"))(验证视图文本是否符合预期)等。

4. 处理 RecyclerView

在 Android 应用中,RecyclerView 是非常常见的组件。Espresso 提供了 RecyclerViewActions 来处理 RecyclerView。

假设我们有一个 RecyclerView,每个 item 中有一个 TextView 和一个 Button。要点击某个 item 中的按钮,可以这样写:

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition
import androidx.test.espresso.matcher.ViewMatchers.withId
import org.junit.Test
import com.example.yourpackage.R

class RecyclerViewEspressoTest {
    @Test
    fun clickItemButtonTest() {
        onView(withId(R.id.recycler_view_id))
           .perform(actionOnItemAtPosition<RecyclerView.ViewHolder>(0, click()))
    }
}

这里 0 表示点击 RecyclerView 中第一个 item 的按钮。如果要根据 item 中的文本内容来定位并操作,可以自定义 ViewMatcher

import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.matcher.BoundedMatcher
import org.hamcrest.Description
import org.hamcrest.Matcher
import android.view.View
import android.widget.TextView

fun withItemText(text: String): Matcher<View> {
    return object : BoundedMatcher<View, RecyclerView.ViewHolder>(RecyclerView.ViewHolder::class.java) {
        override fun describeTo(description: Description) {
            description.appendText("has item text: $text")
        }

        override fun matchesSafely(holder: RecyclerView.ViewHolder): Boolean {
            val view = holder.itemView.findViewById<TextView>(R.id.text_view_in_item)
            return view.text.toString() == text
        }
    }
}

class RecyclerViewEspressoTest {
    @Test
    fun clickItemButtonByTextTest() {
        onView(withId(R.id.recycler_view_id))
           .perform(actionOnItem<RecyclerView.ViewHolder>(withItemText("specific text"), click()))
    }
}

使用 UI Automator 进行 UI 测试

1. 获取 UiDevice 实例

UI Automator 通过 UiDevice 类来与设备进行交互。首先要获取 UiDevice 实例:

import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import org.junit.Before
import org.junit.Test

class UiAutomatorUITest {
    private lateinit var device: UiDevice

    @Before
    fun setup() {
        device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
    }

    @Test
    fun testAppLaunch() {
        device.launchApp("com.example.yourpackage")
    }
}

@Before 注解的方法会在每个测试方法执行前调用,确保 UiDevice 实例已经初始化。

2. 定位 UiObject

UI Automator 使用 UiSelector 来定位 UiObject(代表一个 UI 元素)。例如,定位一个按钮:

import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import org.junit.Before
import org.junit.Test

class UiAutomatorUITest {
    private lateinit var device: UiDevice

    @Before
    fun setup() {
        device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
    }

    @Test
    fun clickButtonTest() {
        val button: UiObject2? = device.findObject(By.res("com.example.yourpackage:id/button_id"))
        button?.click()
    }
}

这里通过资源 ID 定位按钮。UiSelector 还支持通过文本、类名等多种方式定位。

3. 执行操作和断言

定位到 UiObject 后,可以执行操作并进行断言。例如,输入文本并验证结果:

import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import org.junit.Before
import org.junit.Test
import org.junit.Assert.assertTrue

class UiAutomatorUITest {
    private lateinit var device: UiDevice

    @Before
    fun setup() {
        device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
    }

    @Test
    fun inputAndVerifyTest() {
        val editText: UiObject2? = device.findObject(By.res("com.example.yourpackage:id/edit_text_id"))
        editText?.setText("Hello")
        val resultText: UiObject2? = device.findObject(By.res("com.example.yourpackage:id/result_text_id"))
        assertTrue(resultText?.text == "Hello")
    }
}

这里在输入框输入文本后,验证结果文本是否与输入的文本一致。

4. 跨应用操作

UI Automator 的一个强大功能是跨应用操作。例如,从一个应用打开另一个应用,并进行操作:

import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import org.junit.Before
import org.junit.Test

class CrossAppUiAutomatorTest {
    private lateinit var device: UiDevice

    @Before
    fun setup() {
        device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
    }

    @Test
    fun crossAppTest() {
        device.launchApp("com.example.firstpackage")
        // 在第一个应用中执行一些操作,比如点击一个按钮打开第二个应用
        val openSecondAppButton: UiObject2? = device.findObject(By.res("com.example.firstpackage:id/open_second_app_button_id"))
        openSecondAppButton?.click()
        device.wait(Until.hasObject(By.pkg("com.example.secondpackage")), 5000)
        // 在第二个应用中进行操作,比如点击某个按钮
        val secondAppButton: UiObject2? = device.findObject(By.res("com.example.secondpackage:id/second_app_button_id"))
        secondAppButton?.click()
    }
}

这里先启动第一个应用,通过点击按钮打开第二个应用,然后在第二个应用中进行操作。device.wait 方法用于等待第二个应用启动完成。

高级 UI 测试技巧

1. 处理异步操作

在 Android 应用中,很多操作是异步的,如网络请求、数据库查询等。在 UI 测试中,需要等待异步操作完成后再进行断言。

在 Espresso 中,可以使用 IdlingResource 来处理异步操作。例如,假设我们有一个网络请求,请求完成后会更新 UI:

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import com.example.yourpackage.R
import com.example.yourpackage.ui.MainActivity
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.atomic.AtomicBoolean

@LargeTest
@RunWith(AndroidJUnit4::class)
class AsyncEspressoTest {
    private lateinit var activity: MainActivity
    private val isLoading = AtomicBoolean(false)

    @Before
    fun setup() {
        activity = launchActivity<MainActivity>()
        val idlingResource = object : EspressoIdlingResource {
            override val name: String
                get() = "network_idling_resource"
            override fun isIdleNow(): Boolean {
                return!isLoading.get()
            }

            override fun registerIdleTransitionCallback(callback: IdleCallback) {
            }
        }
        IdlingRegistry.getInstance().register(idlingResource)
    }

    @After
    fun teardown() {
        IdlingRegistry.getInstance().unregisterAll()
    }

    @Test
    fun asyncOperationTest() {
        onView(withId(R.id.trigger_button_id)).perform(click())
        isLoading.set(true)
        // 模拟网络请求完成
        activity.runOnUiThread {
            isLoading.set(false)
        }
        onView(withId(R.id.result_view_id)).check(matches(isDisplayed()))
    }
}

这里定义了一个 IdlingResource,在异步操作开始时设置 isLoadingtrue,操作完成后设置为 false。Espresso 在执行断言前会等待 IdlingResource 变为空闲状态。

在 UI Automator 中,可以使用 UiDevice.wait 方法结合 Until 类来等待异步操作完成。例如:

import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Before
import org.junit.Test

class AsyncUiAutomatorTest {
    private lateinit var device: UiDevice

    @Before
    fun setup() {
        device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
    }

    @Test
    fun asyncOperationTest() {
        val triggerButton: UiObject2? = device.findObject(By.res("com.example.yourpackage:id/trigger_button_id"))
        triggerButton?.click()
        device.wait(Until.hasObject(By.res("com.example.yourpackage:id/result_view_id")), 10000)
        val resultView: UiObject2? = device.findObject(By.res("com.example.yourpackage:id/result_view_id"))
        resultView?.let {
            // 进行断言等操作
        }
    }
}

这里等待结果视图出现,超时时间为 10 秒。

2. 测试不同屏幕尺寸和方向

为了确保应用在不同设备上都能正常显示和交互,需要测试不同的屏幕尺寸和方向。

在 Android 测试中,可以通过 Configuration 类来模拟不同的屏幕尺寸和方向。例如,在 Espresso 测试中:

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import android.content.res.Configuration
import com.example.yourpackage.R
import org.junit.Test
import org.junit.runner.RunWith

@LargeTest
@RunWith(AndroidJUnit4::class)
class ScreenOrientationEspressoTest {
    @Test
    fun landscapeAndPortraitTest() {
        // 竖屏测试
        onView(withId(R.id.button_id)).perform(click())
        onView(withId(R.id.result_view_id)).check(matches(isDisplayed()))
        // 切换到横屏
        rotateDevice(Configuration.ORIENTATION_LANDSCAPE)
        onView(withId(R.id.button_id)).perform(click())
        onView(withId(R.id.result_view_id)).check(matches(isDisplayed()))
        // 切换回竖屏
        rotateDevice(Configuration.ORIENTATION_PORTRAIT)
    }

    private fun rotateDevice(orientation: Int) {
        val activity = activityScenarioRule.scenario.get()
        val config = activity.resources.configuration
        config.orientation = orientation
        activity.createConfigurationContext(config)
    }
}

这里通过 rotateDevice 方法切换屏幕方向,并在不同方向下进行 UI 操作和断言。

在 UI Automator 测试中,同样可以通过 UiDevice.setOrientation 方法来切换屏幕方向:

import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import org.junit.Before
import org.junit.Test

class ScreenOrientationUiAutomatorTest {
    private lateinit var device: UiDevice

    @Before
    fun setup() {
        device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
    }

    @Test
    fun landscapeAndPortraitTest() {
        val button: UiObject2? = device.findObject(By.res("com.example.yourpackage:id/button_id"))
        val resultView: UiObject2? = device.findObject(By.res("com.example.yourpackage:id/result_view_id"))
        button?.click()
        resultView?.let {
            // 竖屏断言
        }
        device.setOrientation(UiDevice.ORIENTATION_LANDSCAPE)
        button?.click()
        resultView?.let {
            // 横屏断言
        }
        device.setOrientation(UiDevice.ORIENTATION_PORTRAIT)
    }
}

3. 集成测试与端到端测试

UI 测试不仅仅局限于单个界面的测试,还可以进行集成测试和端到端测试。

集成测试关注多个组件之间的交互。例如,测试一个 Activity 与一个 Fragment 之间的数据传递和交互:

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import com.example.yourpackage.R
import com.example.yourpackage.ui.MainActivity
import org.junit.Test
import org.junit.runner.RunWith

@LargeTest
@RunWith(AndroidJUnit4::class)
class IntegrationEspressoTest {
    @Test
    fun activityFragmentInteractionTest() {
        launchActivity<MainActivity>()
        onView(withId(R.id.trigger_button_in_activity_id)).perform(click())
        onView(withId(R.id.result_view_in_fragment_id)).check(matches(isDisplayed()))
    }
}

这里点击 Activity 中的按钮,验证 Fragment 中的结果视图是否显示。

端到端测试则模拟用户从启动应用到完成一系列操作的完整流程。例如,测试用户注册登录流程:

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.typeText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import com.example.yourpackage.R
import com.example.yourpackage.ui.LoginActivity
import org.junit.Test
import org.junit.runner.RunWith

@LargeTest
@RunWith(AndroidJUnit4::class)
class EndToEndEspressoTest {
    @Test
    fun registerAndLoginTest() {
        // 注册流程
        launchActivity<RegisterActivity>()
        onView(withId(R.id.username_edit_text_id)).perform(typeText("testuser"))
        onView(withId(R.id.password_edit_text_id)).perform(typeText("testpass"))
        onView(withId(R.id.register_button_id)).perform(click())
        // 登录流程
        launchActivity<LoginActivity>()
        onView(withId(R.id.username_edit_text_id)).perform(typeText("testuser"))
        onView(withId(R.id.password_edit_text_id)).perform(typeText("testpass"))
        onView(withId(R.id.login_button_id)).perform(click())
        // 验证登录成功后的界面
        onView(withId(R.id.dashboard_view_id)).check(matches(isDisplayed()))
    }
}

这里模拟用户先注册,然后登录,并验证登录后进入的仪表盘视图是否显示。

通过以上对 Kotlin Android UI 测试的详细介绍,从基础框架的使用到高级技巧的应用,希望能帮助开发者更全面、深入地进行 Android 应用的 UI 测试,确保应用的质量和用户体验。在实际开发中,应根据项目的需求和特点,合理选择测试框架和方法,构建完善的 UI 测试体系。