You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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()
    }
}

问题原因及修复方案

问题原因

  1. TextField标签不匹配:测试代码中用onNodeWithText("Tip (%)")查找小费百分比输入框,但实际应用代码中该输入框的标签是R.string.how_was_the_service,并非"Tip (%)"。这导致测试无法定位到正确的输入框,无法输入20这个值,最终计算出的小费不是预期的$2.00。
  2. UI状态同步延迟:即使输入正确,Compose UI状态更新可能存在短暂延迟,直接断言可能赶不上状态刷新。

修复方案

方案一:修正TextField查找文本

将测试代码中查找小费百分比输入框的代码,替换为R.string.how_was_the_service对应的实际字符串(比如"How was the service?"):

composeTestRule.onNodeWithText("How was the service?")
    .performTextInput("20")

方案二:使用测试标签(更可靠)

为TextField添加测试标签,避免依赖字符串内容,适配多语言场景:

  1. 修改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
    )
}
  1. 在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 }
)
  1. 修改测试代码,用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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.09 07:30:39