界面已本地化;技能介绍与说明文档按所选语言由 AI 自动翻译,代码、命令、路径和专业术语保留原文,以原作者文档为准。
探索技能
开发与工程 · SKILL.md

asserting-bounds-and-dimensions

asserting-bounds-and-dimensions

【开发与工程】Use this skill to verify Compose layout measurements from a UI test using `assertWidthIsEqualTo`, `assertHeightIsEqualTo`, `assertWidthIsAtLeast`, `assertHeightIsAtLeast`, `assertTouchWidthIsEqualTo`, `assertTouchHeightIsEqualTo`, `assertPositionInRootIsEqualTo`, `assertTopPositionInRootIsEqualTo`, `assertLeftPositionInRootIsEqualTo`, plus read helpe…

独立 GitHub 快照 · 自动分类 · 以原作者文档为准

技能说明

GitHub

name: asserting-bounds-and-dimensions description: Use this skill to verify Compose layout measurements from a UI test using assertWidthIsEqualTo, assertHeightIsEqualTo, assertWidthIsAtLeast, assertHeightIsAtLeast, assertTouchWidthIsEqualTo, assertTouchHeightIsEqualTo, assertPositionInRootIsEqualTo, assertTopPositionInRootIsEqualTo, assertLeftPositionInRootIsEqualTo, plus read helpers getUnclippedBoundsInRoot, getBoundsInRoot, getAlignmentLinePosition, getFirstLinkBounds, and the underlying Dp.assertIsEqualTo(expected, subject, tolerance = Dp(.5f)). Covers the half-dp default tolerance, the unclipped vs clipped distinction, the canonical "compute padding from two unclipped rects" pattern, and minimum-touch-target assertions like assertHeightIsAtLeast(MinHeight + 1.dp). Use when the developer wants to assert sizes, padding, alignment, position in dp, or asks about getUnclippedBoundsInRoot, DpRect, touch-target size, or compares widths in pixels. If the developer is comparing layout dimensions from a test, use this skill. license: Apache-2.0. See LICENSE for complete terms. metadata: author: Jaewoong Eum (skydoves) keywords:

  • jetpack-compose
  • ui-testing
  • assertWidthIsEqualTo
  • assertHeightIsAtLeast
  • assertPositionInRootIsEqualTo
  • getUnclippedBoundsInRoot
  • DpRect
  • touch-target
  • dp-tolerance
  • layout-assertions

Asserting Bounds and Dimensions — Layout Math in Dp, Not Pixels

Layout assertions belong in dp, run with a half-dp tolerance, and most of the interesting checks (padding, gap, alignment) are subtractions between two getUnclippedBoundsInRoot() rectangles. This skill picks the right size/position assertion, explains clipped vs unclipped, and shows the canonical "compute padding from two rects" pattern lifted directly from material3/ButtonTest.kt.

When to use this skill

  • The developer wants to verify a Button is 48 dp tall, a Spacer is 16 dp wide, an Icon is at position (24.dp, 12.dp).
  • The developer asks how to assert the padding between two composables.
  • The developer asks about minimum touch target sizes (ChipDefaults.MinHeight + 1.dp).
  • The developer is comparing layout values in pixels and wants the dp-typed equivalent.
  • The developer mentions assertWidthIsEqualTo, getUnclippedBoundsInRoot, DpRect, getAlignmentLinePosition, getFirstLinkBounds.

When NOT to use this skill

  • The check is about state (enabled, on, selected) — see ./asserting-node-state-and-text/SKILL.md.
  • The check is "is the node on screen at all" — assertIsDisplayed() is enough; bounds math adds friction without value.
  • The composable's bounds depend on an animation in flight — pause the clock first; see ../../synchronization/testing-animations-deterministically/SKILL.md.
  • The bounds are relative to a screenshot — use a screenshot test instead.

Prerequisites

  • A working ComposeTestRule / ComposeUiTest. See ../../setup/configuring-test-dependencies/SKILL.md.
  • The target composable has finished measuring and placing. If it animates in, advance the test clock first — see ../../synchronization/controlling-the-test-clock/SKILL.md.
  • For touch-target assertions, the target node has a click action so touchBoundsInRoot is meaningful.

