asserting-bounds-and-dimensions について
Compose UI テストでレイアウトの寸法や位置を検証するスキルガイドです。コンポーネントのサイズ、間隔、配置、タッチ領域を対象に、dp 型のアサーションとクリップ前の境界を使う方法や、可視境界と既定の許容差を説明します。
主な機能
- 01dp によるサイズと位置の検証
- 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 を使い、0.5 dp の許容誤差で行います。また、興味深いチェックの多く(パディング、間隔、配置)は、2 つの getUnclippedBoundsInRoot() 矩形の差を求めます。このスキルでは、適切なサイズ/位置のアサートを選ぶ方法、クリップ済みとクリップ前の違い、そして material3/ButtonTest.kt から直接取り入れた、2 つの矩形からパディングを計算する標準的なパターンを説明します。
このスキルを使う場合
- Button の高さが 48 dp、Spacer の幅が 16 dp、Icon の位置が
(24.dp, 12.dp)であることを確認したい場合。 - 2 つのコンポーザブル間のパディングをどのようにアサートするかを知りたい場合。
- 最小タッチターゲットサイズ(
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-165dp 単位のアラインメントライン getAlignmentLinePosition(line: AlignmentLine): DpBoundsAssertions.kt:171-180Text 内の LinkAnnotationの境界getFirstLinkBounds(predicate)BoundsAssertions.kt:196-248任意の 2 つの 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. 許容誤差の既定値は 0.5 dp です。
Dp.assertIsEqualTo(expected, subject, tolerance = Dp(.5f))(BoundsAssertions.kt:319)は、レイアウトの丸めによって 1 dp 未満のずれが生じるため、最大 0.5 dp の差を許容します。明示的な測定契約により、より厳密な精度が正当化される場合に限り、値を変更してください。 -
4. 2 つのクリップ前の矩形を引いてパディングを計算し、その後
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文字列は失敗メッセージに表示されます。「Actual padding between the start of the button and the start of the text. is 22.dp, expected 24.dp (tolerance: .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)
}
パターン:2 つのクリップ前の矩形を引いてパディングを計算する
@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 でマージを無効にする方法は、マージされたツリーから内側の Text にアクセスできるようにするため、material3/ButtonTest.kt:202-226 で使われているものと同じ手法です。
パターン:絶対位置の指定に 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 を参照してください。
必須ルール
- dp 単位で、型付きの
assertWidthIsEqualTo/assertHeightIsEqualTo/assertPositionInRootIsEqualToを使ってアサートしなければなりません。ピクセル単位の値を比較するために、fetchSemanticsNode().size.widthを読み取ってはなりません。 - パディング/間隔/アライメントの計算には
getUnclippedBoundsInRoot()を使わなければなりません。getBoundsInRoot()を使うのは、「クリップ後にユーザーに見える範囲」が契約となっている場合に限ります。 Dp.assertIsEqualToには、どの測定に失敗したかがエラーメッセージで分かるよう、意味のあるsubject文字列を渡さなければなりません。- 目標が「大きなフォントスケールでレイアウトが最小値を超えて拡大すること」である場合は、
assertHeightIsEqualTo(MinHeight + N.dp)よりもassertHeightIsAtLeast(MinHeight + 1.dp)を優先してください。 - 許容誤差がゼロだと仮定してはなりません。レイアウトの丸めによって dp 未満のずれが生じるため、デフォルトの 0.5 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— パディングテストの標準例「クリップされていない 2 つの矩形を引く」。compose/material/material/src/androidDeviceTest/kotlin/androidx/compose/material/ChipTest.kt:226-233— 最小タッチ領域の要件に対するassertHeightIsAtLeast(MinHeight + 1.dp)。