Android UI 测试基础

语言: CN / TW / HK

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第8天,点击查看活动详情

Android 操作系统是一套可视化系统,所以对 UI 和用户交互进行测试是必须要的。

UI 测试

UI 测试的一种方法是直接让测试人员对目标应用执行一系列用户操作,验证其行为是否正常。这种人工操作的方式一般非常耗时、繁琐、容易出错且 case 覆盖面不全。而另一种高效的方法是为编写 UI 测试,以自动化的方式执行用户操作。自动化方法可以可重复且快速可靠地运行测试。

使用 Android Studio 自动执行 UI 测试,需要在 src/AndroidTest/java 中实现测试代码,这种测试属于插桩单元测试。Android 的 Gradle 插件会根据测试代码构建一个测试应用,然后在目标应用所在的设备上加载该测试应用。在测试代码中,可以使用 UI 测试框架来模拟目标应用上的用户交互。

注意:并不是所有对 UI 的测试都是插桩单元测试,在本地单元测试中,也可以通过第三方框架(例如 Robolectric )来模拟 Android 运行环境,但这种测试是跑在开发计算机上的,基于 JVM 运行,而不是 Android 模拟器或物理设备的真实环境。

涉及 UI 测试的场景有两种情况:

  • 单个 App 的 UI 测试:这种类型的测试可以验证目标应用在用户执行特定操作或在其 Activity 中输入特定内容时行为是否符合预期。Espresso 之类的 UI 测试框架可以实现通过编程的方式模拟用户交互。
  • 流程涵盖多个 App 的 UI 测试:这种类型的测试可以验证不同 App 之间或是用户 App 与系统 App 之间的交互流程是否正常运行。比如在一个应用中打开系统相机进行拍照。UI Automator 框架可以支持跨应用交互。

Android 中的 UI 测试框架

Jetpack 包含了丰富的官方框架,这些框架提供了用于编写 UI 测试的 API:

  • Espresso :提供了用于编写 UI 测试的 API ,可以模拟用户与单个 App 进行 UI 交互。使用 Espresso 的一个主要好处是它提供了测试操作与您正在测试的应用程序 UI 的自动同步。Espresso 会检测主线程何时空闲,因此它能够在适当的时间运行您的测试命令,从而提高测试的可靠性。
  • Jetpack Compose :提供了一组测试 API 用来启动 Compose 屏幕和组件之间的交互,融合到了开发过程中。算是 Compose 的一个优势。
  • UI Automator : 是一个 UI 测试框架,适用于涉及多个应用的操作流程的测试。
  • Robolectric :在 JVM 上运行本地单元测试,而不是模拟器或物理设备上。可以配合 Espresso 或 Compose 的测试 API 与 UI 组件进行模拟交互。

异常行为和同步处理

因为 Android 应用是基于多线程实现的,所有涉及 UI 的操作都会发送到主线程排队执行,所以在编写测试代码时,需要处理这种异步存在的问题。当一个用户输入注入时,测试框架必须等待 App 对用户输入进行响应。当一个测试没有确定性行为的时候,就会出现异常行为。

像 Compose 或 Espresso 这样的现代框架在设计时就考虑到了测试场景,因此可以保证在下一个测试操作或断言之前 UI 将处于空闲状态,从而保证了同步行为。

流程图显示了在通过测试之前检查应用程序是否空闲的循环:

流程图显示了在通过测试之前检查应用程序是否空闲的循环.png

在测试中使用 sleep 会导致测试缓慢或者不稳定,如果有动画执行超过 2s 就会出现异常情况。

显示同步基于等待固定时间时的测试失败的图表.png

应用架构和测试

另一方面,应用的架构应该能够快速替换一些组件,以支持 mock 数据或逻辑进行测试,例如,在有异步加载数据的场景,但我们并不关心异步数据获取相关逻辑的情况下,仅关心获取到数据后的 UI 层测试,就可以将异步逻辑替换成假的数据源,从而能够更加高效的进行测试:

