Jetpack Compose: .semantics(mergeDescendants = true) wirklich verstehen

Wenn du mit Jetpack Compose UI Tests arbeitest, bist du wahrscheinlich schon über ein seltsames Verhalten gestolpert:

Du setzt ein testTag auf ein Composable – aber plötzlich findet dein Test den Node nicht mehr.

Der Grund ist häufig dieser Modifier:

Modifier.semantics(mergeDescendants = true)

Viele Entwickler nutzen ihn, ohne wirklich zu verstehen, was er mit der Semantik-Hierarchie macht.

In diesem Artikel schauen wir uns an:

  • was Semantics in Compose überhaupt ist
  • wie der Semantik-Baum aufgebaut ist
  • was mergeDescendants = true wirklich verändert
  • warum dadurch Testtags verschwinden
  • wie du trotzdem Kinder testen kannst

1. Was ist Semantics in Jetpack Compose?

Semantics beschreibt die Bedeutung eines UI-Elements.

Sie wird hauptsächlich verwendet für:

  • Accessibility (TalkBack / Screenreader)
  • Compose UI Tests

Semantics ist also eine Meta-Beschreibung deiner UI, nicht das UI selbst.

Typische Semantics-Properties sind:

  • contentDescription
  • testTag
  • stateDescription
  • clickable
  • enabled
  • selected

Beispiel:

Box(
modifier = Modifier
.semantics {
testTag = "MyBox"
}
) {
Text("Hallo Welt")
}

Jetzt kann dein UI Test dieses Element finden:

composeTestRule
.onNodeWithTag("MyBox")
.assertExists()

2. Der Semantik-Baum

Compose baut intern einen Semantik-Baum, der parallel zum UI aufgebaut wird.

Beispiel:

Column {
Text(
"Hallo",
modifier = Modifier.semantics {
testTag = "Child1"
}
) Text(
"Welt",
modifier = Modifier.semantics {
testTag = "Child2"
}
)
}

Der Semantik-Baum sieht vereinfacht so aus:





Wichtig:

👉 Jedes Element hat seinen eigenen Semantik-Node

Deshalb funktionieren diese Tests problemlos:

composeTestRule.onNodeWithTag("Child1")
composeTestRule.onNodeWithTag("Child2")

3. Was macht mergeDescendants = true?

Wenn du mergeDescendants = true setzt, passiert Folgendes:

👉 Die Semantics der Kinder werden in den Parent integriert

Die Kinder existieren dann nicht mehr als eigene Semantik-Nodes.

Beispiel:

Column(
modifier = Modifier.semantics(mergeDescendants = true) {
testTag = "Parent"
}
) { Text(
"Hallo",
modifier = Modifier.semantics {
testTag = "Child1"
}
) Text(
"Welt",
modifier = Modifier.semantics {
testTag = "Child2"
}
)
}

Der Semantik-Baum wird jetzt zu:

Die Kinder sind nicht mehr separat vorhanden.

Das bedeutet:

❌ Dieser Test schlägt fehl

composeTestRule.onNodeWithTag("Child1")

weil der Node nicht mehr existiert.


4. Warum gibt es diese Funktion überhaupt?

Der Hauptgrund ist Accessibility.

Ohne Merge kann ein Screenreader sehr viele kleine Elemente vorlesen:

Text: Hallo
Text: Welt
Button
Icon
Text

Mit mergeDescendants = true kann Compose daraus ein einziges logisches Element machen.

Beispiel: Ein zusammengesetzter Button

Row(
modifier = Modifier
.clickable { }
.semantics(mergeDescendants = true) {}
) {
Icon(Icons.Default.Favorite)
Text("Like")
}

Screenreader liest jetzt:

Button: Like

statt

Icon
Text Like

5. Warum verschwinden Testtags?

Der Grund ist simpel:

UI Tests arbeiten auf dem Semantik-Baum.

Wenn mergeDescendants = true gesetzt ist:

  • die Kinder-Nodes verschwinden
  • ihre Semantics werden integriert
  • testTag bleibt nur auf dem Parent sichtbar

Deshalb können Tests Kinder nicht mehr direkt finden.


6. Wie testet man trotzdem Inhalte?

Obwohl die Kinder verschwinden, bleiben ihre Inhalte Teil der Parent-Semantik.

Du kannst also z.B. Text prüfen:

composeTestRule
.onNodeWithTag("Parent")
.assertTextContains("Hallo")

oder

composeTestRule
.onNodeWithTag("Parent")
.assertTextContains("Welt")

7. Trick: Testtags von Kindern in die Parent-Semantics übernehmen

Wenn du mergeDescendants brauchst, aber trotzdem Kinder identifizieren möchtest, kannst du deren Informationen in die Parent-Semantics übernehmen.

Beispiel:

Column(
modifier = Modifier.semantics(mergeDescendants = true) { testTag = "Parent" contentDescription = "Child1 Hallo Child2 Welt"
}
) {
Text("Hallo")
Text("Welt")
}

Dann kannst du im Test prüfen:

composeTestRule
.onNodeWithTag("Parent")
.assertContentDescriptionContains("Child1")

Eine andere Möglichkeit ist eine Custom Semantics Property.

val ChildTagKey = SemanticsPropertyKey<String>("ChildTag")
var SemanticsPropertyReceiver.childTag by ChildTagKey

Dann:

Modifier.semantics {
childTag = "Child1"
}

Das kann im Test gezielt geprüft werden.


8. Wann solltest du mergeDescendants verwenden?

Empfohlen für:

✔ komplexe UI Komponenten
✔ zusammengesetzte Buttons
✔ Accessibility Optimierung

Nicht ideal für:

❌ fein granulare UI Tests
❌ wenn du Kinder direkt selektieren willst


Fazit

mergeDescendants = true ist ein kleines Feature mit großer Wirkung.

Es verändert nicht das UI, sondern den Semantik-Baum, auf dem:

  • Accessibility
  • Compose UI Tests

basieren.

Die wichtigste Konsequenz:

👉 Kinder verschwinden als eigenständige Nodes

Wenn deine Tests plötzlich keine Nodes mehr finden, lohnt sich also immer ein Blick auf diesen Modifier.