Android Compose应用Instrumented UI测试崩溃问题求助
小费计算器Instrumented UI测试断言失败问题排查
问题描述
我正在跟随Android Studio文档学习Kotlin与Jetpack Compose,在Unit 2 Pathway 3环节为小费计算器App编写本地测试与Instrumented测试。手动测试时App运行正常,但执行Instrumented UI测试时触发断言错误,应用崩溃。
错误日志
java.lang.AssertionError: Failed: assertExists. Reason: Expected exactly '1' node but could not find any node that satisfies: (Text + EditableText contains 'Tip Amount: $2.00' (ignoreCase: false)) at androidx.compose.ui.test.SemanticsNodeInteraction.fetchOneOrDie(SemanticsNodeInteraction.kt:162) at androidx.compose.ui.test.SemanticsNodeInteraction.assertExists(SemanticsNodeInteraction.kt:137) at androidx.compose.ui.test.SemanticsNodeInteraction.assertExists$default(SemanticsNodeInteraction.kt:136) at com.example.tipcalculator.TipCalculatorInstrumentedTestUI.calculate_20_percent_tip(TipCalculatorInstrumentedTestUI.kt:46)
相关代码
1. Instrumented测试代码
package com.example.tipcalculator import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performTextInput import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 import com.example.tipcalculator.ui.theme.TipCalculatorTheme import org.junit.Test import org.junit.Rule import org.junit.runner.RunWith import org.junit.Assert.* /** * Instrumented test, which will execute on an Android device. * * See [testing documentation](http://d.android.com/tools/testing). */ @RunWith(AndroidJUnit4::class) class TipCalculatorInstrumentedTestUI { @get: Rule val composeTestRule = createComposeRule() // Compiler knows @Test in androidTest refers to Instrumented Tests, while in Test Directory refers to Local Tests @Test fun calculate_20_percent_tip() { // Set the UI Content, Code looks similar to Main Activity SetContent where we render the Screen and App composeTestRule.setContent { TipCalculatorTheme { TipCalculatorScreen() } } // Accessing the UI Component as a Node to access its particular text with onNodeWithText() method to access TextField Composable composeTestRule.onNodeWithText("Bill Amount") .performTextInput("10") // Pass in the Value of the Text that we wants to populate it with // Apply Same Approach for Tip Percentage composeTestRule.onNodeWithText("Tip (%)") .performTextInput("20") // Use Assertion to ensure that the Text Composable reflects the accurate Tip to be given composeTestRule.onNodeWithText("Tip Amount: $2.00").assertExists() } }
2. 应用代码
package com.example.tipcalculator import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.tipcalculator.ui.theme.TipCalculatorTheme import java.text.NumberFormat import kotlin.math.round class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { TipCalculatorTheme { // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background ) { TipCalculatorScreen() } } } } } @Composable fun TipCalculatorScreen() { // Mutable State that receives 0 as a parameter wrapped in a State Object, making its value observable var amountInput by remember { // Importing remember setter and getter functions allows us to read and set amountInput mutableStateOf("") } // Mutable State for Tip var tipInput by remember { mutableStateOf("") } // Variable to remember State of the Switch var roundUp by remember { mutableStateOf(false) } // Interface to Control Focus in Compose val focusManager = LocalFocusManager.current // Convert to Double or a Null. If Null, return 0 after the Elvis Operator val amount = amountInput.toDoubleOrNull() ?: 0.0 val tipPercentage = tipInput.toDoubleOrNull() ?: 0.0 // Calculate Tip val tip = CalculateTip(amount = amount, tipPercent = tipPercentage, roundUp = roundUp) Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .padding(32.dp) ) { // Screen Title Text( text = stringResource(id = R.string.calculate_tip), fontSize = 24.sp, modifier = Modifier .align(Alignment.CenterHorizontally) ) Spacer(modifier = Modifier.height(16.dp)) // Text Field for User // Pass the Hoisted State back into the Child Function EditNumberField( value = amountInput, onValueChange = { amountInput = it }, label = R.string.bill_amount, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Next, keyboardType = KeyboardType.Number ), keyboardActions = KeyboardActions( // Move Focus downwards to the next composable when the Next Button is clicked onNext = { focusManager.moveFocus(FocusDirection.Down) } ) ) // Input Field for Tip Percentage EditNumberField( label = R.string.how_was_the_service, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Done ), keyboardActions = KeyboardActions( // Closes the Keyboard when Done is pressed onDone = { focusManager.clearFocus() } ), value = tipInput, onValueChange = { tipInput = it } ) // Rounding Function RoundTipRow( // Setting Initial State roundUp = roundUp, // Updating the State when the Switch is clicked onRoundUpChanged = { roundUp = it } ) Spacer(modifier = Modifier.height(24.dp)) // Display the Tip Amount to be given Text( // Can use tip to sub into placeholder as the String has a %s placeholder text = stringResource(id = R.string.tip_amount, tip), fontSize = 20.sp, fontWeight = FontWeight.Bold, modifier = Modifier .align(Alignment.CenterHorizontally) ) } } @Composable fun EditNumberField( // Hoist the State by introducing 2 Parameters @StringRes label: Int, // To indicate that it is meant to be a String Resource keyboardOptions: KeyboardOptions, keyboardActions: KeyboardActions, value: String, onValueChange: (String) -> Unit, // Takes a string as input but has no output modifier: Modifier = Modifier ) { TextField( value = value, // Set to Empty String; Since TextBox that displays the Value onValueChange = onValueChange, // Set to Empty Lambda Function; Callback that is triggered when User enters text label = { Text(text = stringResource(label))}, // Using Label instead of Hardcoding modifier = Modifier .fillMaxWidth(), singleLine = true, // Ensures text box is a single horizontal textbox that is scrollable keyboardOptions = keyboardOptions, // Changing the look of the keyboard keyboardActions = keyboardActions // Functionality for the Action Buttons i.e. Next/Done ) } // Rounding Tip Switch Function @Composable private fun RoundTipRow( modifier: Modifier = Modifier, // Allowing us to hoist the state of the switch roundUp: Boolean, onRoundUpChanged: (Boolean) -> Unit ) { Row( modifier = Modifier .fillMaxWidth() .size(48.dp), verticalAlignment = Alignment.CenterVertically ) { // Text for Rounding Tip Text(text = stringResource(id = R.string.round_up_tip)) Switch( // Determines whether the Switch is Checked, i.e. the Current State checked = roundUp, // Callback called when the Switch is clicked onCheckedChange = onRoundUpChanged, modifier = Modifier .fillMaxWidth() .wrapContentWidth(Alignment.End), colors = SwitchDefaults.colors( // Bad Practice since we are hardcoding the color and will be affected if Dark Mode is implemented for example uncheckedThumbColor = Color.DarkGray ) ) } } // Calculate Tip; Cannot be Private or the Local Tests will not have access to them @VisibleForTesting // Makes the Function Public but only for Testing purposes internal fun CalculateTip( amount: Double, tipPercent: Double = 15.0, roundUp: Boolean ): String { var tip = tipPercent / 100 * amount if (roundUp == true) { // Rounding Up tip = kotlin.math.ceil(tip) } // After calculating the tip, format and display the tip with the Number Class return NumberFormat.getCurrencyInstance().format(tip) } @Preview(showBackground = true) @Composable fun DefaultPreview() { TipCalculatorTheme { TipCalculatorScreen() } }
问题原因及修复方案
问题原因
- TextField标签不匹配:测试代码中用
onNodeWithText("Tip (%)")查找小费百分比输入框,但实际应用代码中该输入框的标签是R.string.how_was_the_service,并非"Tip (%)"。这导致测试无法定位到正确的输入框,无法输入20这个值,最终计算出的小费不是预期的$2.00。 - UI状态同步延迟:即使输入正确,Compose UI状态更新可能存在短暂延迟,直接断言可能赶不上状态刷新。
修复方案
方案一:修正TextField查找文本
将测试代码中查找小费百分比输入框的代码,替换为R.string.how_was_the_service对应的实际字符串(比如"How was the service?"):
composeTestRule.onNodeWithText("How was the service?") .performTextInput("20")
方案二:使用测试标签(更可靠)
为TextField添加测试标签,避免依赖字符串内容,适配多语言场景:
- 修改
EditNumberField组件,添加测试标签参数:
@Composable fun EditNumberField( @StringRes label: Int, keyboardOptions: KeyboardOptions, keyboardActions: KeyboardActions, value: String, onValueChange: (String) -> Unit, testTag: String, // 添加测试标签参数 modifier: Modifier = Modifier ) { TextField( value = value, onValueChange = onValueChange, label = { Text(text = stringResource(label))}, modifier = modifier .fillMaxWidth() .testTag(testTag), // 设置测试标签 singleLine = true, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions ) }
- 在
TipCalculatorScreen中调用时传入测试标签:
// 账单输入框 EditNumberField( value = amountInput, onValueChange = { amountInput = it }, label = R.string.bill_amount, testTag = "bill_amount_input", // 添加标签 keyboardOptions = KeyboardOptions( imeAction = ImeAction.Next, keyboardType = KeyboardType.Number ), keyboardActions = KeyboardActions( onNext = { focusManager.moveFocus(FocusDirection.Down) } ) ) // 小费百分比输入框 EditNumberField( label = R.string.how_was_the_service, testTag = "tip_percent_input", // 添加标签 keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Done ), keyboardActions = KeyboardActions( onDone = { focusManager.clearFocus() } ), value = tipInput, onValueChange = { tipInput = it } )
- 修改测试代码,用
onNodeWithTag查找输入框:
composeTestRule.onNodeWithTag("bill_amount_input") .performTextInput("10") composeTestRule.onNodeWithTag("tip_percent_input") .performTextInput("20")
方案三:添加状态等待(可选)
如果UI状态更新有延迟,可在断言前添加等待,确保状态同步:
// 等待UI状态更新完成 composeTestRule.waitForIdle() composeTestRule.onNodeWithText("Tip Amount: $2.00").assertExists()
内容的提问来源于stack exchange,提问作者Michelin_Boi
相关产品推荐
相关产品推荐

