测试 Cupcake 应用

使用集合让一切井井有条 根据您的偏好保存内容并对其进行分类。

1. 简介

使用 Compose 实现多屏幕导航 Codelab 中,您学习了如何使用 Jetpack Navigation Compose 组件为 Compose 应用添加导航功能。

Cupcake 应用有多个屏幕可供导航,并且用户可以执行各种操作。该应用为您提供了提升自动测试技能的绝佳机会!在此 Codelab 中,您将为 Cupcake 应用编写一些界面测试,并了解如何尽可能提高测试覆盖率。

前提条件

学习内容

  • 使用 Compose 测试 Jetpack Navigation 组件。
  • 为每个界面测试创建一致的界面状态。
  • 为测试创建辅助函数。

构建内容

  • 针对 Cupcake 应用的界面测试

所需条件

  • 最新版本的 Android Studio
  • 互联网连接,用于下载起始代码

2. 下载起始代码

  1. 在 Android Studio 中,打开 basic-android-kotlin-compose-training-cupcake 文件夹。
  2. 在 Android Studio 中打开 Cupcake 应用代码。

3.设置 Cupcake 以便运行界面测试

添加 androidTest 依赖项

您可以使用 Gradle 构建工具为特定模块添加依赖项。此功能可防止对依赖项进行不必要的编译。在项目中添加依赖项时,您已经熟悉了 implementation 配置。您已经使用此关键字在应用模块的 build.gradle 文件中导入依赖项。使用 implementation 关键字可将该依赖项提供给该模块中的所有源代码集使用;在本课程的这一阶段,您已经具备处理 maintestandroidTest 源代码集的经验。

界面测试位于各自的源代码集中,名称为 androidTest。一些依赖项仅需要用于此模块,对于这种情况,不需要为其他模块(例如包含应用代码的 main 模块)编译这些依赖项。在添加仅供界面测试使用的依赖项时,请使用 androidTestImplementation 关键字在应用模块的 build.gradle 文件中声明依赖项。这样做可确保仅在您运行界面测试时才会编译界面测试依赖项。

如需添加编写界面测试所需的依赖项,请完成以下步骤:

  1. 打开 app/build.gradle 文件。
  2. 将以下依赖项添加到该文件的依赖项部分:
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0'
androidTestImplementation "androidx.navigation:navigation-testing:2.5.0"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"

创建界面测试目录

  1. 右键点击项目视图中的 src 目录,然后依次选择 New > Directory

288ebc5eae5fba2e.png

  1. 选择 androidTest/java 选项。

12ec57f9d9e907de.png

创建测试软件包

  1. 右键点击项目窗口中的 androidTest/java 目录,然后依次选择 New > Package

5104da51d0529eb7.png

  1. 将软件包命名为 com.example.cupcake.testda0e0458fdcb1cfc.png

创建导航测试类

test 目录中,创建一个名称为 CupcakeScreenNavigationTest 的新 Kotlin 类。

7336dbed4e215b68.png

4. 设置导航宿主

在前面的 Codelab 中,您已经了解到 Compose 中的界面测试需要使用 Compose 测试规则。测试 Jetpack Navigation 也是一样。不过,测试导航需要通过 Compose 测试规则进行一些额外的设置。

在测试 Compose Navigation 时,您无权访问在应用代码中能够访问的相同 NavHostController。不过,您可以使用 TestNavHostController 并为此导航控制器配置测试规则。在这一部分中,您将了解如何为导航测试配置和重复使用测试规则。

  1. CupcakeScreenNavigationTest.kt 中,使用 createAndroidComposeRule 创建一条测试规则,并将 ComponentActivity 作为类型参数传递。
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()

为了确保应用导航到正确的位置,您需要在应用执行导航操作时引用 TestNavHostController 实例来检查导航宿主的导航路线。

  1. TestNavHostController 实例实例化为 lateinit 变量。在 Kotlin 中,lateinit 关键字用于声明可在声明对象后初始化的属性。
private lateinit var navController: TestNavHostController

