iOS and Android RTL Layout Testing: Automation and Visual Validation

iOS and Android RTL Layout Testing: Automation and Visual Validation

Mobile RTL layout testing is distinct from web RTL testing. On mobile, the OS itself flips the layout direction — constraints, gravity attributes, and flexbox-equivalent APIs all need to respond correctly. This guide focuses on automated testing of RTL layouts on iOS (XCUITest) and Android (Espresso), plus cross-platform approaches with Appium.

iOS: RTL Testing with XCUITest

On iOS, RTL mode is activated by setting the device language to Arabic, Hebrew, or Farsi, or by using environment variables in test schemes:

// XCTestCase setup for RTL
override func setUp() {
    super.setUp()
    app = XCUIApplication()
    app.launchArguments += ["-AppleLanguages", "(ar)", "-AppleLocale", "ar_SA"]
    app.launch()
}

Testing Layout Mirroring

In RTL mode, UI elements should mirror horizontally. Test that the leading element is on the right:

func testNavigationBarBackButtonPositionRTL() {
    let backButton = app.buttons["NavigationBarBackButton"]
    let screenWidth = app.windows.firstMatch.frame.width

    // In RTL, back button should be on the right side
    let buttonMidX = backButton.frame.midX
    XCTAssertGreaterThan(buttonMidX, screenWidth / 2,
        "Back button should be on right side in RTL")
}

func testListCellContentLayoutRTL() {
    let firstCell = app.cells.firstMatch
    let titleLabel = firstCell.staticTexts["CellTitle"]
    let accessoryIcon = firstCell.images["CellAccessoryIcon"]

    // In RTL, title should be on right, accessory icon on left
    XCTAssertGreaterThan(titleLabel.frame.minX, accessoryIcon.frame.maxX,
        "Title should appear to the left of accessory in RTL (visually right)")
}

Testing Text Alignment

Arabic text must be right-aligned. Test via accessibility traits, not by checking pixels:

func testSearchFieldTextAlignmentRTL() {
    let searchField = app.searchFields.firstMatch
    searchField.tap()
    searchField.typeText("مرحبا")

    // Verify the field is accessible and contains the text
    XCTAssertEqual(searchField.value as? String, "مرحبا")

    // Check the text field has RTL layout direction
    let fieldFrame = searchField.frame
    // Cursor should be at the right edge after typing in RTL
    XCTAssertTrue(fieldFrame.width > 0)
}

Screenshot Comparison for RTL Mirroring

Use XCTest attachments to capture and compare RTL vs LTR screenshots:

func testHomeScreenRTLMirrored() {
    let screenshot = app.screenshot()
    let attachment = XCTAttachment(screenshot: screenshot)
    attachment.name = "HomeScreen-RTL-Arabic"
    attachment.lifetime = .keepAlways
    add(attachment)

    // Compare against stored baseline using XCTAssert on specific element positions
    let logo = app.images["AppLogo"]
    let settingsButton = app.buttons["SettingsButton"]

    // Logo typically left-aligned in LTR → right-aligned in RTL
    XCTAssertGreaterThan(logo.frame.minX,
        app.windows.firstMatch.frame.width * 0.5)
}

Android: RTL Testing with Espresso

Android supports RTL via android:supportsRtl="true" in the manifest and layoutDirection attributes. Test with the RTL-specific matchers:

// Espresso RTL test
@RunWith(AndroidJUnit4::class)
class RtlLayoutTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    private fun launchInArabic() {
        val locale = Locale("ar")
        Locale.setDefault(locale)
        val config = Configuration()
        config.setLocale(locale)
        InstrumentationRegistry.getInstrumentation().targetContext
            .resources.updateConfiguration(config, null)
    }

    @Test
    fun testToolbarNavIconPositionRtl() {
        launchInArabic()

        // In RTL, navigation icon should be on right side
        onView(withId(R.id.toolbar))
            .check(matches(hasDescendant(withId(R.id.nav_icon))))

        // Verify layout direction
        onView(withId(R.id.toolbar))
            .check(matches(withLayoutDirection(View.LAYOUT_DIRECTION_RTL)))
    }

    @Test
    fun testRecyclerItemMirroredInRtl() {
        launchInArabic()

        onView(withId(R.id.recycler_view))
            .perform(scrollToPosition<RecyclerView.ViewHolder>(0))

        // Check that leading content (avatar) appears on right in RTL
        onView(withId(R.id.item_avatar))
            .check { view, _ ->
                val parent = view.parent as View
                val parentRight = parent.width
                // Avatar should be in right half in RTL
                assertTrue(view.left + view.width / 2 > parentRight / 2)
            }
    }
}

