Repository: wix/Detox (js)

Issue:
Text property/matching: Derive from child-views
### Describe your idea

As reported via an [internal support case](https://wix.slack.com/archives/C1Y4D9BK8/p1678275248117969) by @bunkib, we could do better in terms of Android equivalence (to iOS) by aligning text-based matching logic:

On iOS, the _text_ property of a view is defined not only by the text associated with view itself - provided that it is in fact a text view, but rather by recursively concatenating the text values of all children. This is not because this is how we decided to introduce that behavior on iOS, but because this is how React Native sees it (IIRC).

This is relevant on 2 levels:
1. `.toHaveText()` matching logic: Look up the text based on the view's textual children, recursively.
2. `text` attribute: Assign based on the children's texts, similarly.


Pull request description:
Android accessibility-label alignment with iOS
## Description

<!--
Thank you for contributing!

Step 1: Before submitting a pull request that introduces a new functionality or fixes a bug,
please open an issue where we could discuss the suggestion, problem - and potential ways to fix.
-->

- This pull request fixes #3977

<!--
Step 2: Provide an overview of how your fix / enhancement works.
If possible, provide screenshots of the before and after states (even for simple command line options - show the terminal).
-->

In this pull request, I have applied a more extensive algorithm for resolving a `View`'s `contentDescription` so as to bridge over the RN gap in https://github.com/facebook/react-native/issues/32826 (as explained in #3977).

<!--
Step 3: Please review the checklist below.
-->

---


> _For features/enhancements:_
 - [x] I have added/updated the relevant references in the [documentation](https://github.com/wix/Detox/tree/master/docs) files.

> _For API changes:_
 - [x] I have made the necessary changes in the [types index](https://github.com/wix/Detox/blob/master/detox/index.d.ts) file.


Patch:
diff --git a/detox/android/detox/src/full/java/com/wix/detox/common/UIExtensions.kt b/detox/android/detox/src/full/java/com/wix/detox/common/UIExtensions.kt
new file mode 100644
index 000000000..b867a3c8a
--- /dev/null
+++ b/detox/android/detox/src/full/java/com/wix/detox/common/UIExtensions.kt
@@ -0,0 +1,28 @@
+package com.wix.detox.common
+
+import android.view.View
+import android.view.ViewGroup
+
+fun View.forEachChild(callback: (child: View) -> Unit) {
+    if (this is ViewGroup) {
+        for (index in 0 until childCount) {
+            val child = getChildAt(index)
+            callback(child)
+        }
+    }
+}
+
+/**
+ * In-order traverse the view-hierarchy specified by a view, considered to be the hierarchy's root.
+ *
+ * @param view The hierarchy's root-view.
+ * @param callback A function to call per each view. Returning `false` from the callback indicates
+ *  a request to refrain from traversing the sub-hierarchy associated with the current view.
+ */
+fun traverseViewHierarchy(view: View, callback: (view: View) -> Boolean) {
+    if (callback(view)) {
+        view.forEachChild { child ->
+            traverseViewHierarchy(child, callback)
+        }
+    }
+}
diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxMatcher.java b/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxMatcher.java
index f57c457a7..31a23f0bf 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxMatcher.java
+++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxMatcher.java
@@ -12,15 +12,17 @@ import static androidx.test.espresso.matcher.ViewMatchers.isChecked;
 import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA;
 import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
 import static androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast;
+import static androidx.test.espresso.matcher.ViewMatchers.isFocused;
 import static androidx.test.espresso.matcher.ViewMatchers.isNotChecked;
 import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
 import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility;
 import static androidx.test.espresso.matcher.ViewMatchers.withTagValue;
 import static androidx.test.espresso.matcher.ViewMatchers.withText;
-import static androidx.test.espresso.matcher.ViewMatchers.isFocused;
 import static com.wix.detox.espresso.matcher.ViewMatchers.isMatchingAtIndex;
 import static com.wix.detox.espresso.matcher.ViewMatchers.isOfClassName;
 import static com.wix.detox.espresso.matcher.ViewMatchers.toHaveSliderPosition;
+import static com.wix.detox.espresso.matcher.ViewMatchers.withAccessibilityLabel;
+import static com.wix.detox.espresso.matcher.ViewMatchers.withShallowAccessibilityLabel;
 import static org.hamcrest.Matchers.allOf;
 import static org.hamcrest.Matchers.anyOf;
 import static org.hamcrest.Matchers.is;
@@ -43,6 +45,14 @@ public class DetoxMatcher {
         return allOf(withText(text), withEffectiveVisibility(Visibility.VISIBLE));
     }
 
+    public static Matcher<View> matcherForAccessibilityLabel(String label) {
+        return allOf(withAccessibilityLabel(label), withEffectiveVisibility(Visibility.VISIBLE));
+    }
+
+    public static Matcher<View> matcherForShallowAccessibilityLabel(String label) {
+        return allOf(withShallowAccessibilityLabel(label), withEffectiveVisibility(Visibility.VISIBLE));
+    }
+
     public static Matcher<View> matcherForContentDescription(String contentDescription) {
         return allOf(withContentDescription(contentDescription), withEffectiveVisibility(Visibility.VISIBLE));
     }
diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/action/GetAttributesAction.kt b/detox/android/detox/src/full/java/com/wix/detox/espresso/action/GetAttributesAction.kt
index eba9c480f..cb8c40cc7 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/espresso/action/GetAttributesAction.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/action/GetAttributesAction.kt
@@ -10,6 +10,7 @@ import androidx.test.espresso.UiController
 import com.google.android.material.slider.Slider
 import com.wix.detox.espresso.ViewActionWithResult
 import com.wix.detox.espresso.common.SliderHelper
+import com.wix.detox.reactnative.ui.getAccessibilityLabel
 import org.hamcrest.Matcher
 import org.hamcrest.Matchers
 import org.hamcrest.Matchers.allOf
@@ -47,7 +48,7 @@ private class CommonAttributes {
     fun get(json: JSONObject, view: View) {
         getId(json, view)
         getVisibility(json, view)
-        getContentDescription(json, view)
+        getAccessibilityLabel(json, view)
         getAlpha(json, view)
         getElevation(json, view)
         getHeight(json, view)
@@ -66,8 +67,8 @@ private class CommonAttributes {
         json.put("visible", view.getLocalVisibleRect(Rect()))
     }
 
-    private fun getContentDescription(json: JSONObject, view: View) =
-            view.contentDescription?.let {
+    private fun getAccessibilityLabel(json: JSONObject, view: View) =
+            view.getAccessibilityLabel()?.let {
                 json.put("label", it)
             }
 
diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/matcher/ViewMatchers.kt b/detox/android/detox/src/full/java/com/wix/detox/espresso/matcher/ViewMatchers.kt
index ee3243783..0184cf5de 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/espresso/matcher/ViewMatchers.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/matcher/ViewMatchers.kt
@@ -8,17 +8,20 @@ import androidx.test.espresso.matcher.BoundedMatcher
 import androidx.test.espresso.matcher.ViewMatchers
 import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility
 import com.wix.detox.espresso.common.SliderHelper
-import org.hamcrest.BaseMatcher
-import org.hamcrest.Description
-import org.hamcrest.Matcher
-import org.hamcrest.Matchers.allOf
-import org.hamcrest.TypeSafeMatcher
+import org.hamcrest.*
+import org.hamcrest.Matchers.*
 import kotlin.math.abs
 
 /*
  * An extension of [androidx.test.espresso.matcher.ViewMatchers].
  */
 
+fun withAccessibilityLabel(text: String) =
+    WithAccessibilityLabelMatcher(`is`(text))
+
+fun withShallowAccessibilityLabel(label: String): Matcher<View>
+    = anyOf(ViewMatchers.withContentDescription(label), ViewMatchers.withText(label))
+
 fun isOfClassName(className: String): Matcher<View> {
     try {
         val cls = Class.forName(className)
diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/matcher/WithAccessibilityLabelMatcher.kt b/detox/android/detox/src/full/java/com/wix/detox/espresso/matcher/WithAccessibilityLabelMatcher.kt
new file mode 100644
index 000000000..dcee401c8
--- /dev/null
+++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/matcher/WithAccessibilityLabelMatcher.kt
@@ -0,0 +1,23 @@
+package com.wix.detox.espresso.matcher
+
+import android.view.View
+import com.wix.detox.reactnative.ui.getAccessibilityLabel
+import org.hamcrest.Description
+import org.hamcrest.Matcher
+import org.hamcrest.TypeSafeDiagnosingMatcher
+
+class WithAccessibilityLabelMatcher(private val textMatcher: Matcher<String>): TypeSafeDiagnosingMatcher<View>() {
+    override fun matchesSafely(view: View, mismatchDescription: Description): Boolean =
+        view.getAccessibilityLabel().let { contentDescription ->
+            return textMatcher.matches(contentDescription).also { matched ->
+                if (!matched) {
+                    mismatchDescription.appendText("view.getAccessibilityLabel() ")
+                    textMatcher.describeMismatch(contentDescription, mismatchDescription)
+                }
+            }
+        }
+
+    override fun describeTo(description: Description) {
+        description.appendText("view.getAccessibilityLabel() ").appendDescriptionOf(textMatcher)
+    }
+}
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/ui/UIExtensions.kt b/detox/android/detox/src/full/java/com/wix/detox/reactnative/ui/UIExtensions.kt
new file mode 100644
index 000000000..c9f257861
--- /dev/null
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/ui/UIExtensions.kt
@@ -0,0 +1,37 @@
+package com.wix.detox.reactnative.ui
+
+import android.view.View
+import android.widget.TextView
+import com.wix.detox.common.traverseViewHierarchy
+import com.wix.detox.reactnative.utils.isReactNativeObject
+
+fun View.getAccessibilityLabel(
+    isReactNativeObjectFn: (Any) -> Boolean = { isReactNativeObject(it) }
+): CharSequence? =
+    if (isReactNativeObjectFn(this)) {
+        val subLabels = collectAccessibilityLabelsFromHierarchy(this)
+        if (subLabels.isEmpty()) null else subLabels.joinToString(" ")
+    } else {
+        getRawAccessibilityLabel(this)
+    }
+
+private fun collectAccessibilityLabelsFromHierarchy(
+    rootView: View,
+    subLabels: MutableList<CharSequence> = mutableListOf(),
+): List<CharSequence> {
+    traverseViewHierarchy(rootView) { view ->
+        getRawAccessibilityLabel(view)?.let { rawLabel ->
+            subLabels.add(rawLabel)
+            false
+        } ?: true
+
+    }
+    return subLabels
+}
+
+private fun getRawAccessibilityLabel(view: View): CharSequence? =
+    if (view.contentDescription != null) {
+        view.contentDescription
+    } else if (view is TextView) {
+        view.text
+    } else null
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/utils/RNUtils.kt b/detox/android/detox/src/full/java/com/wix/detox/reactnative/utils/RNUtils.kt
new file mode 100644
index 000000000..264fdf7ec
--- /dev/null
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/utils/RNUtils.kt
@@ -0,0 +1,6 @@
+package com.wix.detox.reactnative.utils
+
+private const val REACT_NATIVE_PACKAGE = "com.facebook.react"
+
+fun isReactNativeObject(obj: Any): Boolean =
+    obj.javaClass.canonicalName?.startsWith(REACT_NATIVE_PACKAGE) == true
diff --git a/detox/index.d.ts b/detox/index.d.ts
index 1101b9520..be741cf34 100644
--- a/detox/index.d.ts
+++ b/detox/index.d.ts
@@ -626,9 +626,8 @@ declare global {
 
         interface Device {
             /**
-             * Holds the environment-unique ID of the device - namely, the adb ID on Android (e.g. emulator-5554) and the Mac-global simulator UDID on iOS,
+             * Holds the environment-unique ID of the device, namely, the adb ID on Android (e.g. emulator-5554) and the Mac-global simulator UDID on iOS -
              * as used by simctl (e.g. AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE).
-             *
              */
             id: string;
             /**
@@ -1122,39 +1121,39 @@ declare global {
              * expectation with a `not` expects the view's visible area to be smaller than N%.
              * @param pct optional integer ranging from 1 to 100, indicating how much percent of the view should be
              *  visible to the user to be accepted.
-             * @example await expect(element(by.id('UniqueId204'))).toBeVisible(35);
+             * @example await expect(element(by.id('mainTitle'))).toBeVisible(35);
              */
             toBeVisible(pct?: number): R;
 
             /**
              * Negate the expectation.
-             * @example await expect(element(by.id('UniqueId205'))).not.toBeVisible();
+             * @example await expect(element(by.id('cancelButton'))).not.toBeVisible();
              */
             not: this;
 
             /**
              * Expect the view to not be visible.
-             * @example await expect(element(by.id('UniqueId205'))).toBeNotVisible();
+             * @example await expect(element(by.id('cancelButton'))).toBeNotVisible();
              * @deprecated Use `.not.toBeVisible()` instead.
              */
             toBeNotVisible(): R;
 
             /**
              * Expect the view to exist in the UI hierarchy.
-             * @example await expect(element(by.id('UniqueId205'))).toExist();
+             * @example await expect(element(by.id('okButton'))).toExist();
              */
             toExist(): R;
 
             /**
              * Expect the view to not exist in the UI hierarchy.
-             * @example await expect(element(by.id('RandomJunk959'))).toNotExist();
+             * @example await expect(element(by.id('cancelButton'))).toNotExist();
              * @deprecated Use `.not.toExist()` instead.
              */
             toNotExist(): R;
 
             /**
              * Expect the view to be focused.
-             * @example await expect(element(by.id('loginInput'))).toBeFocused();
+             * @example await expect(element(by.id('emailInput'))).toBeFocused();
              */
             toBeFocused(): R;
 
@@ -1168,21 +1167,23 @@ declare global {
             /**
              * In React Native apps, expect UI component of type <Text> to have text.
              * In native iOS apps, expect UI elements of type UIButton, UILabel, UITextField or UITextViewIn to have inputText with text.
-             * @example await expect(element(by.id('UniqueId204'))).toHaveText('I contain some text');
+             * @example await expect(element(by.id('mainTitle'))).toHaveText('Welcome back!);
              */
             toHaveText(text: string): R;
 
             /**
-             * It searches by accessibilityLabel on iOS, or by contentDescription on Android.
-             * In React Native it can be set for both platforms by defining an accessibilityLabel on the view.
-             * @example await expect(element(by.id('UniqueId204'))).toHaveLabel('Done');
+             * Expects a specific accessibilityLabel, as specified via the `accessibilityLabel` prop in React Native.
+             * On the native side (in both React Native and pure-native apps), that is equivalent to `accessibilityLabel`
+             * on iOS and contentDescription on Android. Refer to Detox's documentation in order to learn about caveats
+             * with accessibility-labels in React Native apps.
+             * @example await expect(element(by.id('submitButton'))).toHaveLabel('Submit');
              */
             toHaveLabel(label: string): R;
 
             /**
              * In React Native apps, expect UI component to have testID with that id.
              * In native iOS apps, expect UI element to have accessibilityIdentifier with that id.
-             * @example await expect(element(by.text('I contain some text'))).toHaveId('UniqueId204');
+             * @example await expect(element(by.text('Submit'))).toHaveId('submitButton');
              */
             toHaveId(id: string): R;
 
@@ -1195,7 +1196,7 @@ declare global {
 
             /**
              * Expect components like a Switch to have a value ('0' for off, '1' for on).
-             * @example await expect(element(by.id('UniqueId533'))).toHaveValue('0');
+             * @example await expect(element(by.id('temperatureDial'))).toHaveValue('25');
              */
             toHaveValue(value: any): R;
 
@@ -1212,7 +1213,7 @@ declare global {
             /**
              * This API polls using the given expectation continuously until the expectation is met. Use manual synchronization with waitFor only as a last resort.
              * NOTE: Every waitFor call must set a timeout using withTimeout(). Calling waitFor without setting a timeout will do nothing.
-             * @example await waitFor(element(by.id('UniqueId336'))).toExist().withTimeout(2000);
+             * @example await waitFor(element(by.id('bigButton'))).toExist().withTimeout(2000);
              */
             (element: NativeElement): Expect<WaitFor>;
         }
@@ -1220,13 +1221,13 @@ declare global {
         interface WaitFor {
             /**
              * Waits for the condition to be met until the specified time (millis) have elapsed.
-             * @example await waitFor(element(by.id('UniqueId336'))).toExist().withTimeout(2000);
+             * @example await waitFor(element(by.id('bigButton'))).toExist().withTimeout(2000);
              */
             withTimeout(millis: number): Promise<void>;
 
             /**
              * Performs the action repeatedly on the element until an expectation is met
-             * @example await waitFor(element(by.text('Text5'))).toBeVisible().whileElement(by.id('ScrollView630')).scroll(50, 'down');
+             * @example await waitFor(element(by.text('Item #5'))).toBeVisible().whileElement(by.id('itemsList')).scroll(50, 'down');
              */
             whileElement(by: NativeMatcher): NativeElement & WaitFor;
 
@@ -1438,7 +1439,7 @@ declare global {
         interface WebExpect<R = Promise<void>> {
             /**
              * Negate the expectation.
-             * @example await expect(web.element(by.web.id('UniqueId205'))).not.toExist();
+             * @example await expect(web.element(by.web.id('sessionTimeout'))).not.toExist();
              */
             not: this;
 
@@ -1446,13 +1447,13 @@ declare global {
              * Expect the element content to have the `text` supplied
              * @param text expected to be on the element content
              * @example
-             * await expect(web.element(by.web.id('UniqueId205'))).toHaveText('ExactText');
+             * await expect(web.element(by.web.id('checkoutButton'))).toHaveText('Proceed to check out');
              */
             toHaveText(text: string): R;
 
             /**
              * Expect the view to exist in the webview DOM tree.
-             * @example await expect(web.element(by.web.id('UniqueId205'))).toExist();
+             * @example await expect(web.element(by.web.id('submitButton'))).toExist();
              */
             toExist(): R;
         }
@@ -1651,7 +1652,9 @@ declare global {
              */
             text?: string;
             /**
-             * The label of the element. Matches accessibilityLabel for ios, and contentDescription for android.
+             * The label of the element. Largely matches accessibilityLabel for ios, and contentDescription for android.
+             * Refer to Detox's documentation (`toHaveLabel()` subsection) in order to learn about caveats associated with
+             * this property in React Native apps.
              */
             label?: string;
             /**
diff --git a/detox/src/android/core/NativeMatcher.js b/detox/src/android/core/NativeMatcher.js
index e03d81259..b5e98c5e6 100644
--- a/detox/src/android/core/NativeMatcher.js
+++ b/detox/src/android/core/NativeMatcher.js
@@ -1,3 +1,5 @@
+const { inspect } = require('util');
+
 const { DetoxRuntimeError } = require('../../errors');
 const invoke = require('../../invoke');
 const DetoxMatcherApi = require('../espressoapi/DetoxMatcher');
@@ -5,7 +7,7 @@ const DetoxMatcherApi = require('../espressoapi/DetoxMatcher');
 class NativeMatcher {
   static _assertMatcher(matcher) {
     if (!(matcher instanceof NativeMatcher)) {
-      throw new DetoxRuntimeError(`Expected a matcher, got ${typeof matcher}`);
+      throw new DetoxRuntimeError({ message: `Expected a matcher, got: ${inspect(matcher)}` });
     }
   }
 
diff --git a/detox/src/android/espressoapi/DetoxMatcher.js b/detox/src/android/espressoapi/DetoxMatcher.js
index efde8a12c..a9647b774 100644
--- a/detox/src/android/espressoapi/DetoxMatcher.js
+++ b/detox/src/android/espressoapi/DetoxMatcher.js
@@ -26,6 +26,30 @@ class DetoxMatcher {
     };
   }
 
+  static matcherForAccessibilityLabel(label) {
+    if (typeof label !== "string") throw new Error("label should be a string, but got " + (label + (" (" + (typeof label + ")"))));
+    return {
+      target: {
+        type: "Class",
+        value: "com.wix.detox.espresso.DetoxMatcher"
+      },
+      method: "matcherForAccessibilityLabel",
+      args: [label]
+    };
+  }
+
+  static matcherForShallowAccessibilityLabel(label) {
+    if (typeof label !== "string") throw new Error("label should be a string, but got " + (label + (" (" + (typeof label + ")"))));
+    return {
+      target: {
+        type: "Class",
+        value: "com.wix.detox.espresso.DetoxMatcher"
+      },
+      method: "matcherForShallowAccessibilityLabel",
+      args: [label]
+    };
+  }
+
   static matcherForContentDescription(contentDescription) {
     if (typeof contentDescription !== "string") throw new Error("contentDescription should be a string, but got " + (contentDescription + (" (" + (typeof contentDescription + ")"))));
     return {
diff --git a/detox/src/android/matchers/index.js b/detox/src/android/matchers/index.js
index a62a6cd5e..66859d13f 100644
--- a/detox/src/android/matchers/index.js
+++ b/detox/src/android/matchers/index.js
@@ -2,9 +2,9 @@ const native = require('./native');
 const web = require('./web');
 
 module.exports = {
-  accessibilityLabel: (value) => new native.LabelMatcher(value),
   id: (value) => new native.IdMatcher(value),
-  label: (value) => new native.LabelMatcher(value),
+  label: (value) => new native.ShallowLabelMatcher(value),
+  accessibilityLabel: (value) => new native.ShallowLabelMatcher(value),
   text: (value) => new native.TextMatcher(value),
   traits: (value) => new native.TraitsMatcher(value),
   type: (value) => new native.TypeMatcher(value),
diff --git a/detox/src/android/matchers/native.js b/detox/src/android/matchers/native.js
index 140092266..d45519a2b 100644
--- a/detox/src/android/matchers/native.js
+++ b/detox/src/android/matchers/native.js
@@ -6,7 +6,14 @@ const DetoxMatcherApi = require('../espressoapi/DetoxMatcher');
 class LabelMatcher extends NativeMatcher {
   constructor(value) {
     super();
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForContentDescription(value));
+    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForAccessibilityLabel(value));
+  }
+}
+
+class ShallowLabelMatcher extends NativeMatcher {
+  constructor(value) {
+    super();
+    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForShallowAccessibilityLabel(value));
   }
 }
 
@@ -90,6 +97,7 @@ class SliderPositionMatcher extends NativeMatcher {
 
 module.exports = {
   LabelMatcher,
+  ShallowLabelMatcher,
   IdMatcher,
   TypeMatcher,
   TraitsMatcher,
diff --git a/docs/api/actions.md b/docs/api/actions.md
index f0c83c633..10e50298f 100644
--- a/docs/api/actions.md
+++ b/docs/api/actions.md
@@ -277,7 +277,7 @@ Returns an object, representing various attributes of the element.
 Retrieved attributes are:
 
 - `text`: The text value of any textual element.
-- `label`: The label of the element. Matches `accessibilityLabel` for iOS, and `contentDescription` for android.
+- `label`: The label of the element. Matches `accessibilityLabel` for iOS, and `contentDescription` for android. Refer to the [`.toHaveLabel()` API](./expect.md#tohavelabellabel) in order to learn about caveats associated with this attribute in React Native apps.
 - `placeholder`: The placeholder text value of the element. Matches `hint` on android.
 - `enabled`: Whether the element is enabled for user interaction.
 - `identifier`: The identifier of the element. Matches `accessibilityIdentifier` on iOS, and the main view tag, on Android - both commonly **holding the component’s test ID in React Native apps**.
diff --git a/docs/api/expect.md b/docs/api/expect.md
index 440ac3ade..355c74ddc 100644
--- a/docs/api/expect.md
+++ b/docs/api/expect.md
@@ -28,8 +28,8 @@ Negating this expectation with a `not` expression expects the view’s visible a
 On iOS, visibility is defined by having the view, or one of its subviews, be topmost at the view’s activation point on screen.
 
 ```js
-await expect(element(by.id('UniqueId203'))).toBeVisible();
-await expect(element(by.id('UniqueId204'))).toBeVisible(35);
+await expect(element(by.id('subtitle'))).toBeVisible();
+await expect(element(by.id('mainTitle'))).toBeVisible(35);
 ```
 
 ### `toExist()`
@@ -37,7 +37,7 @@ await expect(element(by.id('UniqueId204'))).toBeVisible(35);
 Expects the element to exist within the app’s current UI hierarchy.
 
 ```js
-await expect(element(by.id('UniqueId205'))).toExist();
+await expect(element(by.id('okButton'))).toExist();
 ```
 
 ### `toBeFocused()`
@@ -45,7 +45,7 @@ await expect(element(by.id('UniqueId205'))).toExist();
 Expects the element to be the focused element.
 
 ```js
-await expect(element(by.id('textFieldId'))).toBeFocused();
+await expect(element(by.id('emailInput'))).toBeFocused();
 ```
 
 ### `toHaveText(text)`
@@ -53,7 +53,7 @@ await expect(element(by.id('textFieldId'))).toBeFocused();
 Expects the element to have the specified text.
 
 ```js
-await expect(element(by.id('UniqueId204'))).toHaveText('I contain some text');
+await expect(element(by.id('mainTitle'))).toHaveText('Welcome back!');
 ```
 
 ### `toHaveLabel(label)`
@@ -61,11 +61,23 @@ await expect(element(by.id('UniqueId204'))).toHaveText('I contain some text');
 Expects the element to have the specified label as its accessibility label (iOS) or content description (Android). In React Native, this corresponds to the value in the [`accessibilityLabel`](https://reactnative.dev/docs/accessibility#accessibilitylabel) prop.
 
 :::note
-Note that there is an inconsistency between the implementation for accessibility between Android and iOS. On iOS if a View has no `accessibilityLabel` explicitly defined, then it defaults to having a concatenation of the accessibilityLabels of the child Views. On Android, the same View would have no `accessibilityLabel` at all. See [this](https://github.com/facebook/react-native/issues/32826) issue for details.
+Note that in React Native apps, the `accessibilityLabel` is computed in a non-standard way, which happens to [differ between iOS and Android](https://github.com/facebook/react-native/issues/32826). Detox [bridges over that gap](https://github.com/wix/Detox/issues/3977) by artificially aligning Android to iOS.
+Effectively, that means that in React Native apps, performing accessibility-label based matching for elements **with no explicit label** suggests that the matching will be performed against a concatenation of labels from the child-elements, if applicable. For example:
+
+```js
+<View testID='title-root'>
+  <Text accessibilityLabel={'title'}>Goodbye!</Text>
+  <Text accessibilityLabel={'subtitle'}>Thanks for all the fish.</Text>
+</View>
+```
+
+In this case, where `title-root` has no accessibility label of its own, matching the label of `title-root` will be performed against the text: `title subtitle`.
+
+Also note that in iOS, `accessibilityLabel` for primitive elements such as text, automatically receives the text itself - even if the accessibilityLabel prop isn't explicitly specified.
 :::
 
 ```js
-await expect(element(by.id('UniqueId204'))).toHaveLabel('Done');
+await expect(element(by.id('submitButton'))).toHaveLabel('Submit');
 ```
 
 ### `toHaveId(id)`
@@ -73,7 +85,7 @@ await expect(element(by.id('UniqueId204'))).toHaveLabel('Done');
 Expects the element to have the specified accessibility identifier. In React Native, this corresponds to the value in the [`testID`](https://reactnative.dev/docs/view.html#testid) prop.
 
 ```js
-await expect(element(by.text('I contain some text'))).toHaveId('UniqueId204');
+await expect(element(by.text('Submit'))).toHaveId('submitButton');
 ```
 
 ### `toHaveValue(value)`
@@ -81,7 +93,7 @@ await expect(element(by.text('I contain some text'))).toHaveId('UniqueId204');
 Expects the element to have the specified accessibility value. In React Native, this corresponds to the value in the [`accessibilityValue`](https://reactnative.dev/docs/view.html#accessibilityvalue) prop.
 
 ```js
-await expect(element(by.id('UniqueId533'))).toHaveValue('0');
+await expect(element(by.id('temperatureDial'))).toHaveValue('25');
 ```
 
 ### `toHaveSliderPosition(normalizedPosition, tolerance)`
@@ -109,7 +121,7 @@ Waits until the expectation is resolved for the specified amount of time. If tim
 `timeout`—the timeout to wait, in ms
 
 ```js
-await waitFor(element(by.id('UniqueId204'))).toBeVisible().withTimeout(2000);
+await waitFor(element(by.id('bigButton'))).toBeVisible().withTimeout(2000);
 ```
 
 ## Properties
@@ -119,9 +131,9 @@ await waitFor(element(by.id('UniqueId204'))).toBeVisible().withTimeout(2000);
 Negates the expectation, e.g.:
 
 ```js
-await expect(element(by.id('UniqueId533'))).not.toBeVisible();
-await expect(element(by.id('UniqueId533'))).not.toExist();
-await expect(element(by.id('UniqueId533'))).not.toBeFocused();
-await expect(element(by.id('UniqueId533'))).not.toHaveText('');
-await expect(element(by.id('UniqueId533'))).not.toHaveValue('');
+await expect(element(by.id('tinyButton'))).not.toBeVisible();
+await expect(element(by.id('tinyButton'))).not.toExist();
+await expect(element(by.id('tinyButton'))).not.toBeFocused();
+await expect(element(by.id('tinyButton'))).not.toHaveText('');
+await expect(element(by.id('tinyButton'))).not.toHaveValue('');
 ```


Test patch:
diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/UTHelpers.kt b/detox/android/detox/src/testFull/java/com/wix/detox/UTHelpers.kt
index 044fb3094..b51c3ab98 100644
--- a/detox/android/detox/src/testFull/java/com/wix/detox/UTHelpers.kt
+++ b/detox/android/detox/src/testFull/java/com/wix/detox/UTHelpers.kt
@@ -1,8 +1,20 @@
 package com.wix.detox
 
+import android.view.View
+import android.view.ViewGroup
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.kotlin.whenever
 import java.util.concurrent.ExecutorService
 import java.util.concurrent.TimeUnit
 
 object UTHelpers {
     fun yieldToOtherThreads(executor: ExecutorService) = executor.awaitTermination(100L, TimeUnit.MILLISECONDS)
+
+    fun mockViewHierarchy(parent: ViewGroup, vararg children: View) {
+        whenever(parent.childCount).thenReturn(children.size)
+
+        children.forEachIndexed { index, view ->
+            whenever(parent.getChildAt(eq(index))).thenReturn(view)
+        }
+    }
 }
diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/common/UIExtensionsTest.kt b/detox/android/detox/src/testFull/java/com/wix/detox/common/UIExtensionsTest.kt
new file mode 100644
index 000000000..ab1669c2e
--- /dev/null
+++ b/detox/android/detox/src/testFull/java/com/wix/detox/common/UIExtensionsTest.kt
@@ -0,0 +1,107 @@
+package com.wix.detox.common
+
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import com.wix.detox.UTHelpers.mockViewHierarchy
+import com.wix.detox.reactnative.ui.getAccessibilityLabel
+import org.assertj.core.api.Assertions
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class UIExtensionsTest {
+    private val alwaysReactNativeObjFn: (Any) -> Boolean = { true }
+    private val neverReactNativeObjFn: (Any) -> Boolean = { false }
+
+    private fun withContentDescription(value: String, v: View) { whenever(v.contentDescription).doReturn(value) }
+    private fun withText(value: String, v: TextView) { whenever(v.text).doReturn(value) }
+
+    @Test
+    fun `should return accessibility label according to content-description`() {
+        val view: View = mock()
+
+        val contentDescription = "content-description-mock"
+        withContentDescription(contentDescription, view)
+
+        val label = view.getAccessibilityLabel()
+        Assertions.assertThat(label).isEqualTo(contentDescription)
+    }
+
+    @Test
+    fun `should return accessibility label according to children's content-description, recursively`() {
+        val contentDescription1st = "cd.1"
+        val contentDescription2nd = "cd.2"
+        val expectedLabel = "$contentDescription1st $contentDescription2nd"
+
+
+        val parent: ViewGroup = mock()
+        val sibling1: ViewGroup = mock()
+        val sibling2: ViewGroup = mock<ViewGroup>().also {
+            withContentDescription(contentDescription2nd, it)
+        }
+        val grandchild: View = mock<View>().also {
+            withContentDescription(contentDescription1st, it)
+        }
+
+        mockViewHierarchy(parent, sibling1, sibling2)
+        mockViewHierarchy(sibling1, grandchild)
+
+        val label = parent.getAccessibilityLabel(alwaysReactNativeObjFn)
+        Assertions.assertThat(label).isEqualTo(expectedLabel)
+    }
+
+    @Test
+    fun `should return accessibility label according to children's text, on top of label`() {
+        val text = "some mocked text"
+
+        val parent: ViewGroup = mock()
+        val grandchild: TextView = mock<TextView>().also {
+            withText(text, it)
+        }
+        mockViewHierarchy(parent, grandchild)
+
+        val label = parent.getAccessibilityLabel(alwaysReactNativeObjFn)
+        Assertions.assertThat(label).isEqualTo(text)
+    }
+
+    @Test
+    fun `should not return accessibility label if content description not set in view nor its descendants`() {
+        val parent: ViewGroup = mock()
+        val child: View = mock()
+
+        mockViewHierarchy(parent, child)
+
+        val label = parent.getAccessibilityLabel(alwaysReactNativeObjFn)
+        Assertions.assertThat(label).isNull()
+    }
+
+    @Test
+    fun `should not return accessibility label based on children for non-RN views`() {
+        val childContentDescription = "content-description-mock"
+
+        val parent: ViewGroup = mock()
+        val child: View = mock<View>().also {
+            withContentDescription(childContentDescription, it)
+        }
+        mockViewHierarchy(parent, child)
+
+        val label = parent.getAccessibilityLabel(neverReactNativeObjFn)
+        Assertions.assertThat(label).isNull()
+    }
+
+    @Test
+    fun `should return accessibility label for non-RN views`() {
+        val view: View = mock()
+
+        val contentDescription = "content-description-mock"
+        withContentDescription(contentDescription, view)
+
+        val label = view.getAccessibilityLabel(neverReactNativeObjFn)
+        Assertions.assertThat(label).isEqualTo(contentDescription)
+    }
+}
diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/action/GetAttributesActionTest.kt b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/action/GetAttributesActionTest.kt
index bef4a4e60..4bf15ea53 100644
--- a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/action/GetAttributesActionTest.kt
+++ b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/action/GetAttributesActionTest.kt
@@ -6,6 +6,7 @@ import android.widget.ProgressBar
 import android.widget.TextView
 import com.facebook.react.views.slider.ReactSlider
 import com.google.android.material.slider.Slider
+import com.wix.detox.reactnative.ui.getAccessibilityLabel
 import org.assertj.core.api.Assertions.assertThat
 import org.json.JSONObject
 import org.junit.Before
@@ -32,7 +33,7 @@ class GetAttributesActionTest {
     private fun givenNoViewTag() = givenViewTag(null)
     private fun givenVisibility(value: Int) { whenever(view.visibility).doReturn(value) }
     private fun givenVisibilityRectAvailability(value: Boolean) { whenever(view.getLocalVisibleRect(any())).doReturn(value) }
-    private fun givenContentDescription(value: String) { whenever(view.contentDescription).doReturn(value) }
+    private fun givenAccessibilityLabel(value: String) { whenever(view.getAccessibilityLabel()).doReturn(value) }
 
     private fun perform(v: View = view): JSONObject {
         uut.perform(null, v)
@@ -110,16 +111,16 @@ class GetAttributesActionTest {
     }
 
     @Test
-    fun `should return label according to content-description`() {
-        val contentDescription = "content-description-mock"
-        givenContentDescription(contentDescription)
+    fun `should return label according to accessibilityLabel extension`() {
+        val accessibilityLabel = "label-mock"
+        givenAccessibilityLabel(accessibilityLabel)
 
         val resultJson = perform()
-        assertThat(resultJson.opt("label")).isEqualTo(contentDescription)
+        assertThat(resultJson.opt("label")).isEqualTo(accessibilityLabel)
     }
 
     @Test
-    fun `should not return label if content description no set`() {
+    fun `should not return label if accessibility label is not available`() {
         val resultJson = perform()
         assertThat(resultJson.opt("label")).isNull()
     }
diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/matcher/ViewAtIndexMatcherSpec.kt b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/matcher/ViewAtIndexMatcherSpec.kt
index b726d591e..e8f8c1c81 100644
--- a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/matcher/ViewAtIndexMatcherSpec.kt
+++ b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/matcher/ViewAtIndexMatcherSpec.kt
@@ -3,7 +3,6 @@ package com.wix.detox.espresso.matcher
 import android.view.View
 import org.hamcrest.Description
 import org.hamcrest.Matcher
-import org.hamcrest.Matchers
 import org.mockito.kotlin.mock
 import org.mockito.kotlin.verify
 import org.mockito.kotlin.whenever
@@ -28,7 +27,7 @@ object ViewAtIndexMatcherSpec: Spek({
                 uut.describeTo(description)
                 verify(description).appendText("View at index #0, of those matching MATCHER(innerMatcher description)")
             }
-            
+
             it("should append a valid description for index≥0") {
                 val uut = ViewAtIndexMatcher(7, innerMatcher)
                 uut.describeTo(description)
diff --git a/detox/src/android/AndroidExpect.test.js b/detox/src/android/AndroidExpect.test.js
index e1e4b3dcd..01dbe95df 100644
--- a/detox/src/android/AndroidExpect.test.js
+++ b/detox/src/android/AndroidExpect.test.js
@@ -136,10 +136,11 @@ describe('AndroidExpect', () => {
       jestExpect(() => e.by.value(0)).toThrow();
       jestExpect(() => e.by.text(0)).toThrow();
 
-      jestExpect(() => e.element(e.by.id('test').withAncestor('notAMatcher'))).toThrow('Expected a matcher, got string');
-      jestExpect(() => e.element(e.by.id('test').withDescendant('notAMatcher'))).toThrow('Expected a matcher, got string');
-      jestExpect(() => e.element(e.by.id('test').and('notAMatcher'))).toThrow('Expected a matcher, got string');
-      jestExpect(() => e.element(e.by.id('test').or('notAMatcher'))).toThrow('Expected a matcher, got string');
+      const expectedErrorMsg = 'Expected a matcher, got: \'notAMatcher\'';
+      jestExpect(() => e.element(e.by.id('test').withAncestor('notAMatcher'))).toThrow(expectedErrorMsg);
+      jestExpect(() => e.element(e.by.id('test').withDescendant('notAMatcher'))).toThrow(expectedErrorMsg);
+      jestExpect(() => e.element(e.by.id('test').and('notAMatcher'))).toThrow(expectedErrorMsg);
+      jestExpect(() => e.element(e.by.id('test').or('notAMatcher'))).toThrow(expectedErrorMsg);
     });
 
     it(`waitFor (element)`, async () => {
diff --git a/detox/test/e2e/04.assertions.test.js b/detox/test/e2e/04.assertions.test.js
index 7508edb2c..f2997536e 100644
--- a/detox/test/e2e/04.assertions.test.js
+++ b/detox/test/e2e/04.assertions.test.js
@@ -4,6 +4,7 @@ const driver = {
     await element(by.text('Assertions')).tap();
   },
   get textElement() { return element(by.id('main-text')) },
+  get subtextElement() { return element(by.id('subtext-root')) },
   get invisibleTextElement() { return element(by.id('offscreen-text')) },
   get toggleElement() { return element(by.id('toggle')) },
 }
@@ -37,6 +38,10 @@ describe('Assertions', () => {
     await expect(driver.textElement).toHaveLabel('I contain some text');
   });
 
+  it('should assert a sub-element has a computed (accessibility) label', async () => {
+    await expect(driver.subtextElement).toHaveLabel('This is some subtext');
+  });
+
   it('should assert an element has (accessibility) id', async () => {
     await expect(element(by.text('I contain some text'))).toHaveId('main-text');
   });
diff --git a/detox/test/e2e/33.attributes.test.js b/detox/test/e2e/33.attributes.test.js
index 4b1f07f60..eec8e0bde 100644
--- a/detox/test/e2e/33.attributes.test.js
+++ b/detox/test/e2e/33.attributes.test.js
@@ -12,16 +12,14 @@ describe('Attributes', () => {
   /**
    * @param {Detox.NativeMatcher} matcher
    */
-  function useMatcher(matcher) {
-    return async () => {
-      currentElement = element(matcher);
-      const result = await currentElement.getAttributes();
-      if ('elements' in result) {
-        attributesArray = result.elements;
-      } else {
-        attributes = await result;
-      }
-    };
+  async function useMatcher(matcher) {
+    currentElement = element(matcher);
+    const result = await currentElement.getAttributes();
+    if ('elements' in result) {
+      attributesArray = result.elements;
+    } else {
+      attributes = await result;
+    }
   }
 
   beforeAll(async () => {
@@ -30,7 +28,7 @@ describe('Attributes', () => {
   });
 
   describe('of a view', () => {
-    beforeAll(useMatcher(by.id('viewId')));
+    beforeAll(() => useMatcher(by.id('viewId')));
 
     it('should have the corresponding shape', () =>
       expect(attributes).toMatchObject({
@@ -60,7 +58,7 @@ describe('Attributes', () => {
   describe('of a text', () => {
     const EXPECTED_TEXT = 'TextView';
 
-    beforeAll(useMatcher(by.id('textViewId')));
+    beforeAll(() => useMatcher(by.id('textViewId')));
 
     it('should have the corresponding shape', () => {
       expect(attributes).toMatchObject({
@@ -89,9 +87,19 @@ describe('Attributes', () => {
     });
   });
 
+  describe('of a text group', () => {
+    const EXPECTED_TEXT = 'InnerText1 InnerText2';
+
+    beforeAll(() => useMatcher(by.id('textGroupRoot')));
+
+    it('should have a label based on text concatenation', () => {
+      expect(attributes).toMatchObject({ label: EXPECTED_TEXT });
+    });
+  });
+
   describe('of a text input', () => {
     describe('(blurred)', () => {
-      beforeAll(useMatcher(by.id('blurredTextInputId')));
+      beforeAll(() => useMatcher(by.id('blurredTextInputId')));
 
       it('should have the corresponding attributes', () => {
         expect(attributes).toMatchObject({
@@ -108,7 +116,7 @@ describe('Attributes', () => {
     });
 
     describe('(focused)', () => {
-      beforeAll(useMatcher(by.id('focusedTextInputId')));
+      beforeAll(() => useMatcher(by.id('focusedTextInputId')));
 
       it('should have the corresponding attributes', () => {
         expect(attributes).toMatchObject({
@@ -126,7 +134,7 @@ describe('Attributes', () => {
   });
 
   describe('of a checkbox', () => {
-    beforeAll(useMatcher(by.id('checkboxId')));
+    beforeAll(() => useMatcher(by.id('checkboxId')));
 
     it(':ios: should have a string .value', async () => {
       expect(await currentElement.getAttributes()).toMatchObject({
@@ -148,7 +156,7 @@ describe('Attributes', () => {
   });
 
   describe('of a legacy slider', () => {
-    beforeAll(useMatcher(by.id('legacySliderId')));
+    beforeAll(() => useMatcher(by.id('legacySliderId')));
 
     it(':ios: should have a string percent .value, and .normalizedSliderPosition', () => {
       expect(attributes).toMatchObject({ value: '50%', normalizedSliderPosition: 0.5 });
@@ -160,7 +168,7 @@ describe('Attributes', () => {
   });
 
   describe('of a slider', () => {
-    beforeAll(useMatcher(by.id('sliderId')));
+    beforeAll(() => useMatcher(by.id('sliderId')));
 
     it(':ios: should have a string percent .value, and .normalizedSliderPosition', () => {
       expect(attributes).toMatchObject({ value: '50%', normalizedSliderPosition: 0.5 });
@@ -172,7 +180,7 @@ describe('Attributes', () => {
   });
 
   describe('of a date picker', () => {
-    beforeAll(useMatcher(by.id('attrDatePicker')));
+    beforeAll(() => useMatcher(by.id('attrDatePicker')));
 
     it(':ios: should have Date .value', () => {
       expect(attributes).toMatchObject({
@@ -182,7 +190,7 @@ describe('Attributes', () => {
   });
 
   describe('of a scroll view', () => {
-    beforeAll(useMatcher(by.type('RCTCustomScrollView').withAncestor(by.id('attrScrollView'))));
+    beforeAll(() => useMatcher(by.type('RCTCustomScrollView').withAncestor(by.id('attrScrollView'))));
 
     it(':ios: should have offsets and insets', async () => {
       expect(attributes).toMatchObject({
@@ -195,7 +203,7 @@ describe('Attributes', () => {
 
   describe('of multiple views', () => {
     describe(':ios:', () => {
-      beforeAll(useMatcher(by.type('RCTView').withAncestor(by.id('attrScrollView'))));
+      beforeAll(() => useMatcher(by.type('RCTView').withAncestor(by.id('attrScrollView'))));
 
       it('should return an object with .elements array', async () => {
         const viewShape = {
diff --git a/detox/test/src/Screens/AssertionsScreen.js b/detox/test/src/Screens/AssertionsScreen.js
index 6fc65b5c3..1b576d142 100644
--- a/detox/test/src/Screens/AssertionsScreen.js
+++ b/detox/test/src/Screens/AssertionsScreen.js
@@ -5,36 +5,39 @@ import {
   Switch,
 } from 'react-native';
 
-class TestSwitchWidget extends Component {
+export default class AssertionsScreen extends Component {
   constructor(props) {
     super(props);
     this.state = {
-      value: false
+      switchValue: false
     }
-    this.onChange = () => this.setState({ value: !this.state.value });
   }
 
   render() {
-    return <Switch testID={this.props.testID} onChange={this.onChange} value={this.state.value}/>
+    return (
+      <View style={{flex: 1, paddingTop: 20, justifyContent: 'center', alignItems: 'center'}}>
+        <Text testID='main-text' style={{marginBottom: 20}} accessibilityLabel={'I contain some text'}>I contain some text</Text>
+        <View testID='subtext-root' style={{marginBottom: 20}}>
+          <Text accessibilityLabel={'This is some'}>This is some</Text>
+          <Text accessibilityLabel={'subtext'}>subtext</Text>
+        </View>
+        <Text testID='offscreen-text' style={{marginBottom: 20, position: 'absolute', left: -200}}>I am not visible</Text>
+        <TestSwitchWidget testID='toggle'/>
+      </View>
+    );
   }
 }
 
-export default class AssertionsScreen extends Component {
-
+class TestSwitchWidget extends Component {
   constructor(props) {
     super(props);
     this.state = {
-      switchValue: false
+      value: false
     }
+    this.onChange = () => this.setState({ value: !this.state.value });
   }
 
   render() {
-    return (
-      <View style={{flex: 1, paddingTop: 20, justifyContent: 'center', alignItems: 'center'}}>
-        <Text testID='main-text' style={{marginBottom: 20}} accessibilityLabel={'I contain some text'}>I contain some text</Text>
-        <Text testID='offscreen-text' style={{marginBottom: 20, position: 'absolute', left: -200}}>I am not visible</Text>
-        <TestSwitchWidget testID='toggle'/>
-      </View>
-    );
+    return <Switch testID={this.props.testID} onChange={this.onChange} value={this.state.value}/>
   }
 }
diff --git a/detox/test/src/Screens/AttributesScreen.js b/detox/test/src/Screens/AttributesScreen.js
index 71c441d73..762fd1c74 100644
--- a/detox/test/src/Screens/AttributesScreen.js
+++ b/detox/test/src/Screens/AttributesScreen.js
@@ -36,6 +36,10 @@ export default class AttributesScreen extends Component {
           </ScrollView>
         </View>
         <Text testID={'textViewId'} accessibilityLabel={'TextView'}>TextView</Text>
+        <View testID={'textGroupRoot'} marginLeft={20} >
+          <Text accessibilityLabel={'InnerText1'}>Some inner text</Text>
+          <Text accessibilityLabel={'InnerText2'}>Some more inner text</Text>
+        </View>
         <CheckBox
           disabled={false}
           testID={'checkboxId'}