接下来,指定要用于界面测试的可组合项。

  1. 创建一个名称为 setupCupcakeNavHost() 的方法。
  2. setupCupcakeNavHost() 方法中,对您创建的 Compose 测试规则调用 setContent() 方法。
  3. 在传递给 setContent() 方法的 lambda 内,调用 CupcakeApp() 可组合项。
fun setupCupcakeNavHost() {
   composeTestRule.setContent {
       CupcakeApp()
   }
}

现在,您需要在测试类中创建 TestNavHostContoller 对象。您稍后将使用此对象确定导航状态,因为应用使用控制器在 Cupcake 应用中的各个屏幕之间导航。

  1. 使用您之前创建的 lambda 设置导航宿主。初始化您创建的 navController 变量,注册导航器,然后将该 TestNavHostController 传递给 CupcakeApp 可组合项。
fun setupCupcakeNavHost() {
   composeTestRule.setContent {
       navController =
           TestNavHostController(LocalContext.current)
       navController.navigatorProvider.addNavigator(
           ComposeNavigator()
       )
       CupcakeApp(navController = navController)
   }
}

CupcakeScreenNavigationTest 类中的每一项测试均涉及测试导航的一个方面。因此,每一项测试都取决于您创建的 TestNavHostController 对象。您可以使用 junit 库提供的 @Before 注解来自动设置导航控制器,而不必针对每个测试手动调用 setupCupcakeNavHost() 函数。如果某个方法带有 @Before 注解,它会在每个带有 @Test 注解的方法之前运行。

  1. 将 @Test 注解添加到 setupCupcakeNavHost() 方法中。
@Before
fun setupCupcakeNavHost() {
   composeTestRule.setContent {
       navController =
           TestNavHostController(LocalContext.current)
       navController.navigatorProvider.addNavigator(
           ComposeNavigator()
       )
       CupcakeApp(navController = navController)
   }
}

5. 编写导航测试

验证起始目标页面

回想之前,在构建 Cupcake 应用时,您创建了一个名称为 Cupcakeenum 类,其中包含用于指定应用导航的常量。

CupcakeScreen.kt

/**
* enum values that represent the screens in the app
*/
enum class CupcakeScreen(@StringRes val title: Int) {
   Start(title = R.string.app_name),
   Flavor(title = R.string.choose_flavor),
   Pickup(title = R.string.choose_pickup_date),
   Summary(title = R.string.order_summary)
}

所有具有界面的应用都有一个某种类型的主屏幕。对于 Cupcake,该屏幕是 Start Order 屏幕CupcakeApp 可组合项中的导航控制器使用 CupcakeScreen 枚举的 Start 项来确定何时导航至此屏幕。当应用启动时,如果尚不存在目标页面路线,则导航宿主目标页面路线将设置为 Cupcake.Start.name

您首先需要编写一项测试,用于验证 Start Order 屏幕是否为应用启动时的当前目标页面路线。

  1. 创建一个名称为 cupcakeNavHost_verifyStartDestination() 的函数,并为其添加 @Test 注解。
@Test
fun cupcakeNavHost_verifyStartDestination() {
}

您现在必须确认导航控制器的初始目标页面路线是 Start Order 屏幕。

  1. 断言预期路线名称(在本例中为 Cupcake.Start.name)等于导航控制器当前返回堆栈条目的目标页面路线。
import org.junit.Assert.assertEquals
...

@Test
fun cupcakeNavHost_verifyStartDestination() {assertEquals(Cupcake.Start.name, currentBackStackEntry?.destination?.route)
}

创建辅助方法

界面测试通常需要重复执行某些步骤,确保界面处于可以测试界面特定部分的状态。自定义界面可能还需要一些复杂断言,这些复杂断言需要多行代码。您在上一部分中编写的断言需要大量代码,并且您在 Cupcake 应用中测试导航时需要多次使用该断言。在这些情况下,在测试中编写辅助方法可以避免编写重复代码。

对于您创建的每一项导航测试,您可以使用 CupcakeScreen 枚举项的 name 属性来检查导航控制器的当前目标页面路线是否正确。您可以编写一个辅助函数,以便在需要此类断言时调用。