生产和测试架构图。 生产图显示了向存储库提供数据的本地和远程数据源,而存储库又将数据异步提供给 UI。 测试图显示了一个 Fake 存储库,该存储库将其数据同步提供给 UI.png

推荐使用 Hilt 框架实现这种注入数据的替换操作。

为什么需要自动化测试?

Android App 可以在不同的 API 版本的上千种不同设备上运行,并且手机厂商有可能修改系统代码,这意味着 App 可能会在一些设备上不正确地运行甚至导致 crash 。

UI 测试可以进行兼容性测试,验证 App 在不同环境中的行为。例如可以测试不同环境下的行为:

  • API level 不同
  • 位置和语言设置不同
  • 屏幕方向不同

此外,还要考虑设备类型的问题,例如平板电脑和可折叠设备的行为,可能与普通手机设备环境下,产生不同的行为。

AndroidX 测试框架的使用

环境配置

  1. 修改根目录下的 build.gradle文件,确保项目依赖仓库:

groovy allprojects { repositories { jcenter() google() } }

  1. 添加测试框架依赖:

```groovy dependencies { // 核心框架 androidTestImplementation "androidx.test:core:$androidXTestVersion0"

// AndroidJUnitRunner and JUnit Rules
androidTestImplementation "androidx.test:runner:$testRunnerVersion"
androidTestImplementation "androidx.test:rules:$testRulesVersion"

// Assertions 断言
androidTestImplementation "androidx.test.ext:junit:$testJunitVersion"
androidTestImplementation "androidx.test.ext:truth:$truthVersion"

// Espresso 依赖
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
androidTestImplementation "androidx.test.espresso:espresso-contrib:$espressoVersion"
androidTestImplementation "androidx.test.espresso:espresso-intents:$espressoVersion"
androidTestImplementation "androidx.test.espresso:espresso-accessibility:$espressoVersion"
androidTestImplementation "androidx.test.espresso:espresso-web:$espressoVersion"
androidTestImplementation "androidx.test.espresso.idling:idling-concurrent:$espressoVersion"

// 下面的依赖可以使用 "implementation" 或 "androidTestImplementation",
// 取决于你是希望这个依赖出现在 Apk 中,还是测试 apk 中
androidTestImplementation "androidx.test.espresso:espresso-idling-resource:$espressoVersion"

} ```

发行版本号参阅: https://developer.android.com/jetpack/androidx/releases/test

另外值得注意的一点是 espresso-idling-resource 这个依赖在生产代码中使用的话,需要打包到 apk 中。

AndroidX 中的 Junit4 Rules

AndroidX 测试框架包含了一组配合 AndroidJunitRunner 使用的 Junit Rules。

关于什么是 JUnit Rules ,可以查看 wiki:https://github.com/junit-team/junit4/wiki/Rules

JUnit Rules 提供了更大的灵活性并减少了测试中所需的样板代码。可以将 JUnit Rules 理解为一些模拟环境用来测试的 API 。例如:

  • ActivityScenarioRule : 用来模拟 Activity 。
  • ServiceTestRule :可以用来模拟启动 Service 。
  • TemporaryFolder :可以用来创建文件和文件夹,这些文件会在测试方法完成时被删除(若不能删除,会抛出异常)。
  • ErrorCollector :发生问题后继续执行测试,最后一次性报告所有错误内容。
  • ExpectedException :在测试过程中指定预期的异常。

除了上面几个例子,还有很多 Rules ,可以将 Rules 理解为用来在测试中快捷实现一些能力的 API 。

ActivityScenarioRule

ActivityScenarioRule 用来对单个 Activity 进行功能测试。声明一个 ActivityScenarioRule 实例:

kotlin @get:Rule val activityRule = ActivityScenarioRule(MainActivity::class.java)

这个规则,会在执行标注有 @Test 注解的测试方法启动前,绑定构造参数中执行的 Activity ,并且在带有 @Test 测试方法执行前,先执行所有带有 @Before 注解的方法,并在执行的测试方法结束后,执行所有带有 @After 注解的方法。

