Android UI 测试最佳实践 Espresso UI Automator Compose UI 测试 WireMock / MockWebServer OkReplay Barista Kaspresso 结论

2025-06-10

Android UI 测试的最佳实践

浓缩咖啡

UI自动化

撰写 UI 测试

WireMock / MockWebServer

OkReplay

咖啡师

卡斯普雷索

结论

在上一篇文章,我们讨论了最近流行起来或一直是开发标准的 Android 单元测试框架。它们始终位于测试金字塔的底层。在本文中,我们将跳到更高的层次,即 UI 测试框架。

图片描述

与上一篇文章一样,我不会涉及使用 UI Automator 和 Espresso 编写测试的基础知识,因为这意味着您已经熟悉它们。但我会为您提供一些建议,帮助您更轻松地编写 UI 测试,以及如何解决最常见的问题。使用标准工具并不总是能够解决这些问题,因此各种插件、扩展和框架通常可以提供帮助。我将介绍我自己使用过的,以及如果您还没有使用过的,我可以推荐给您的。

浓缩咖啡

所以,UI 测试。Google 的框架 Espresso 是这方面的黄金标准。Espresso 的文档很多,但简而言之,几乎每个测试都基于以下算法。

  • 对于使用ViewMatcher找到的元素
  • 做一些ViewAction
  • 使用ViewAssertion检查屏幕上显示的结果
@RunWith(AndroidJUnit4::class)
class MainActivityTest {

    @Test
    fun test_clickRefreshButton_freshItemsAreLoaded() {
        onView(withId(R.id.nameEditText)).perform(typeText("Alex"));

        onView(withId(R.id.greetButton)).perform(click());

        onView(withId(R.id.greetingTextView)).check(matches(withText("Hi, Alex!")));
    }
}
Enter fullscreen mode Exit fullscreen mode

通常,点击某个按钮时可能会打开另一个屏幕。为此,我们有另一个工具IntentMatcher,它可以检查某个 Intent 是否已启动。

这四个组件:ViewAction、ViewMatcher、ViewAssertion 和 IntentMatcher,是所有 UI 测试的基础。上面的示例非常简单,但在复杂的屏幕上,如果发生很多事情,测试代码可能会变得非常冗长,阅读起来也会更加困难。为了改进测试的结构和可读性,我们运用了各种设计模式。

测试可读性的设计模式

  • 页面对象模式:此模式意味着应用程序的每个屏幕都呈现为一个单独的类,其中包含所有界面元素以及与它们交互的方法。因此,测试场景不依赖于 UI 实现的细节,并且可以轻松适应设计的变化。KakaoKaspresso框架使用了此模式(我将在本文后面讨论)。
  • Screenplay 模式:此模式是页面对象模式的改进版本,它添加了另外两个组件:actor 和 ability。actor 是用户在应用中执行操作的角色。ability 决定了 actors 如何与应用交互(例如,通过 Espresso 或 UiAutomator)。此模式允许您编写具有高抽象级别的测试,并更好地展示应用的业务逻辑。
  • 机器人模式:此模式与剧本模式类似,但不同于演员和能力,它使用封装了与屏幕交互逻辑的机器人。机器人可以在不同的测试中重复使用,并相互组合。此模式简化了测试结构,避免了代码重复。

使用 Robot 模式编写的 Espresso 代码如下所示:

@RunWith(AndroidJUnit4::class)
class MainActivityTest {