如需创建此辅助函数,请完成以下步骤:

  1. test 目录中创建一个名称为 ScreenAssertions 的空 Kotlin 文件。

ac62e5b9b8153027.png

  1. 为名称为 assertCurrentRouteName()NavController 类添加一个扩展函数,并在方法签名中传递预期路线名称的字符串。
fun NavController.assertCurrentRouteName(expectedRouteName: String) {

}
  1. 在此函数中,断言 expectedRouteName 等于导航控制器的当前返回堆栈条目的目标页面路线。
import org.junit.Assert.assertEquals
...

fun NavController.assertCurrentRouteName(expectedRouteName: String) {
   assertEquals(expectedRouteName, currentBackStackEntry?.destination?.route)
}
  1. 打开 CupcakeScreenNavigationTest 文件,并修改 cupcakeNavHost_verifyStartDestination() 函数以使用新的扩展函数,而不是冗长的断言。
@Test
fun cupcakeNavHost_verifyStartDestination() {
   navController.assertCurrentRouteName(CupcakeScreen.Start.name)
}

验证 Start 屏幕没有向上按钮

Cupcake 应用的原始设计在 Start 屏幕的工具栏中没有向上按钮。

e6d3d87788ba56c8.png

Start 屏幕缺少一个向上按钮,因为这是 Start 屏幕,也就无法从此屏幕向上导航。请按照以下步骤创建一个函数来确认 Start 屏幕没有向上按钮:

  1. 创建一个名称为 cupcakeNavHost_verifyBackNavigationNotShownOnStartOrderScreen() 的方法,并为其添加 @Test 注解。
@Test
fun cupcakeNavHost_verifyBackNavigationNotShownOnStartOrderScreen() {
}

在 Cupcake 中,向上按钮的内容说明设置为 R.string.back_button 资源中的字符串。

  1. 在测试函数中使用 R.string.back_button 资源的值创建一个变量。
@Test
fun cupcakeNavHost_verifyBackNavigationNotShownOnStartOrderScreen() {
   val backText = composeTestRule.activity.getString(R.string.back_button)
}
  1. 断言屏幕上没有包含此内容说明的节点。
@Test
fun cupcakeNavHost_verifyBackNavigationNotShownOnStartOrderScreen() {
   val backText = composeTestRule.activity.getString(R.string.back_button)
   composeTestRule.onNodeWithContentDescription(backText).assertDoesNotExist()
}

验证导航到 Flavor 屏幕

点击 Start 屏幕中的任一按钮会触发一个方法,该方法会指示导航控制器导航到 Flavor 屏幕。

fb8f61896bfa473c.png

在此测试中,您将编写一个命令,用于点击按钮以触发此导航,并验证目标页面路线是否为 Flavor 屏幕。

  1. 创建一个名称为 cupcakeNavHost_clickOneCupcake_navigatesToSelectFlavorScreen() 的函数,并为其添加 @Test 注解。
@Test
fun cupcakeNavHost_clickOneCupcake_navigatesToSelectFlavorScreen(){
}
  1. 按字符串资源 ID 找到 One Cupcake 按钮,然后对其执行点击操作。
@Test
fun cupcakeNavHost_clickOneCupcake_navigatesToSelectFlavorScreen() {
   composeTestRule.onNodeWithStringId(R.string.one_cupcake)
       .performClick()
}
  1. 断言当前路线名称是 Flavor 屏幕名称。
@Test
fun cupcakeNavHost_clickOneCupcake_navigatesToSelectFlavorScreen() {
   composeTestRule.onNodeWithStringId(R.string.one_cupcake)
       .performClick()
   navController.assertCurrentRouteName(CupcakeScreen.Flavor.name)
}

编写更多辅助方法

Cupcake 应用的导航流程大体呈线性。如果不点击 Cancel 按钮,那么您就只能向一个方向导航应用。因此,当您测试应用内更加深层的屏幕时,您会发现自己要重复编写代码以前往要测试的区域。在这种情况下,有必要使用更多的辅助方法,这样您就只需编写一次代码。

