mcpField
A
asserting-bounds-and-dimensions

asserting-bounds-and-dimensions

github.com/skydovesテスト・品質33961 回インストール22 週間前に更新
リポジトリ

asserting-bounds-and-dimensions について

Compose UI テストでレイアウトの寸法や位置を検証するスキルガイドです。コンポーネントのサイズ、間隔、配置、タッチ領域を対象に、dp 型のアサーションとクリップ前の境界を使う方法や、可視境界と既定の許容差を説明します。

主な機能

  1. 01dp によるサイズと位置の検証
  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 を使い、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-165
    dp 単位のアラインメントラインgetAlignmentLinePosition(line: AlignmentLine): DpBoundsAssertions.kt:171-180
    Text 内の 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)。

コメント