mcpField
A
asserting-bounds-and-dimensions

asserting-bounds-and-dimensions

github.com/skydoves测试与质量33961 次安装22 周前更新
代码仓库

关于 asserting-bounds-and-dimensions

面向 Compose UI 测试的布局尺寸与位置断言技能,适用于验证组件尺寸、间距、对齐和触控区域。它使用 dp 类型化断言及未裁剪边界计算布局关系,并说明可视边界与默认容差的区别。

主要功能

  1. 01以 dp 断言尺寸和位置
  2. 02读取裁剪与未裁剪边界
  3. 03通过矩形差值计算间距
  4. 04断言触控目标尺寸
  5. 05获取对齐线和链接边界

适用场景

  1. 01验证按钮高度或图标位置
  2. 02检查组件间距和内边距
  3. 03测试最小尺寸与触控区域
  4. 04区分布局尺寸和裁剪后的可视尺寸

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/skydoves/android-testing-skills --skill asserting-bounds-and-dimensions

安装后在 Claude Code 中输入/asserting-bounds-and-dimensions确认已加载。

详细介绍

断言边界和尺寸——使用 dp 进行布局计算,而非像素

布局断言应使用 dp,并采用半个 dp 的容差。大多数有意义的检查(内边距、间距、对齐)都是对两个 getUnclippedBoundsInRoot() 矩形进行相减。本技能会介绍如何选择合适的尺寸/位置断言,解释裁剪与未裁剪的区别,并展示直接取自 material3/ButtonTest.kt 的规范模式:“根据两个矩形计算内边距”。

何时使用此技能

  • 开发者想验证 Button 高 48 dp、Spacer 宽 16 dp,或 Icon 的位置为 (24.dp, 12.dp)。
  • 开发者询问如何断言两个可组合项之间的内边距。
  • 开发者询问最小触摸目标尺寸(ChipDefaults.MinHeight + 1.dp)。
  • 开发者正在比较像素布局值,并希望了解对应的 dp 类型写法。
  • 开发者提到 assertWidthIsEqualTo、getUnclippedBoundsInRoot、DpRect、getAlignmentLinePosition、getFirstLinkBounds。

何时不使用此技能

  • 检查的是状态(启用、打开、选中)——请参阅 ./asserting-node-state-and-text/SKILL.md。
  • 检查的是“节点是否显示在屏幕上”——使用 assertIsDisplayed() 即可;边界计算只会增加繁琐操作,没有实际价值。
  • 可组合项的边界受正在进行的动画影响——先暂停时钟;请参阅 ../../synchronization/testing-animations-deterministically/SKILL.md。
  • 边界相对于屏幕截图而言——请改用屏幕截图测试。

前置条件

  • 可用的 ComposeTestRule / ComposeUiTest。请参阅 ../../setup/configuring-test-dependencies/SKILL.md。
  • 目标可组合项已完成测量和放置。如果它带有动画,请先推进测试时钟——请参阅 ../../synchronization/controlling-the-test-clock/SKILL.md。
  • 对于触摸目标断言,目标节点需具有点击操作,这样 touchBoundsInRoot 才有意义。