现在,您已经测试了导航至 Flavor 屏幕的操作,接下来创建一个导航至 Flavor 屏幕的方法,这样在后续测试中就不必重复编写该代码。

  1. 创建一个名称为 navigateToFlavorScreen() 的方法。
private fun navigateToFlavorScreen() {
}
  1. 按照上一部分中的步骤,编写一个命令来查找 One Cupcake 按钮,并对其执行点击操作。
private fun navigateToFlavorScreen() {
   composeTestRule.onNodeWithStringId(R.string.one_cupcake)
       .performClick()
}

回想一下,在选择口味之前,Flavor 屏幕上的 Next 按钮不可点击。此方法仅用于让界面为导航做好准备。调用此方法后,界面应处于 Next 按钮可点击的状态。

  1. 在界面中找到包含 R.string.chocolate 字符串的节点,并对其执行点击操作以将其选中。
private fun navigateToFlavorScreen() {
   composeTestRule.onNodeWithStringId(R.string.one_cupcake)
       .performClick()
   composeTestRule.onNodeWithStringId(R.string.chocolate)
       .performClick()
}

看看您能否编写一些辅助方法,用于导航到 Pickup 屏幕和 Summary 屏幕。在查看解决方案之前,请自行尝试本练习。

private fun navigateToPickupScreen() {
   navigateToFlavorScreen()
   composeTestRule.onNodeWithStringId(R.string.next)
       .performClick()
}

private fun navigateToSummaryScreen() {
   navigateToPickupScreen()
   composeTestRule.onNodeWithText(getFormattedDate())
       .performClick()
   composeTestRule.onNodeWithStringId(R.string.next)
       .performClick()
}

在测试 Flavor 屏幕以外的屏幕时,您需要测试向上按钮功能,以确保该按钮可以让用户返回上一个屏幕。不妨考虑创建一个辅助函数来查找并点击向上按钮。

private fun performNavigateUp() {
   val backText = composeTestRule.activity.getString(R.string.back_button)
   composeTestRule.onNodeWithContentDescription(backText).performClick()
}

尽可能扩大测试覆盖范围

应用的测试套件应测试尽可能多的应用功能。在理想情况下,界面测试套件可以 100% 完全覆盖界面功能。在实践中,这种测试覆盖率很难实现,因为应用外部的许多因素都会影响界面,例如设备具有独特的屏幕尺寸、不同版本的 Android 操作系统,以及可能会影响手机上的其他应用的第三方应用。

尽可能扩大测试覆盖范围的一种方法是在添加功能的同时编写测试。这样一来,您就不必太过深度地开发新功能,然后再回来反复查找所有可能的情形。到目前为止,Cupcake 是一个相当小的应用,您已经测试了应用导航的很大一部分!不过,该应用还有更多其他导航状态有待测试。

看看您能否编写测试来验证以下导航状态。在查看解决方案之前,请尝试自行实现它们。

  • 点击 Start 屏幕中的“向上”按钮以导航到 Flavor 屏幕
  • 点击 Flavor 屏幕中的 Cancel 按钮以导航到 Start 屏幕
  • 导航到 Pickup 屏幕
  • 点击 Pickup 屏幕上的“向上”按钮以导航到 Flavor 屏幕
  • 点击 Pickup 屏幕上的 Cancel 按钮以导航到 Start 屏幕
  • 导航到 Summary 屏幕
  • 点击 Summary 屏幕上的 Cancel 按钮以导航到 Start 屏幕
@Test
fun cupcakeNavHost_clickNextOnFlavorScreen_navigatesToPickupScreen() {
   navigateToFlavorScreen()
   composeTestRule.onNodeWithStringId(R.string.next)
       .performClick()
   navController.assertCurrentRouteName(CupcakeScreen.Pickup.name)
}

@Test
fun cupcakeNavHost_clickBackOnFlavorScreen_navigatesToStartOrderScreen() {
   navigateToFlavorScreen()
   performNavigateUp()
   navController.assertCurrentRouteName(CupcakeScreen.Start.name)
}

