5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Jetpack Compose]useUnmergedTreeはいつ使うのか

Posted at

はじめに

この記事はand factory.inc Advent Calendar 2025 11日目の記事です。

昨日は@nagata1121さんの[Android] CoilでIntrinsicSizeによる画像サイズ指定が効かない時の対処法 でした。

概要

Jetpack Composeのテストで「なぜかノードが見つからない!」という経験はありませんか?それはセマンティクスツリーのマージが原因かもしれません。

🔍 セマンティクスツリーのマージとは?

Composeは、パフォーマンスとアクセシビリティの最適化のために、複数のUIノードを1つのセマンティクスノードにマージすることがあります。

マージが発生する条件

Clickable要素(Button、Surface with onClick など)の子要素はマージされます。

// このような構造の場合
Surface(onClick = { /* クリック処理 */ }) {
	Row {
		Icon(...)     // これらの要素は
		Text(...)     // 1つのノードに
		Icon(...)     // マージされる
	}
}

❌ マージによって起こる問題

  1. testTagが見つからない
// ❌ このテストは失敗する
@Composable
fun CardWithTag() {
	Surface(onClick = { /* ... */ }) {
		Text(
			text = "タイトル",
			modifier = Modifier.testTag("title_text")  // このタグは見つからない
		)
	}
}
@Test
fun testTagでノドを見つける() {
	composeTestRule.setContent { CardWithTag() }

  // ❌ 失敗:マージされてtestTagが消える
  composeTestRule.onNodeWithTag("title_text").assertIsDisplayed()

  // ✅ 成功:useUnmergedTreeを使う
  composeTestRule.onNodeWithTag("title_text", useUnmergedTree = true).assertIsDisplayed()
  
  // ✅ 成功:nodeWithTextなら見るかる
  composeTestRule.onNodeWithText("タイトル").assertIsDisplayed()
}
  1. contentDescriptionが結合される
// ❌ 複数のIconのcontentDescriptionがマージされる
@Composable
fun IconRow() {
	Surface(onClick = { /* ... */ }) {
		Row {
			Icon(
				imageVector = Icons.Default.Star,
				contentDescription = "お気に入り"
			)
			Icon(
				imageVector = Icons.Default.Share,
				contentDescription = "シェア"
			)
		}
	}
}

@Test
fun 個別のIconを見つける() {
	composeTestRule.setContent { IconRow() }

  // ❌ 失敗:contentDescriptionが「お気に入り シェア」のように結合される
  composeTestRule.onNodeWithContentDescription("シェア").performClick()

  // ✅ 成功:useUnmergedTreeを使う
  composeTestRule.onNode(
      hasContentDescription("シェア"),
      useUnmergedTree = true
  ).performClick()
}

✅ マージされても大丈夫なケース

Text要素の内容は検索可能

@Composable
fun CardWithText() {
	Surface(onClick = { /* ... */ }) {
		Column {
			Text("タイトル")      // このテキストは
			Text("説明文")        // マージ後も
			Text("2024年12月")    // 検索可能!
		}
	}
}

@Test
fun テキスト内容で検索() {
	composeTestRule.setContent { CardWithText() }

  // ✅ 成功:Textの内容はマージ後も検索できる
  composeTestRule.onNodeWithText("タイトル").assertIsDisplayed()
  composeTestRule.onNodeWithText("説明文").performClick()
}

📊 まとめ表

検索方法 マージ後の動作 useUnmergedTreeが必要?
onNodeWithText() ✅ 検索可能 不要
onNodeWithTag() ❌ 見つからない 必要
onNodeWithContentDescription() ⚠️ 結合される 必要(個別に探す場合)

🎯 使い分けのベストプラクティス

  1. 基本はマージされたツリーを使う
  • パフォーマンスが良い
  • 実際のユーザー操作に近い
  1. useUnmergedTree = trueを使うケース
  • 特定の子要素だけをテストしたい
  • testTagで個別要素を識別したい
  • 複数のIconから特定のものを選びたい
  • ノードの階層構造を厳密にテストしたい
  1. Textの検索は通常のツリーでOK
  • onNodeWithText()は多くの場合そのまま使える

おわりに

いかがでしたでしょうか?
useUnmergedTreeを使うべきところ、そうでないところを理解してComposeのテスト書いていきたいですね。

明日の
Andfactory Advent Calendarもよろしくお願いします。 :santa:

🔗 Links

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?