工作流

  • 1. 根据问题类型选择断言。 所有 API 都位于 commonMain/.../BoundsAssertions.kt。

    问题API文件:行号
    布局宽度是否恰好为 W dp?assertWidthIsEqualTo(expectedWidth: Dp)BoundsAssertions.kt:44-46
    布局高度是否恰好为 H dp?assertHeightIsEqualTo(expectedHeight: Dp)BoundsAssertions.kt:53-55
    宽度至少为 W?assertWidthIsAtLeast(expectedMinWidth: Dp)BoundsAssertions.kt:85-87
    高度至少为 H?assertHeightIsAtLeast(expectedMinHeight: Dp)BoundsAssertions.kt:95-99
    触控目标宽度?assertTouchWidthIsEqualTo(expectedWidth: Dp)BoundsAssertions.kt:62-66
    触控目标高度?assertTouchHeightIsEqualTo(expectedHeight: Dp)BoundsAssertions.kt:73-77
    在根节点中的精确位置?assertPositionInRootIsEqualTo(left: Dp, top: Dp)BoundsAssertions.kt:109-117
    仅顶部位置?assertTopPositionInRootIsEqualTo(top: Dp)BoundsAssertions.kt:126-130
    仅左侧位置?assertLeftPositionInRootIsEqualTo(left: Dp)BoundsAssertions.kt:139-143
    读取完整的未裁剪边界getUnclippedBoundsInRoot(): DpRectBoundsAssertions.kt:148-152
    读取裁剪后的边界getBoundsInRoot(): DpRectBoundsAssertions.kt:158-165
    以 dp 为单位的对齐线getAlignmentLinePosition(line: AlignmentLine): DpBoundsAssertions.kt:171-180
    Text 中 LinkAnnotation 的边界getFirstLinkBounds(predicate)BoundsAssertions.kt:196-248
    比较任意两个 Dp 值Dp.assertIsEqualTo(expected, subject, tolerance = Dp(.5f))BoundsAssertions.kt:319-324
  • 2. 布局计算使用未裁剪边界;判断“用户能看到什么”时使用裁剪后的边界。 getUnclippedBoundsInRoot() 返回忽略视口裁剪的布局矩形(BoundsAssertions.kt:148-152 → 私有的 unclippedBoundsInRoot,BoundsAssertions.kt:284-291)。getBoundsInRoot() 会按视口裁剪(BoundsAssertions.kt:158-165)。内边距/间距计算使用未裁剪边界;检查部分可见性时使用裁剪后的边界。

  • 3. 默认容差为半个 dp。 Dp.assertIsEqualTo(expected, subject, tolerance = Dp(.5f))(BoundsAssertions.kt:319)允许最多 0.5 dp 的偏差,因为布局舍入会产生小于 1 dp 的误差。只有在明确的测量约定要求更高精度时,才覆盖此值。

  • 4. 通过两个未裁剪矩形相减来计算内边距,然后调用 Dp.assertIsEqualTo。 这是 material3 中的标准模式(material3/.../ButtonTest.kt:213-225):

    val buttonBounds = rule.onNodeWithTag(ButtonTestTag).getUnclippedBoundsInRoot()
    val textBounds   = rule.onNodeWithTag(TextTestTag).getUnclippedBoundsInRoot()
    
    (textBounds.left - buttonBounds.left).assertIsEqualTo(
        24.dp,
        "padding between the start of the button and the start of the text.",
    )
    
    (buttonBounds.right - textBounds.right).assertIsEqualTo(
        24.dp,
        "padding between the end of the text and the end of the button.",
    )
    buttonBounds.height.assertIsEqualTo(ButtonDefaults.MinHeight, "height of button.")
    

    subject 字符串会出现在失败消息中:"按钮起始位置与文本起始位置之间的实际内边距。为 22.dp,预期为 24.dp(容差:.5.dp)"。

  • 5. 对于最小尺寸约定,使用 assertHeightIsAtLeast / assertWidthIsAtLeast。 当可组合项即使在较大字体缩放下也不能小于某个常量时,这很有用。示例来自 material/ChipTest.kt:226-233:

    rule.setMaterialContent { Chip(onClick = {}) { Text(text = "Test chip", fontSize = 50.sp) } }
    rule.onNode(hasClickAction()).assertHeightIsAtLeast(ChipDefaults.MinHeight + 1.dp)
    
  • 6. 验证点击目标的无障碍性时,使用触控边界。 assertTouchWidthIsEqualTo / assertTouchHeightIsEqualTo 会读取 node.touchBoundsInRoot(BoundsAssertions.kt:270-282)。当可组合项应用 Modifier.minimumInteractiveComponentSize() 或类似修饰符时,触控边界可能超出视觉边界。视觉边界使用 getUnclippedBoundsInRoot;触控边界则回答“点击会落在哪里”。

模式

模式:使用 dp 类型断言,而非读取像素值
// WRONG
@Test
fun submit_isMin48dpTall() {
    rule.setContent { CheckoutScreen() }
    val node = rule.onNodeWithTag(SubmitTag).fetchSemanticsNode()
    val heightPx = node.size.height
    assert(heightPx >= 48 * Resources.getSystem().displayMetrics.density)
}
// WRONG because: pixel-typed and density-dependent. Reads outside the framework's tolerance
// model. Failure prints raw integers, no node dump, no subject label.
// RIGHT
@Test
fun submit_isMin48dpTall() {
    rule.setContent { CheckoutScreen() }
    rule.onNodeWithTag(SubmitTag).assertHeightIsAtLeast(48.dp)
}
模式:通过两个未裁剪矩形相减来计算内边距
@Test
fun button_text_has24dpPadding() {
    rule.setContent {
        Button(onClick = {}, modifier = Modifier.testTag(ButtonTestTag)) {
            Text("Submit", modifier = Modifier.testTag(TextTestTag).semantics(mergeDescendants = true) {})
        }
    }

    val buttonBounds = rule.onNodeWithTag(ButtonTestTag).getUnclippedBoundsInRoot()
    val textBounds   = rule.onNodeWithTag(TextTestTag).getUnclippedBoundsInRoot()

    (textBounds.left - buttonBounds.left).assertIsEqualTo(24.dp, "start padding")
    (buttonBounds.right - textBounds.right).assertIsEqualTo(24.dp, "end padding")
}

对内部 Text 禁用合并的做法,与 material3/ButtonTest.kt:202-226 中使用的技巧相同,可让内部 Text 在合并后的树中仍可访问。