@Test
fun cupcakeNavHost_clickCancelOnFlavorScreen_navigatesToStartOrderScreen() {
   navigateToFlavorScreen()
   composeTestRule.onNodeWithStringId(R.string.cancel)
       .performClick()
   navController.assertCurrentRouteName(CupcakeScreen.Start.name)
}

@Test
fun cupcakeNavHost_clickNextOnPickupScreen_navigatesToSummaryScreen() {
   navigateToPickupScreen()
   composeTestRule.onNodeWithText(getFormattedDate())
       .performClick()
   composeTestRule.onNodeWithStringId(R.string.next)
       .performClick()
   navController.assertCurrentRouteName(CupcakeScreen.Summary.name)
}

@Test
fun cupcakeNavHost_clickBackOnPickupScreen_navigatesToFlavorScreen() {
   navigateToPickupScreen()
   performNavigateUp()
   navController.assertCurrentRouteName(CupcakeScreen.Flavor.name)
}

@Test
fun cupcakeNavHost_clickCancelOnPickupScreen_navigatesToStartOrderScreen() {
   navigateToPickupScreen()
   composeTestRule.onNodeWithStringId(R.string.cancel)
       .performClick()
   navController.assertCurrentRouteName(CupcakeScreen.Start.name)
}

@Test
fun cupcakeNavHost_clickCancelOnSummaryScreen_navigatesToStartOrderScreen() {
   navigateToSummaryScreen()
   composeTestRule.onNodeWithStringId(R.string.cancel)
       .performClick()
   navController.assertCurrentRouteName(CupcakeScreen.Start.name)
}

6. 为 Order 屏幕编写测试

导航只是 Cupcake 应用功能的一个方面。用户还会与应用的各个屏幕互动。您需要验证这些屏幕上显示的内容以及在这些屏幕上执行的操作是否会产生正确的结果。SelectOptionScreen 是应用的一个重要部分。

在本部分中,您将编写一项测试来验证此屏幕上的内容是否已正确设置。

测试 Choose Flavor 屏幕内容

  1. app/src/androidTest 目录中创建一个名称为 CupcakeOrderScreenTest 的新类,其中包含其他测试文件。

b8d85fba1fabedef.png

  1. 在此类中,创建一个 AndroidComposeTestRule
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
  1. 创建一个名称为 selectOptionScreen_verifyContent() 的函数,并为其添加 @Test 注解。
@Test
fun selectOptionScreen_verifyContent() {

}

在此函数中,您最终会将 Compose 规则内容设置为 SelectOptionScreen。这样做可以确保 SelectOptionScreen 可组合项会直接启动,因此无需进行导航。不过,该屏幕需要两个参数:小计和口味选项列表。

  1. 创建要传递给屏幕的口味列表和小计。
@Test
fun selectOptionScreen_verifyContent() {
   // Given list of options
   val flavours = listOf("Vanilla", "Chocolate", "Hazelnut", "Cookie", "Mango")
   // And sub total
   val subTotal = "$100"
}
  1. 使用您刚才创建的值,将内容设置为包含 CupcakeThemeSelectOptionScreen 可组合项。

请注意,此方法类似于从 MainActivity 启动可组合项。唯一的区别在于,MainActivity 会调用 CupcakeApp 可组合项,而此处会调用 SelectOptionScreen 可组合项。通过更改从 setContent() 启动的可组合项,您可以启动特定的可组合项,而不是让测试明确地逐步运行应用以前往您要测试的区域。此方法有助于防止测试在与当前测试无关的代码中失败。

@Test
fun selectOptionScreen_verifyContent() {
   // Given list of options
   val flavours = listOf("Vanilla", "Chocolate", "Hazelnut", "Cookie", "Mango")
   // And sub total
   val subTotal = "$100"

   // When SelectOptionScreen is loaded
   composeTestRule.setContent {
       CupcakeTheme {
           SelectOptionScreen(subtotal = subTotal, options = flavours)
       }
   }
}

在测试的当前阶段,应用会启动 SelectOptionScreen 可组合项,然后您可以通过测试说明与其进行交互。

  1. 遍历 flavours 列表并确保列表中的每个字符串项均显示在屏幕上。
  2. 使用 onNodeWithText() 方法查找屏幕上的文本,并使用 assertIsDisplayed() 方法验证文本已显示在应用中。