    @Test
    fun test_clickRefreshButton_freshItemsAreLoaded() {
        login {
            setEmail("mail@example.com")
            setPassword("pass")
            clickLogin()
        }
        home {
            checkFirstRow("First item")
            clickRefreshItems()
            checkFirstRow("First ")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

封装了 Espresso 逻辑的机器人将会是这样的:

class HomeRobot {
    fun checkFirstRow(text: String) {
        onView(withId(R.id.item1)).check(matches(withText(text)))
    }

    fun clickRefreshItems() {
        onView(withId(R.id.button)).perform(click())
    }
}

fun home(body: HomeRobot.() -> Unit) {
    HomeRobot().apply { this.body() }
}
Enter fullscreen mode Exit fullscreen mode

因此,如果测试失败,我们就会知道哪个步骤出了问题以及该如何处理。

理想情况下,我们的 Espresso 测试必须像单元测试一样简单易读。如果一次测试涵盖由多个屏幕组成的整个流程,则并非总是可行。在这种情况下,不可能高质量地测试某个特定的屏幕。

流动测试与屏幕测试

当您的 Espresso 测试覆盖整个流程时,您的测试将如下所示:

  • 打开屏幕A
  • 执行操作 1
  • 确保操作成功
  • 执行操作 2
  • 确保屏幕 B已打开
  • 执行操作 3
  • 确保操作成功

在这里,我们走一条所谓的“快乐路径”,不检查任何极端情况。实际上,这种测试被称为端到端 (e2e) 测试,它必须尽可能地与屏幕实现分离(理想情况下,它不能使用 Espresso 编写,而是使用其他提供更高抽象级别的框架(UI Automator、Appium 或其他框架))。由于其复杂性,此类测试经常会失败,而且很难修复。此外,它们在持续集成 (CI) 上运行成本相当高,可能持续几分钟甚至几小时,因此您不会希望在每个拉取请求 (PR) 上都运行它们。这就是为什么一个项目中不能有很多这样的测试。相反,
我们可以有更多原子 UI 测试,它们只测试特定的屏幕。这样的测试将包含一组简单的操作:

  • 在onBefore中打开一个屏幕
  • 执行操作 1
  • 确保操作成功

这类测试有很多,它们既能覆盖理想路径,也能涵盖各种极端情况。而且,这类测试通常更稳定。由于其简单性,出错导致测试失败的可能性要小得多。不过,有时你可能会遇到这样的情况:你的测试正确,它所涵盖的业务逻辑也正确。你期望测试在 100% 的情况下都能通过,但实际上只有 1% 的情况下是失败的。这样的测试被称为“不稳定的”。

片状

不稳定测试的主要问题可能在于网络和其他后台操作。问题是,当我们在测试中执行某个操作(例如,点击按钮)并期望得到某个结果时,结果可能会延迟。默认情况下,Espresso 框架可以等待各种操作的完成,但它只处理与 UI 交互的操作(例如,另一个 Activity 以动画方式打开)。Espresso 对与我们的业务逻辑相关的后台操作一无所知。这可能导致测试失败,onView(withId(R.id.item1)).check(matches(withText(text)))因为预期的文本尚未加载或显示。然而,测试并非总是会失败,只有当运行测试的模拟器上的网络连接速度较慢时才会失败。这可能是不稳定测试最常见的问题之一。有多种方法可以解决这个问题:

  • 在我们的测试中添加 Thread.sleep(…)。这是一种蛮力方法,在大多数情况下都会有所帮助,但是,首先,我们无法避免延迟时间比 sleep 更长的情况,这样测试仍然会失败。此外, sleep 会在每次测试运行中添加不必要的延迟,即使服务器运行速度足够快,我们的测试仍然会运行得比我们需要的更长。
  • 为 ViewMatcher 添加超时和重试功能。如下所示:

    fun onViewWithTimeout(
        retries: Int = 10,
        retryDelayMs: Long = 500,
        retryAssertion: ViewAssertion = matches(withEffectiveVisibility(Visibility.VISIBLE)),
        matcher: Matcher<View>,
    ): ViewInteraction {
        repeat(retries) { i ->
            try {
                val viewInteraction = onView(matcher)
                viewInteraction.check(retryAssertion)
                return viewInteraction
            } catch (e: NoMatchingViewException) {
                if (i >= retries) {
                    throw e
                } else {
                    Thread.sleep(retryDelayMs)
                }
            }
        }
        throw AssertionError("View matcher is broken for $matcher")
    }
    

Kaspresso 框架就采用了这种方法,我将在下文中讨论。这比添加 好得多Thread.sleep(),但它仍然不能保证你设置的超时时间会比服务器延迟更长。此外,这样的代码会隐藏一些运行缓慢的代码片段,因此,与其引入超时和重试,不如先研究一下服务器响应时间过长的原因,以及是否应该从另一个角度来解决这个问题。

空闲资源

如上所述,Espresso 在 UI 层面了解空闲状态,也就是说,只有当前一个 ViewAction 完成并且系统进入空闲状态后,测试中才会启动下一个 ViewAction。但是,如果您有一些协程或 Rx Observable 在后台运行并异步返回结果,我们需要以某种方式通知 Espresso,我们希望等待操作完成,并且在此之后才执行下一个ViewAction/ViewAssertion 。您可以在官方文档中详细了解这一点。这里我仅提供一些在实践中有用的提示。

  1. 你的生产代码不应该知道任何关于 IdlingResource 的信息。你的应用中可能有一些接口

    interface OperationStatus {
    
        fun finished()
    
        fun reset()
    }
    

    并在app中转到这个界面,告知测试操作完成:

    coroutineScope.launch(coroutineDispatcher) {
        viewModel.usersFlow.collect {
            // show UI
    
            operationStatus.finished()
        }
    }
    

    androidTest中,你将拥有此接口的实现,该实现将了解 IdlingResource。相应地,你将能够在 IdlingRegistry 中注册它。

    class OperationStatusIdlingResource : OperationStatus {
    
        val idlingResource = CountingIdlingResource("op-status")
    
        override fun finished() {
            idlingResource.decrement()
        }
    
        override fun reset() {
            idlingResource.increment()
        }
    }
    
    @Test
    fun test_clickRefreshButton_freshItemsAreLoaded() {
    
        val idlingResourceImpl = OperationStatusIdlingResource()
        IdlingRegistry.getInstance().register(idlingResourceImpl.idlingResource)
    
        // Test
    }
    

    假设它只存在于测试中,那么该如何将你的代码传递OperationStatusIdlingResource给应用程序呢?这里,第二个原则将帮助我们。

  2. 使用依赖注入 (DI)。无论您使用 Hilt、Dagger 还是 Koin,您始终都会拥有一个依赖关系树和一个模块列表,用于声明这些依赖关系(在我们的例子中是OperationStatus)。对于生产代码,您需要创建一个默认的虚拟实现,它不会执行任何操作;对于测试,您需要覆盖源依赖项所在的模块,以便 DI 树能够与其协同工作。我将在下文解释如何覆盖 DI 依赖关系。

  3. 不要在特殊情况下使用 IdlingResource。在上面的例子中,我们用它来表示数据在屏幕打开时已加载。这是异步数据上传的一个特例。即使在一个屏幕内,也可能有多个异步操作,为每个操作单独创建 IdlingResource 是多余的。最好能识别出引入并发的地方。例如,如果你的应用基于协程,那么在使用Dispatchers.Default和时就会引入异步性Dispatchers.IO。这意味着,在测试中,你需要用某个测试版本替换这些调度器,并在其中添加 IdlingResource:

    class SimpleViewModel(
        private val usersRepository: UsersRepository,
        private val coroutineScope: LifecycleCoroutineScope,
        private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.Default,
    ) : ViewModel() {
    
        fun loadUsers(filter: FilterType) {
            coroutineScope.launch(coroutineDispatcher) {
                val allUsers = usersRepository.getUsers()
                // ...
            }
        }
    

    我们可以通过 DI 在测试中传递以下 Dispatcher:

    class IdlingDispatcher(
        private val wrappedCoroutineDispatcher: CoroutineDispatcher,
    ) : CoroutineDispatcher() {
    
        val counter: CountingIdlingResource = CountingIdlingResource(
            "IdlingDispatcher for $wrappedCoroutineDispatcher"
        )
    
        override fun dispatch(context: CoroutineContext, block: Runnable) {
            counter.increment()
            val blockWithDecrement = Runnable {
                try {
                    block.run()
                } finally {
                    counter.decrement()
                }
            }
            wrappedCoroutineDispatcher.dispatch(context, blockWithDecrement)
        }
    }
    

在 DI 中使用 Fake 对象

这是另一个应该在测试中使用的实用做法。顺便说一句,如果你的项目中还没有使用 DI,那么你应该开始使用它了。

在上面的示例中,我描述了如何在生产代码中使用 IdlingResource 的伪实现,但尚未讨论如何在测试中引入它们。让我们以 Dagger 为例更详细地介绍一下。

如果您不使用 dagger-android 并且更喜欢手动创建组件,那么您的应用程序将或多或少看起来像这样:

open class MyApplication : Application() {

    private lateinit var appComponent: ApplicationComponent

    override fun onCreate() {
        super.onCreate()

        appComponent = DaggerApplicationComponent
            .builder()
            .usersModule(UsersModule())
            .dataModule(DataModule())
            .build()
    }
}
Enter fullscreen mode Exit fullscreen mode

在中DataModule,我们声明了我们的调度程序,在中UsersModule,我们定义了与相关的逻辑UsersRepository

@Module
open class DataModule {

    @Provides
    @MyIODisptcher
    open fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO
Enter fullscreen mode Exit fullscreen mode

请注意MyApplicationDataModuleprovideIODispatcher被声明open,以便可以在测试中从它们继承。

现在,将模块的创建DataModule转移到单独的方法:

open class MyApplication : Application() {

    private lateinit var appComponent: ApplicationComponent

    override fun onCreate() {
        super.onCreate()

        appComponent = DaggerApplicationComponent
            .builder()
            .usersModule(UsersModule())                 
            .dataModule(createDataModule())
            .build()
    }

    open fun createDataModule() = DataModule()
Enter fullscreen mode Exit fullscreen mode

然后在androidTest文件夹中,创建一个测试类Application,并DataModule在其中重新定义。

class MyTestApplication: MyApplication() {

    override fun createDataModule() = TestDataModule()
}

class TestDataModule {

    override fun provideIODispatcher(): CoroutineDispatcher = IdlingDispatcher()
}
Enter fullscreen mode Exit fullscreen mode

在中provideIODispatcher,我们创建了上面讨论过的我们的一个实例IdlingDispatcher,现在,它将在所有 UI 测试中默认使用。

但这还不够。我们需要注册测试应用,以便它能与测试一起运行。为此,我们需要创建一个自定义的 TestRunner,并在其中传递测试应用的名称。

class MyApplicationTestRunner: AndroidJUnitRunner() {

    override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
        return super.newApplication(cl, MyTestApplication::class.java.name, context)
    }
}
Enter fullscreen mode Exit fullscreen mode

现在,我们在以下位置注册此 TestRunner build.gradle

android {
    namespace 'com.rchugunov.tests'
    compileSdk 33

    defaultConfig {
        applicationId "com.rchugunov.tests"
        minSdk 26
        targetSdk 33
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "com.rchugunov.tests.MyApplicationTestRunner"
     }
Enter fullscreen mode Exit fullscreen mode

这就是我们所需要的。类似于IdlingDispatcher,我们也可以覆盖其他依赖项,用伪副本替换它们。例如,对于 UserRepository,这样的实现可能如下所示:

class FakeUserRepository: UsersRepository {

    var usersToOverride = listOf(
        User(id = "1", userName = "jamie123", age = 10),
        User(id = "2", userName = "christy_a1", age = 34)
    )

    override suspend fun getUsers(): List<User> {
        return usersToOverride
    }
}
Enter fullscreen mode Exit fullscreen mode

现在,当您需要放置自定义用户列表时,您可以FakeUserRepository直接将其注入测试,并设置必须直接返回 ViewModel 的列表usersToOverride。如果您只想测试表示层而不测试数据层,这将非常有用。另一个好处是,由于不会出现服务器请求延迟,测试运行速度会更快。下面,我将介绍如何使用WiremockOkReplay模拟客户端-服务器逻辑。

与 Dagger 类似,您还可以在HiltKoin中提供测试实现。

在编写和使用 UI 测试时,还有什么方法能让你的工作更轻松呢?那就从Robolectric开始吧。

机器人电工

Robolectric是一个相当古老的框架,可以追溯到 arm-v7 模拟器在 x86 机器上运行的时代。由于它运行速度非常慢,开发者们想出了提取 Android AOSP 并将其编译成 jar 文件的想法,然后像在真机上一样对其进行 Espresso 测试。由于这些测试实际上是在本地计算机上运行的(与 JUnit 测试相同),因此它比在模拟器或设备上进行相同的测试要快得多。

Robolectric 使用起来非常简单;你只需要在现有的 Espresso 测试中添加几行代码即可。以下是官方页面上的一个测试示例:

@RunWith(RobolectricTestRunner::class)
class MyActivityTest {

    @Test
    fun clickingButton_shouldChangeMessage() {

        Robolectric.buildActivity(MyActvitiy::class.java).use { controller ->
            controller.setup() // Moves Activity to RESUMED state
            val activity: MyActvitiy = controller.get()
            activity.findViewById(R.id.button).performClick()
            assertEquals((activity.findViewById(R.id.text) as TextView).text, "Robolectric Rocks!")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

使用 Robolectric 有很多优点,但最主要的是测试速度。然而,也存在一些限制:例如,它不能与设备的传感器、系统按钮和位置服务一起使用。另外,不要忘记您只是在处理一个模拟的 Android 实现。实际上,您的代码可能在 Robolectric 环境中运行,但由于某种原因会在模拟器上失败。根据 Jake Wharton 的说法,只有当您确信知道测试代码在后台如何运行时,您最好才使用 Robolectric。我不建议使用 Robolectric 运行涵盖整个流程或用户与 UI 交互的测试。以下是一些如何使用 Robolectric 的示例:

  • 您可以测试应用中使用数据层的各个组件。例如,您可以使用 Room DAO 进行测试。

    1. 将对象插入数据库
    2. 从数据库中获取具有相同 ID 的对象
    3. 检查是否返回了相同的对象。

    使用 Robolectric 编写的测试对于此来说是理想的。

  • 打开深层链接。在这里,您可以启动广播事件并检查具有特定参数集的某个意图是否已打开。

  • 使用文件系统。这是应用的数据层,因此您可以将其与流程的其余部分隔离开来测试。在这种情况下,您可能需要 Context,而 Robolectric 正是提供 Context 的工具。

因此,Robolectric 和 Espresso 可以协同工作,帮助您测试应用的各个组件以及整个屏幕和流程。然而,有些场景它们无法覆盖。例如,当我们需要最小化应用、进入系统设置或授予应用某些运行时权限时。在这种情况下,UI Automator就是您的救星。

UI自动化

Espresso 测试有一个重要特性——它们应该了解正在测试的生产代码。您可以接收某个类对象的引用,或在测试中注入应用程序的虚拟组件。您可以访问应用程序的资源(R.id、R.string 等等)。因此,您可以编写非常灵活的测试,以适应应用程序的逻辑。此外,您还可以更改应用程序的逻辑,使其在测试运行时的工作方式略有不同。

相反,UI Automator 测试会像用户一样看待您的应用。它们可以看到文本字段、按钮,并且可以与 UI 元素交互,但它们不知道应用的内部逻辑和状态。您无法更改应用的逻辑或访问某些资源。然而,使用 UI Automator,您可以执行以下操作:

  • 与系统应用和设置(例如主屏幕、通知和设备设置)交互。例如,您可以这样访问系统通知列表:

    @Test
    @Throws(UiObjectNotFoundException::class)
    fun testNotifications() {
        device.openNotification()
        device.wait(Until.hasObject(By.pkg("com.android.systemui")), 10000)
    
        val notificationStackScroller: UiSelector = UiSelector()
            .packageName("com.android.systemui")
            .resourceId("com.android.systemui:id/notification_stack_scroller")
        val notificationStackScrollerUiObject: UiObject = device.findObject(notificationStackScroller)
        assertTrue(notificationStackScrollerUiObject.exists())
    
        val notiSelectorUiObject: UiObject = notificationStackScrollerUiObject.getChild(UiSelector().index(0))
        assertTrue(notiSelectorUiObject.exists())
        notiSelectorUiObject.click()
    }
    
  • Android UI Automator 可以测试复杂的场景,包括应用间的切换,例如内容交换或使用 Intent。Espresso 只能在一个应用内测试场景,无法处理应用间或 Intent 间的切换。

  • 您可以在运行测试时直接检查或更改系统设置。本文介绍如何在测试中连接 Wi-Fi。

    // BySelector matching the just added Wi-Fi
    val ssidSelector = By.text(ssid).res("android:id/title")
    // BySelector matching the connected status
    val status = By.text("Connected").res("android:id/summary")
    // BySelector matching on entry of Wi-Fi list with the desired SSID and status
    val networkEntrySelector = By.clazz(RelativeLayout::class.qualifiedName)
        .hasChild(ssidSelector)
        .hasChild(status)
    
    // Perform the validation using hasObject
    // Wait up to 5 seconds to find the element we're looking for
    val isConnected = device.wait(Until.hasObject(networkEntrySelector), 5000)
    Assert.assertTrue("Verify if device is connected to added Wi-Fi", isConnected)
    

如您所见,UI Automator、Espresso 和 Robolectric 提供了以独立方式测试应用组件的机会,并检查非常复杂的流程,包括与其他应用和 Android 组件的交互。顺便说一句,您还可以组合测试,将 Espresso 测试与 UI Automator 测试结合起来。

撰写 UI 测试

那么 Compose 呢?为了测试 Compose,有一组特殊的 API可以将可组合项视为单独的节点。它还包含选择器和操作,您可以使用它们查找界面元素并对其执行某些操作。

composeTestRule.onNode(hasTestTag("Players"))
    .onChildren()
    .filter(hasClickAction())
    .assertCountEquals(4)
    .onFirst()
    .assert(hasText("John"))
Enter fullscreen mode Exit fullscreen mode

好消息是,所有这些 API 都与 UI 相关,类似于 Espresso 的 ViewMatchers/ViewActions/ViewAssertions。这意味着您的测试在语法上只会略有不同,但您仍然可以使用 Robot 或 Page Object 模式来解决代码整洁问题。为了同步后台任务和测试,您仍然可以使用 IdlingResource。此外,您可以像我们在 Espresso 示例中所做的那样,替换 DI 树中的各种对象。

此外,您仍然可以使用 Espresso API 来测试您的应用与 Android 框架的集成,例如导航、动画和对话框窗口。

@Test
fun androidViewInteropTest() {
    // Check the initial state of a TextView that depends on a Compose state:
    Espresso.onView(withText("Hello Views")).check(matches(isDisplayed()))
    // Click on the Compose button that changes the state
    composeTestRule.onNodeWithText("Click here").performClick()
    // Check the new value
    Espresso.onView(withText("Hello Compose")).check(matches(isDisplayed()))
}
Enter fullscreen mode Exit fullscreen mode

另外,您还可以找到一些介绍如何在 Robolectric 上运行ComposeUI 测试的文章。我个人没有这样做过,因为我不喜欢在模拟器之外测试 UI 逻辑。

WireMock / MockWebServer

还有什么可以帮助我们编写测试?可以模拟网络请求的框架。我们已经讨论过,通过创建虚假对象并将其传递给 DI 树,我们可以模拟部分业务逻辑,并仅测试高级逻辑(表示层)。然而,在某些情况下,一次性覆盖应用程序所有层的测试仍然有用。这样一来,你可能会遇到一些问题,例如服务器不稳定或所需条件的复杂复制。所有这些都会使你的测试变得不稳定——我们上面已经讨论过这个问题。幸运的是,有些框架允许你模拟客户端-服务器部分。

WiremockMockWebServer提供了类似的功能来替代客户端/服务器交互。我们以 MockWebServer 为例。

在运行每个测试之前,我们必须启动服务器,并在测试完成后停止它。创建自定义测试规则 (TestRule) 可以方便地做到这一点。

class MockWebServerRule : TestRule {

    lateinit val server: MockWebServer

    override fun apply(base: Statement, description: Description): Statement {
        return object : Statement() {
            @Throws(Throwable::class)
            override fun evaluate() {
                val server = MockWebServer()
                server.start(8080)
                try {
                    base.evaluate()
                } finally {
                    server.shutdown()
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

如果您希望请求按照一定的顺序执行,那么您可以在测试中对它们进行排队。

@RunWith(AndroidJUnit4::class)
class MyEspressoTest {

    @get:Rule
    val mockWebServerRule = MockWebServerRule()

    @Test
    fun test_some_action() {
        mockWebServerRule.apply {
            server.enqueue(MockResponse().setBody("..."))
            server.enqueue(MockResponse().setBody("Hello world!"))
            server.enqueue(MockResponse().setResponseCode(401))
        }

        // your test case
    }
}
Enter fullscreen mode Exit fullscreen mode

记得为 Retrofit 提供测试 BaseUrl 127.0.0.1。你可以按照我们上面保存伪造文件时的方法进行UserRepository操作TestApplicationComponent

之后,您可以运行测试,响应您应用的所有请求,您添加到队列中的响应将会返回。请注意,请求的数量和顺序必须与测试中设置的响应数量严格匹配。否则,测试肯定会失败。您还可以使用请求调度器编写更精细的逻辑来处理应用的请求Dispatcher

@RunWith(AndroidJUnit4::class)
class MyEspressoTest {

    @get:Rule
    val mockWebServerRule = MockWebServerRule()

    @Test
    fun test_mock_with_dispatcher() {

        val requests = listOf(MockRule.USERS_REQUEST_FAILED_RESPONSE)

        mockWebServerRule.server.dispatcher = object : Dispatcher() {
            override fun dispatch(request: RecordedRequest): MockResponse {
                return requests.first { it.content.url == request.requestUrl }.content.response
            }
        }

                // YOUR TEST CODE

}

data class MockRuleContent(
    val url: String,
    val response: MockResponse,
)

enum class MockRule(val content: MockRuleContent) {
    USERS_REQUEST_POSITIVE_RESPONSE(MockRuleContent("/users/", MockResponse().setBody("[{\"name\": \"John\"}]"))),
    USERS_REQUEST_FAILED_RESPONSE(MockRuleContent("/users/", MockResponse().setResponseCode(404)))
}
Enter fullscreen mode Exit fullscreen mode

WireMock具有类似的功能,但它比 MockWebServer 更难使用,而且 Android 社​​区的支持也不那么强大。此外,WireMock 还有一个重要特性:您可以以记录模式运行服务器,这样您就可以在在线客户端/服务器交互模式下运行测试。WireMock 可以写入来自服务器的所有响应并将其保存到一个文件中。之后,您将能够使用已经记录的模拟运行相同的测试。MockWebServer 无法做到这一点,但OkReplay非常适合这项任务。

OkReplay

使用 OkReplay,您可以根据真实的服务器请求准备测试存根(类似于 WireMock)。要使用它,您需要OkReplayInterceptor在 Retrofit/OkHttp 测试配置中添加拦截器。然后,使用 Gradle 插件,您可以以将服务的请求和响应记录到 .yaml 文件(称为 Tape)的模式运行测试。此外,OkReplay 还提供了一个 Gradle 插件,其中包含从设备或模拟器中提取已录制磁带以及清理磁带的任务。

./gradlew clearDebugOkReplayTapes - Cleaning tapes
./gradlew pullDebugOkReplayTapes - Pulling tapes from the device or emulator
Enter fullscreen mode Exit fullscreen mode

为了在磁带录制或重放模式下运行测试,您需要将相应的参数TapeMode传递到OkReplay配置中:

private val activityTestRule = ActivityTestRule(MainActivity::class.java)
private val configuration = OkReplayConfig.Builder()
    .tapeRoot(AndroidTapeRoot(InstrumentationRegistry.getInstrumentation().targetContext, javaClass))
    .defaultMode(TapeMode.READ_WRITE) // или TapeMode.READ_ONLY
    .sslEnabled(true)
    .interceptor(okReplayInterceptor)
    .build()

@JvmField
@Rule
val testRule = OkReplayRuleChain(configuration, activityTestRule).get()

@Test
@OkReplay
fun myTest() {
    ...
}
Enter fullscreen mode Exit fullscreen mode

OkReplay 框架简化了 Android 应用的网络请求测试流程,确保测试结果更安全、更可预测。然而,还有一个重要因素:你需要测试能够真实重现应用行为的场景(例如,服务器端的特定错误)。重现这些情况通常非常困难,因此录制磁带存在问题。

开发人员一直在努力解决上述所有问题。你可以在 Github 上找到许多开源库,它们不仅封装了 Espresso API,还有助于解决其中的一些问题,并添加了各种令人愉悦的功能。我将向你介绍其中的两个:BaristaKaspresso

咖啡师

Barista 是 Espresso 的额外抽象层,因此与 Espresso 相比,它具有一些额外的功能。首先,它添加了许多方法,以便更轻松地处理 UI 元素。

例如,原始 Espresso 代码如下:

@Test
fun myTest() {
    onView(withId(R.id.first_name))
        .perform(typeText(FIRST_NAME), ViewActions.closeSoftKeyboard())
    onView(withId(R.id.second_name))
        .perform(typeText(SECOND_NAME), ViewActions.closeSoftKeyboard())
    onView(withId(R.id.save)).check(matches(isEnabled()))
    onView(withId(R.id.save)).perform(click())
        // write your test as usual...
}
Enter fullscreen mode Exit fullscreen mode

我们可以这样写:

@Test
fun myTest() {

    writeTo(R.id.first_name, FIRST_NAME)
    closeKeyboard()

    writeTo(R.id.second_name, SECOND_NAME)
    closeKeyboard()

    assertEnabled(R.id.save);

    clickOn(R.id.save)

    assertDisplayed(FIRST_NAME)
}
Enter fullscreen mode Exit fullscreen mode

不可否认的是,测试的可读性有所提升。缺点是,与常规 Espresso 相比,你需要记住更多不同的 ViewMatcher/ViewAction 和其他元素。不过,你仍然可以使用 Robot 模式来提升测试的表达能力。你可以在这里了解更多可用方法。Barista 还提供了许多便捷的测试规则,例如针对数据库和SharedPreferences清理的规则:

// Clear all app's SharedPreferences
@Rule public ClearPreferencesRule clearPreferencesRule = new ClearPreferencesRule();

// Delete all tables from all the app's SQLite Databases
@Rule public ClearDatabaseRule clearDatabaseRule = new ClearDatabaseRule();

// Delete all files in getFilesDir() and getCacheDir()
@Rule public ClearFilesRule clearFilesRule = new ClearFilesRule();
Enter fullscreen mode Exit fullscreen mode

但是,究竟应该使用它们还是自己编写所有内容,这是一个好问题。我们的应用程序通常非常复杂,无法使用标准工具,因此每个开发人员都倾向于编写自己的自定义逻辑。

卡斯普雷索

另一个由卡巴斯基反病毒软件开发人员创建的 Espresso 包装器。但这个框架提供的功能远多于 Barista。首先,它默认允许你使用页面对象模式编写测试。这是一个不可否认的优势,因为测试看起来更简洁,并且更抽象地脱离了所使用的视图及其 ID。

object SimpleScreen : KScreen<SimpleScreen>() {

    override val layoutId: Int? = R.layout.activity_simple
    override val viewClass: Class<*>? = SimpleActivity::class.java

    val button1 = KButton { withId(R.id.button_1) }

    val button2 = KButton { withId(R.id.button_2) }

    val edit = KEditText { withId(R.id.edit) }
}
Enter fullscreen mode Exit fullscreen mode

Kaspresso 的另一个重要功能是所有 ViewAction 都包含一些超时设置,以应对不稳定的测试,这在我们等待后端响应的情况下非常有用。这可能很方便,但不太可靠,因为设置超时有时并不够。我建议更多地依赖 IdlingResource 和使用 OkReplay 或服务器响应模拟的预定义服务器响应。

此外,Kaspresso 还提供许多其他实用功能,例如在测试中直接运行 adb 提示以及与 Android 系统交互。考虑到所有这些功能都包含在现成的解决方案中,Kaspresso 是传统 Espresso 的绝佳替代品。

结论

和许多其他开发者一样,我在编写 Espresso 测试时遇到了很多困难。这些测试通常很复杂、缓慢且不稳定。然而,现在有很多库、框架和方法可以显著简化和加快 UI 测试的编写过程。如果我现在开始开发一个新应用,我会立即使用 Kaspresso 编写 UI 测试。IdlingResource 对于同步后台任务和测试本身至关重要。如果可能,请使用存储库的模拟实现或使用 OkReplay 记录请求和响应。使用页面对象和 Robot 模式来保持测试的整洁。如果您遵循这些建议,您将能够显著提高测试质量并减少 Android 应用代码中的错误数量。

鏂囩珷鏉ユ簮锛�https://dev.to/rchugunov/best-practices-of-ui-testing-for-android-5756
PREV
在多个应用程序中重复使用 Gradle 模块 在多个应用程序中重复使用 Gradle 模块
NEXT
使用 Mockk、Kotest 和其他工具对 Android 应用进行单元测试的最佳实践 JUnit TDD 与 BDD Spek Kotest 结论