模式:使用 assertPositionInRootIsEqualTo 检查绝对位置
// "the close button sits at (320.dp, 0.dp) in the root"
rule.onNodeWithTag(CloseButtonTag)
    .assertPositionInRootIsEqualTo(expectedLeft = 320.dp, expectedTop = 0.dp)

如果只关心一个轴,请使用 assertLeftPositionInRootIsEqualTo / assertTopPositionInRootIsEqualTo,避免让测试依赖另一轴上的布局决策。

模式:基线数学的对齐线
val baselineDp = rule.onNodeWithTag(LabelTag)
    .getAlignmentLinePosition(FirstBaseline)
require(!baselineDp.isUnspecified) { "Label has no first baseline" }
baselineDp.assertIsEqualTo(20.dp, "first baseline of label")

当未提供对齐线时,getAlignmentLinePosition 会返回 Dp.Unspecified(BoundsAssertions.kt:172-179)。比较前务必检查 isUnspecified。

模式:子 dp 精度的容差覆盖
// Most tests want the default ½ dp tolerance:
rule.onNodeWithTag(IconTag).getUnclippedBoundsInRoot().width.assertIsEqualTo(24.dp, "icon width")

// Stricter tolerance when measuring a hand-aligned constant:
val width = rule.onNodeWithTag(IconTag).getUnclippedBoundsInRoot().width
width.assertIsEqualTo(expected = 24.dp, subject = "icon width", tolerance = 0.1.dp)
模式:裁剪与未裁剪 — 部分可见
val unclipped = rule.onNodeWithTag(BannerTag).getUnclippedBoundsInRoot()
val clipped   = rule.onNodeWithTag(BannerTag).getBoundsInRoot()

// Banner laid out 200 dp tall but only 80 dp visible (rest clipped by parent):
unclipped.height.assertIsEqualTo(200.dp, "banner intrinsic height")
clipped.height.assertIsEqualTo(80.dp, "banner visible height")

若要判断“是否有任何部分可见”,优先使用 assertIsDisplayed() — 参见 ./asserting-node-state-and-text/SKILL.md。

强制规则

  • 必须使用类型化的 assertWidthIsEqualTo / assertHeightIsEqualTo / assertPositionInRootIsEqualTo,以 dp 为单位进行断言。不得读取 fetchSemanticsNode().size.width 并比较像素值。
  • 必须使用 getUnclippedBoundsInRoot() 进行内边距 / 间距 / 对齐计算;只有当契约是“用户裁剪后看到的内容”时,才必须使用 getBoundsInRoot()。
  • 必须向 Dp.assertIsEqualTo 传入有意义的 subject 字符串,以便失败消息指出哪项测量失败。
  • 若目标是“字体缩放较大时,布局会超出最小值”,必须优先使用 assertHeightIsAtLeast(MinHeight + 1.dp),而不是 assertHeightIsEqualTo(MinHeight + N.dp)。
  • 不得假设容差为零。布局舍入会导致亚 dp 偏差;应使用默认的半 dp 容差,仅在有充分理由时才覆盖。
  • **首选:**动画进行期间,暂停 mainClock.autoAdvance = false,并以确定的步长推进,然后再读取边界。Skydoves 热门观点 #3。

验证

  • 不再有 node.size.width / node.size.height 读取。所有尺寸检查都使用类型化的 assert*IsEqualTo / Dp.assertIsEqualTo。
  • 内边距 / 间距计算使用 getUnclippedBoundsInRoot;只有在注释说明原因时才读取裁剪后的边界。
  • 每个 Dp.assertIsEqualTo(...) 都传入非空的 subject 字符串。
  • 验证无障碍约束时,触摸目标断言使用 assertTouchWidthIsEqualTo / assertTouchHeightIsEqualTo,而不是视觉边界。
  • ./gradlew :app:connectedDebugAndroidTest 通过;失败消息会通过 subject 指明具体失败的测量项。

参考

  • Compose 测试概览:https://developer.android.com/develop/ui/compose/testing
  • Compose 测试速查表:https://developer.android.com/develop/ui/compose/testing-cheatsheet
  • Compose 中的布局:https://developer.android.com/develop/ui/compose/layouts
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/BoundsAssertions.kt — assertWidthIsEqualTo、assertHeightIsAtLeast、assertPositionInRootIsEqualTo、getUnclippedBoundsInRoot、getBoundsInRoot、getAlignmentLinePosition、getFirstLinkBounds、Dp.assertIsEqualTo(默认容差为 ½ dp)。
  • compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/ButtonTest.kt:202-226 — 规范的“两个未裁剪矩形相减”内边距测试。
  • compose/material/material/src/androidDeviceTest/kotlin/androidx/compose/material/ChipTest.kt:226-233 — 用于最小触摸目标契约的 assertHeightIsAtLeast(MinHeight + 1.dp)。

评论

支持 Markdown