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 将处于空闲状态,从而保证了同步行为。
流程图显示了在通过测试之前检查应用程序是否空闲的循环:
在测试中使用 sleep 会导致测试缓慢或者不稳定,如果有动画执行超过 2s 就会出现异常情况。
应用架构和测试
另一方面,应用的架构应该能够快速替换一些组件,以支持 mock 数据或逻辑进行测试,例如,在有异步加载数据的场景,但我们并不关心异步数据获取相关逻辑的情况下,仅关心获取到数据后的 UI 层测试,就可以将异步逻辑替换成假的数据源,从而能够更加高效的进行测试:
推荐使用 Hilt 框架实现这种注入数据的替换操作。
为什么需要自动化测试?
Android App 可以在不同的 API 版本的上千种不同设备上运行,并且手机厂商有可能修改系统代码,这意味着 App 可能会在一些设备上不正确地运行甚至导致 crash 。
UI 测试可以进行兼容性测试,验证 App 在不同环境中的行为。例如可以测试不同环境下的行为:
- API level 不同
- 位置和语言设置不同
- 屏幕方向不同
此外,还要考虑设备类型的问题,例如平板电脑和可折叠设备的行为,可能与普通手机设备环境下,产生不同的行为。
AndroidX 测试框架的使用
环境配置
- 修改根目录下的
build.gradle
文件,确保项目依赖仓库:
allprojects {
repositories {
jcenter()
google()
}
}
- 添加测试框架依赖:
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"
}
发行版本号参阅: developer.android.com/jetpack/and…
另外值得注意的一点是 espresso-idling-resource
这个依赖在生产代码中使用的话,需要打包到 apk 中。
AndroidX 中的 Junit4 Rules
AndroidX 测试框架包含了一组配合 AndroidJunitRunner 使用的 Junit Rules。
关于什么是 JUnit Rules ,可以查看 wiki:github.com/junit-team/…
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 ,包括 bindService
和 startService
两种方式,创建一个 ServiceTestRule 实例:
@get:Rule
val serviceTestRule = ServiceTestRule()
在测试方法中通过 ServiceTestRule 启动 Service ,下面是一个普通的服务,在真实环境下通过 startService
可以正常启动:
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
@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 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
@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
}
作者:自动化BUG制造器
链接:https://juejin.cn/post/7110184974791213064
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。