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
touchBoundsInRootis meaningful.
Workflow
-
1. Pick the assertion by question type. All APIs live in
commonMain/.../BoundsAssertions.kt.Question API File:line Is the layout exactly W dp wide? assertWidthIsEqualTo(expectedWidth: Dp)BoundsAssertions.kt:44-46Is the layout exactly H dp tall? assertHeightIsEqualTo(expectedHeight: Dp)BoundsAssertions.kt:53-55At least W wide? assertWidthIsAtLeast(expectedMinWidth: Dp)BoundsAssertions.kt:85-87At least H tall? assertHeightIsAtLeast(expectedMinHeight: Dp)BoundsAssertions.kt:95-99Touch-target width? assertTouchWidthIsEqualTo(expectedWidth: Dp)BoundsAssertions.kt:62-66Touch-target height? assertTouchHeightIsEqualTo(expectedHeight: Dp)BoundsAssertions.kt:73-77Exact position in root? assertPositionInRootIsEqualTo(left: Dp, top: Dp)BoundsAssertions.kt:109-117Top position only? assertTopPositionInRootIsEqualTo(top: Dp)BoundsAssertions.kt:126-130Left position only? assertLeftPositionInRootIsEqualTo(left: Dp)BoundsAssertions.kt:139-143Read full unclipped bounds getUnclippedBoundsInRoot(): DpRectBoundsAssertions.kt:148-152Read clipped bounds getBoundsInRoot(): DpRectBoundsAssertions.kt:158-165Alignment line in dp getAlignmentLinePosition(line: AlignmentLine): DpBoundsAssertions.kt:171-180Bounds of a LinkAnnotationin a TextgetFirstLinkBounds(predicate)BoundsAssertions.kt:196-248Compare any two DpvaluesDp.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→unclippedBoundsInRootprivate,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
subjectstring 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 frommaterial/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/assertTouchHeightIsEqualToreadsnode.touchBoundsInRoot(BoundsAssertions.kt:270-282), which can extend past the visual bounds when the composable appliesModifier.minimumInteractiveComponentSize()or similar. Visual bounds usegetUnclippedBoundsInRoot; 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 readfetchSemanticsNode().size.widthand compare pixels. - MUST use
getUnclippedBoundsInRoot()for padding / gap / alignment math; MUST usegetBoundsInRoot()only when the contract is "what the user sees after clipping". - MUST pass a meaningful
subjectstring toDp.assertIsEqualToso the failure message identifies which measurement failed. - MUST prefer
assertHeightIsAtLeast(MinHeight + 1.dp)overassertHeightIsEqualTo(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 = falseand step deterministically before reading bounds. Skydoves hot take #3.
Verification
- No
node.size.width/node.size.heightreads remain. All dimension checks use the typedassert*IsEqualTo/Dp.assertIsEqualTo. - Padding / gap math uses
getUnclippedBoundsInRoot; clipped reads only appear with a comment explaining why. - Every
Dp.assertIsEqualTo(...)passes a non-emptysubjectstring. - Touch-target assertions use
assertTouchWidthIsEqualTo/assertTouchHeightIsEqualTorather than visual bounds when verifying accessibility constraints. -
./gradlew :app:connectedDebugAndroidTestpasses; failure messages identify the specific failed measurement bysubject.
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.