Android UI 测试基础

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 将处于空闲状态,从而保证了同步行为。

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

Android UI 测试基础

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

Android UI 测试基础

应用架构和测试

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

Android UI 测试基础

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

为什么需要自动化测试?

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

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

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

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

AndroidX 测试框架的使用

环境配置

  1. 修改根目录下的 build.gradle文件,确保项目依赖仓库:
allprojects {
    repositories {
       jcenter()
       google()
    }
}
  1. 添加测试框架依赖:
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 实例:

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

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

@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方法,其日志为:

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 的能力:

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

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

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

控制 Activity 的生命周期

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

    @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 能够设置的值包括:

    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 进行,此类需要引用

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

ServiceTestRule

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

    @get:Rule
    val serviceTestRule = ServiceTestRule()

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

class RegularServiceService() {

    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

    @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 :

class RegularServiceService() {

    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 RegularBinderBinder() {
        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

    @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 实例:

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


原文始发于微信公众号(八千里路山与海):Android UI 测试基础

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/60252.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!