@Test
fun selectOptionScreen_verifyContent() {
   // Given list of options
   val flavours = listOf("Vanilla", "Chocolate", "Hazelnut", "Cookie", "Mango")
   // And sub total
   val subTotal = "$100"

   // When SelectOptionScreen is loaded
   composeTestRule.setContent {
       CupcakeTheme {
           SelectOptionScreen(subtotal = subTotal, options = flavours)
       }
   }

   // Then all the options are displayed on the screen.
   flavours.forEach { flavour ->
       composeTestRule.onNodeWithText(flavour).assertIsDisplayed()
   }
}
  1. 使用相同的方法来验证应用是否会显示文本,验证应用是否会在屏幕上显示正确的小计字符串。在屏幕中搜索 R.string.subtotal 资源 ID 和正确的小计值,然后断言应用显示该值。
@Test
fun selectOptionScreen_verifyContent() {
   // Given list of options
   val flavours = listOf("Vanilla", "Chocolate", "Hazelnut", "Cookie", "Mango")
   // And sub total
   val subTotal = "$100"

   // When SelectOptionScreen is loaded
   composeTestRule.setContent {
       CupcakeTheme {
           SelectOptionScreen(subtotal = subTotal, options = flavours)
       }
   }

   // Then all the options are displayed on the screen.
   flavours.forEach { flavour ->
       composeTestRule.onNodeWithText(flavour).assertIsDisplayed()
   }
   // And then the subtotal is displayed correctly.
   composeTestRule.onNodeWithText(
      composeTestRule.activity.getString(
          R.string.subtotal_price,
          subTotal
      )
   ).assertIsDisplayed()
}

回想一下,在选择屏幕中的项之前,Next 按钮不会启用。此测试仅验证屏幕内容,因此要测试的最后一项内容就 Next 按钮处于停用状态。

  1. 采用相同的方法,通过字符串资源 ID 查找节点,找到 Next 按钮。不过,请不要验证应用是否显示节点,而是使用 assertIsNotEnabled() 方法。
@Test
fun selectOptionScreen_verifyContent() {
   // Given list of options
   val flavours = listOf("Vanilla", "Chocolate", "Hazelnut", "Cookie", "Mango")
   // And sub total
   val subTotal = "$100"

   // When SelectOptionScreen is loaded
   composeTestRule.setContent {
       CupcakeTheme {
           SelectOptionScreen(subtotal = subTotal, options = flavours)
       }
   }

   // Then all the options are displayed on the screen.
   flavours.forEach { flavour ->
       composeTestRule.onNodeWithText(flavour).assertIsDisplayed()
   }
   // And then the subtotal is displayed correctly.
   composeTestRule.onNodeWithText(
      composeTestRule.activity.getString(
          R.string.subtotal_price,
          subTotal
      )
   ).assertIsDisplayed()
   // And then the next button is disabled
composeTestRule.onNodeWithText(getString(R.string.next)).assertIsNotEnabled()
}

尽可能扩大测试覆盖范围

Choose Flavor 屏幕内容测试仅测试单个屏幕的一个方面。您还可以编写一些额外的测试来增加代码覆盖率。在下载解决方案代码之前,请尝试自行编写以下测试。

  • 验证 Start 屏幕内容。
  • 验证 Summary 屏幕内容。
  • 验证在 Choose Flavor 屏幕上选择某个选项时,Next 按钮处于启用状态。

在编写测试的过程中,请注意任何有助于减少代码编写量的辅助函数!

7. 解决方案代码

8. 总结

恭喜!您已经学习了如何测试 Jetpack Navigation 组件。您还学习了编写界面测试的一些基本技能,例如编写可重复使用的辅助方法、如何利用 setContent() 编写简洁的测试、如何使用 @Before 注解设置测试以及如何尽可能扩大测试覆盖率。在继续构建 Android 应用的过程中,请记住要在编写功能代码的同时编写测试!