关于 asserting-bounds-and-dimensions
面向 Compose UI 测试的布局尺寸与位置断言技能,适用于验证组件尺寸、间距、对齐和触控区域。它使用 dp 类型化断言及未裁剪边界计算布局关系,并说明可视边界与默认容差的区别。
主要功能
- 01以 dp 断言尺寸和位置
- 02读取裁剪与未裁剪边界
- 03通过矩形差值计算间距
- 04断言触控目标尺寸
- 05获取对齐线和链接边界
适用场景
- 01验证按钮高度或图标位置
- 02检查组件间距和内边距
- 03测试最小尺寸与触控区域
- 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-180Text 中 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)。