```kotlin @RunWith(AndroidJUnit4::class) class MainActivityTest { @Before fun beforeActivityCreate() { Log.d(TAG, "beforeActivityCreate") }

@Before
fun beforeTest() {
    Log.d(TAG, "beforeTest")
}

@Test
fun onCreate() {
    activityRule.scenario.moveToState(Lifecycle.State.CREATED).onActivity {
        Log.d(TAG, "in test thread: ${Thread.currentThread()}}")
    }
}

@After
fun afterActivityCreate() {
    Log.d(TAG, "afterActivityCreate")
}
// ...

} ```

执行这个带有 @Test 注解的 onCreate方法,其日志为:

log 2022-06-17 17:29:07.341 I/TestRunner: started: onCreate(com.chunyu.accessibilitydemo.MainActivityTest) 2022-06-17 17:29:08.006 D/MainActivityTest: beforeTest 2022-06-17 17:29:08.006 D/MainActivityTest: beforeActivityCreate 2022-06-17 17:29:08.565 D/MainActivityTest: in ui thread: Thread[main,5,main] 2022-06-17 17:29:08.566 D/MainActivityTest: afterActivityCreate 2022-06-17 17:29:09.054 I/TestRunner: finished: onCreate(com.chunyu.accessibilitydemo.MainActivityTest)

在执行完所有的 @After 方法后,会终止模拟启动的这个 Activity 。

访问 Activity

测试方法中的重点是通过 ActivityScenarioRule 模拟构造 Activity ,并对其中的一些行为进行测试。

如果要在测试逻辑中访问指定的 Activity ,可以通过 ActivityScenarioRule.getScenario().onActivity{ ... } 回调中指定一些代码逻辑。例如上面的 onCreate() 测试方法中,稍加修改,就可以展示访问 Activity 的能力:

kotlin @Test fun onCreate() { activityRule.scenario.onActivity { it -> Log.d(TAG, "${it.isFinishing}") } }

不光可以访问 Activity 中公开的属性和方法,还可以访问指定 Activity 中 public 的内容,例如:

java @Test fun test() { activityRule.scenario.onActivity { it -> it.button.performClick() } }

控制 Activity 的生命周期

在最开始的例子中,我们通过 moveToState 来控制了这个 Activity 的生命周期,修改代码:

kotlin @Test fun onCreate() { activityRule.scenario.moveToState(Lifecycle.State.CREATED).onActivity { Log.d(TAG, "${it.lifecycle.currentState}") } }

我们在 onActivity 中打印 Activity 的当前生命周期,检查一下是否真的是在 moveToState 中指定的状态,打印结果:

2022-06-17 17:45:30.425 D/MainActivityTest: CREATED

moveToState 的确生效了,它可以将 Activity 控制到我们想要的状态。

通过 ActivityScenarioRule 的 getState() ,也可以直接获取到模拟的 Activity 的状态,这个方法可能存在的状态包括:

  • State.CREATED
  • State.STARTED
  • State.RESUMED
  • State.DESTROYED

moveToState 能够设置的值包括:

```java public enum State { // 这个状态表示 Activity 已销毁 DESTROYED,

    // 初始化状态,还没调用 onCreate
    INITIALIZED,

    // 存在两种情况,在 onCreate 开始后,onStop 结束前
    CREATED,

    // 存在两种情况,在 onStart 开始后,在 onPause 结束前。
    STARTED,

    // onResume 开始后调用。
    RESUMED;

            // ...
}

```

当 moveToState 设置为 DESTROYED ,再访问 Activity ,会抛出异常

java.lang.NullPointerException: Cannot run onActivity since Activity has been destroyed already

如果要测试 Fragment ,可以通过 FragmentScenario 进行,此类需要引用

groovy debugImplementation "androidx.fragment:fragment-testing:$fragment_version"

ServiceTestRule

ServiceTestRule 用来在单元测试情况下模拟启动指定的 Service ,包括 bindServicestartService 两种方式,创建一个 ServiceTestRule 实例:

kotlin @get:Rule val serviceTestRule = ServiceTestRule()

在测试方法中通过 ServiceTestRule 启动 Service ,下面是一个普通的服务,在真实环境下通过 startService 可以正常启动:

```kotlin class RegularService: Service() {

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    Log.d("onStartCommand", ": ${Thread.currentThread().name}")
    Toast.makeText(this,  "in Service", Toast.LENGTH_SHORT).show()
    return super.onStartCommand(intent, flags, startId)
}

override fun onBind(intent: Intent?): IBinder? {
    return null
}

} ```

startService

```kotlin @Test fun testService() { serviceTestRule.startService(Intent(ApplicationProvider.getApplicationContext(), RegularService::class.java)) }

```

但是这样会抛出异常:

java.util.concurrent.TimeoutException: Waited for 5 SECONDS, but service was never connected

这是因为,通过 ServiceTestRule 的 startService(Intent) 启动一个 Service ,会在 5s 内阻塞直到 Service 已连接,即调用到了 ServiceConnection.onServiceConnected(ComponentName, IBinder)

也就是说,你的 Service 的 onBind(Intent) 方法,不能返回 null ,否则就会抛出 TimeoutException 。

修改 RegularService :

```kotlin class RegularService: Service() {

private val binder = RegularBinder()

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    Log.d("RegularServiceTest", "onStartCommand")
    return super.onStartCommand(intent, flags, startId)
}

override fun onBind(intent: Intent?): IBinder? {
    return binder
}

inner class RegularBinder: Binder() {
    fun getService(): RegularService = this@RegularService
}

} ```

这样,通过 ServiceTestRule 的 startService 启动服务就可以正常运行了:

2022-06-17 19:51:59.772 I/TestRunner: started: testService(com.chunyu.accessibilitydemo.service.RegularServiceTest) 2022-06-17 19:51:59.777 D/RegularServiceTest: beforeService1 2022-06-17 19:51:59.777 D/RegularServiceTest: beforeService2 2022-06-17 19:51:59.795 D/RegularServiceTest: onStartCommand 2022-06-17 19:51:59.820 D/RegularServiceTest: afterService1 2022-06-17 19:51:59.820 D/RegularServiceTest: afterService2 2022-06-17 19:51:59.830 I/TestRunner: finished: testService(com.chunyu.accessibilitydemo.service.RegularServiceTest)

ServiceTestRule 和 ActivityScenarioRule 一样,都会在执行测试前执行所有的 @Before 方法,执行结束后,继续执行所有的 @After 方法。

bindService

kotlin @Test fun testService() { serviceTestRule.bindService(Intent(ApplicationProvider.getApplicationContext(), RegularService::class.java)) }

ServiceTestRule.bindService 效果和 Context.bindService 相同,都不走 onStartCommand 而是 onBind 方法。

2022-06-17 19:57:19.274 I/TestRunner: started: testService(com.chunyu.accessibilitydemo.service.RegularServiceTest) 2022-06-17 19:57:19.277 D/RegularServiceTest: beforeService1 2022-06-17 19:57:19.277 D/RegularServiceTest: beforeService2 2022-06-17 19:57:19.296 D/RegularServiceTest: onBind 2022-06-17 19:57:19.302 D/RegularServiceTest: afterService1 2022-06-17 19:57:19.302 D/RegularServiceTest: afterService2 2022-06-17 19:57:19.314 I/TestRunner: finished: testService(com.chunyu.accessibilitydemo.service.RegularServiceTest)

测试方法的执行顺序也是一样的。

访问 Service

startService 启动的 Service 无法获取到 Service 实例,ServiceTestRule 并没有像 ActivityScenarioRule 那样提供 onActivity {... } 回调方法。

bindService 的返回类型是 IBinder ,可以通过 IBinder 对象获取到 Service 实例:

kotlin @Test fun testService() { val binder = serviceTestRule.bindService(Intent(ApplicationProvider.getApplicationContext(), RegularService::class.java)) val service = (binder as? RegularService.RegularBinder)?.getService() // access RegularService info }