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 verifyPaparazziDebugRTL 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/endattributes 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.