Testing Gravity and Padding

Android's start/end attributes should flip in RTL. Test that paddingStart becomes paddingRight in RTL:

@Test
fun testPaddingDirectionRtl() {
    launchInArabic()

    onView(withId(R.id.content_container))
        .check { view, _ ->
            val paddingStart = view.paddingStart
            val paddingEnd = view.paddingEnd
            // In RTL: paddingStart is the right padding, paddingEnd is left
            assertEquals("Right padding should match design spec",
                view.resources.getDimensionPixelSize(R.dimen.content_padding_start),
                view.paddingRight)
        }
}

Screenshot Testing with Shot or Paparazzi

For pixel-level RTL screenshot comparison, use Paparazzi:

@RunWith(AndroidJUnit4::class)
class RtlSnapshotTest {

    @get:Rule
    val paparazzi = Paparazzi(
        deviceConfig = DeviceConfig.PIXEL_5.copy(
            layoutDirection = LayoutDirection.RTL,
            locale = "ar"
        )
    )

    @Test
    fun testProductCardRtl() {
        paparazzi.snapshot {
            ProductCard(
                title = "منتج رائع",
                price = "١٢٣ ر.س",
                imageUrl = null
            )
        }
    }
}

Run ./gradlew recordPaparazziDebug to create baselines, then ./gradlew verifyPaparazziDebug in CI.

Appium Cross-Platform RTL Tests

Appium lets you write one test that runs on both iOS and Android:

# tests/test_rtl_appium.py
import pytest
from appium import webdriver
from appium.options import XCUITestOptions, UiAutomator2Options

@pytest.fixture(params=['ios', 'android'])
def rtl_driver(request):
    if request.param == 'ios':
        options = XCUITestOptions()
        options.platform_name = 'iOS'
        options.device_name = 'iPhone 15'
        options.app = 'path/to/App.app'
        options.language = 'ar'
        options.locale = 'ar_SA'
    else:
        options = UiAutomator2Options()
        options.platform_name = 'Android'
        options.device_name = 'emulator-5554'
        options.app = 'path/to/app.apk'
        options.language = 'ar'
        options.locale = 'ar_SA'

    driver = webdriver.Remote('http://localhost:4723', options=options)
    yield driver
    driver.quit()

def test_back_button_visible_in_rtl(rtl_driver):
    rtl_driver.find_element('accessibility id', 'HomeScreen')
    back_btn = rtl_driver.find_element('accessibility id', 'Back')
    screen_size = rtl_driver.get_window_size()

    btn_x = back_btn.location['x']
    # In RTL, back button should be in the right half of screen
    assert btn_x > screen_size['width'] / 2, \
        f"Back button at x={btn_x}, expected right half (width={screen_size['width']})"

CI Setup for RTL Testing

Run RTL tests in CI using simulators/emulators configured for Arabic:

# .github/workflows/rtl-tests.yml
jobs:
  ios-rtl:
    runs-on: macos-14
    steps:
      - name: Run RTL UI tests
        run: |
          xcodebuild test \
            -scheme MyApp \
            -destination 'platform=iOS Simulator,name=iPhone 15,OS=17.0' \
            -testPlan RTLTests \
            -resultBundlePath TestResults.xcresult

  android-rtl:
    runs-on: ubuntu-latest
    steps:
      - name: Run Paparazzi RTL snapshots
        run: ./gradlew verifyPaparazziDebug

RTL Test Coverage Checklist

  • Navigation: back/forward buttons mirror correctly
  • Lists: leading content (avatars, icons) appears on correct side
  • Form fields: text alignment, placeholder direction
  • Modals/drawers: slide in from correct side
  • Chevrons and directional icons: point in RTL-correct direction
  • Progress bars and sliders: fill direction reverses
  • Padding/margin start/end attributes tested explicitly
  • Screenshot baselines exist for Arabic and Hebrew locales

Mobile RTL failures are often invisible in LTR development. Automating screenshot comparison and position assertions against Arabic/Hebrew locales catches these before users do.

Read more