Workflow

  • 1. Pick the assertion by question type. All APIs live in commonMain/.../BoundsAssertions.kt.

    QuestionAPIFile:line
    Is the layout exactly W dp wide?assertWidthIsEqualTo(expectedWidth: Dp)BoundsAssertions.kt:44-46
    Is the layout exactly H dp tall?assertHeightIsEqualTo(expectedHeight: Dp)BoundsAssertions.kt:53-55
    At least W wide?assertWidthIsAtLeast(expectedMinWidth: Dp)BoundsAssertions.kt:85-87
    At least H tall?assertHeightIsAtLeast(expectedMinHeight: Dp)BoundsAssertions.kt:95-99
    Touch-target width?assertTouchWidthIsEqualTo(expectedWidth: Dp)BoundsAssertions.kt:62-66
    Touch-target height?assertTouchHeightIsEqualTo(expectedHeight: Dp)BoundsAssertions.kt:73-77
    Exact position in root?assertPositionInRootIsEqualTo(left: Dp, top: Dp)BoundsAssertions.kt:109-117
    Top position only?assertTopPositionInRootIsEqualTo(top: Dp)BoundsAssertions.kt:126-130
    Left position only?assertLeftPositionInRootIsEqualTo(left: Dp)BoundsAssertions.kt:139-143
    Read full unclipped boundsgetUnclippedBoundsInRoot(): DpRectBoundsAssertions.kt:148-152
    Read clipped boundsgetBoundsInRoot(): DpRectBoundsAssertions.kt:158-165
    Alignment line in dpgetAlignmentLinePosition(line: AlignmentLine): DpBoundsAssertions.kt:171-180
    Bounds of a LinkAnnotation in a TextgetFirstLinkBounds(predicate)BoundsAssertions.kt:196-248
    Compare any two Dp valuesDp.assertIsEqualTo(expected, subject, tolerance = Dp(.5f))BoundsAssertions.kt:319-324
  • 2. Use unclipped bounds for layout math; clipped bounds for "what the user sees". getUnclippedBoundsInRoot() returns the laid-out rectangle ignoring viewport clipping (BoundsAssertions.kt:148-152 → unclippedBoundsInRoot private, BoundsAssertions.kt:284-291). getBoundsInRoot() clips to the viewport (BoundsAssertions.kt:158-165). Padding/spacing math uses unclipped; partial-visibility checks use clipped.

  • 3. Tolerance is half a dp by default. Dp.assertIsEqualTo(expected, subject, tolerance = Dp(.5f)) (BoundsAssertions.kt:319) accepts deviations up to 0.5 dp because layout rounding introduces sub-dp drift. Override only when stricter precision is justified by an explicit measurement contract.

  • 4. Compute padding by subtracting two unclipped rects, then call Dp.assertIsEqualTo. This is the canonical material3 pattern (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.")
    

    The subject string lands in the failure message: "Actual padding between the start of the button and the start of the text. is 22.dp, expected 24.dp (tolerance: .5.dp)".

  • 5. For minimum-size contracts, use assertHeightIsAtLeast / assertWidthIsAtLeast. Useful when a composable should never be smaller than a constant, even at large font scale. Example from 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. Use touch bounds when verifying tap-target accessibility. assertTouchWidthIsEqualTo / assertTouchHeightIsEqualTo reads node.touchBoundsInRoot (BoundsAssertions.kt:270-282), which can extend past the visual bounds when the composable applies Modifier.minimumInteractiveComponentSize() or similar. Visual bounds use getUnclippedBoundsInRoot; touch bounds answer "where will a click land".

Patterns

Pattern: dp typed assertions over pixel reads

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

Pattern: padding by subtracting two unclipped rects

@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")
}

The merge bypass on the inner Text is the same trick used by material3/ButtonTest.kt:202-226 to keep the inner Text addressable from the merged tree.

Pattern: assertPositionInRootIsEqualTo for absolute placement

// "the close button sits at (320.dp, 0.dp) in the root"
rule.onNodeWithTag(CloseButtonTag)
    .assertPositionInRootIsEqualTo(expectedLeft = 320.dp, expectedTop = 0.dp)

If only one axis matters, use assertLeftPositionInRootIsEqualTo / assertTopPositionInRootIsEqualTo to avoid coupling the test to layout decisions on the other axis.

Pattern: alignment line for baseline math

val baselineDp = rule.onNodeWithTag(LabelTag)
    .getAlignmentLinePosition(FirstBaseline)
require(!baselineDp.isUnspecified) { "Label has no first baseline" }
baselineDp.assertIsEqualTo(20.dp, "first baseline of label")

getAlignmentLinePosition returns Dp.Unspecified when the alignment line is not provided (BoundsAssertions.kt:172-179). Always check isUnspecified before comparing.

Pattern: tolerance override for sub-dp precision

// 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)

Pattern: clipped vs unclipped — partial visibility

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")

For "is any of it visible", prefer assertIsDisplayed() — see ./asserting-node-state-and-text/SKILL.md.

Mandatory rules

  • MUST assert in dp using the typed assertWidthIsEqualTo / assertHeightIsEqualTo / assertPositionInRootIsEqualTo. MUST NOT read fetchSemanticsNode().size.width and compare pixels.
  • MUST use getUnclippedBoundsInRoot() for padding / gap / alignment math; MUST use getBoundsInRoot() only when the contract is "what the user sees after clipping".
  • MUST pass a meaningful subject string to Dp.assertIsEqualTo so the failure message identifies which measurement failed.
  • MUST prefer assertHeightIsAtLeast(MinHeight + 1.dp) over assertHeightIsEqualTo(MinHeight + N.dp) when the goal is "the layout grows past the minimum at large font scales".
  • MUST NOT assume zero tolerance. Layout rounding produces sub-dp drift; rely on the half-dp default and override only when justified.
  • PREFERRED: when an animation is in flight, pause mainClock.autoAdvance = false and step deterministically before reading bounds. Skydoves hot take #3.

Verification

  • No node.size.width / node.size.height reads remain. All dimension checks use the typed assert*IsEqualTo / Dp.assertIsEqualTo.
  • Padding / gap math uses getUnclippedBoundsInRoot; clipped reads only appear with a comment explaining why.
  • Every Dp.assertIsEqualTo(...) passes a non-empty subject string.
  • Touch-target assertions use assertTouchWidthIsEqualTo / assertTouchHeightIsEqualTo rather than visual bounds when verifying accessibility constraints.
  • ./gradlew :app:connectedDebugAndroidTest passes; failure messages identify the specific failed measurement by subject.

References

  • Compose testing overview: https://developer.android.com/develop/ui/compose/testing
  • Compose testing cheat sheet: https://developer.android.com/develop/ui/compose/testing-cheatsheet
  • Layout in 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 (default tolerance ½ dp).
  • compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/ButtonTest.kt:202-226 — canonical "subtract two unclipped rects" padding test.
  • compose/material/material/src/androidDeviceTest/kotlin/androidx/compose/material/ChipTest.kt:226-233 — assertHeightIsAtLeast(MinHeight + 1.dp) for minimum-touch contracts.
我的收藏