Mobile Accessibility Testing
Mobile Accessibility Is Not Web Accessibility
Mobile accessibility is not a subset of web accessibility. Touch interfaces, screen readers, and device-specific features introduce entirely different requirements. A web page that passes WCAG AA with flying colors may be completely inaccessible when loaded in a mobile WebView that lacks proper ARIA bridge support.
VoiceOver (iOS) and TalkBack (Android)
Screen readers on mobile work differently from desktop screen readers. Users navigate by swiping (linear navigation) or by exploring (touching the screen to hear what is under their finger). Both patterns must work.
Testing Screen Reader Navigation Order
# Testing screen reader accessibility
def test_voiceover_navigation_order(driver):
"""Verify that VoiceOver reads elements in logical order."""
# Enable accessibility inspection
elements = driver.find_elements(AppiumBy.CLASS_NAME, "XCUIElementTypeAny")
# Filter to accessible elements
accessible = [
{
"label": el.get_attribute("label"),
"trait": el.get_attribute("trait"),
"value": el.get_attribute("value"),
"frame": el.rect,
}
for el in elements
if el.get_attribute("accessible") == "true"
]
# Verify reading order follows visual layout (top-to-bottom, left-to-right)
for i in range(len(accessible) - 1):
current = accessible[i]
next_el = accessible[i + 1]
# Next element should be below or to the right
assert (
next_el["frame"]["y"] > current["frame"]["y"] or
(next_el["frame"]["y"] == current["frame"]["y"] and
next_el["frame"]["x"] >= current["frame"]["x"])
), f"Reading order violation: '{current['label']}' before '{next_el['label']}'"
Testing Accessibility Labels
def test_all_interactive_elements_have_labels(driver):
"""Every interactive element must have an accessibility label."""
buttons = driver.find_elements(AppiumBy.CLASS_NAME, "XCUIElementTypeButton")
text_fields = driver.find_elements(AppiumBy.CLASS_NAME, "XCUIElementTypeTextField")
switches = driver.find_elements(AppiumBy.CLASS_NAME, "XCUIElementTypeSwitch")
for element in buttons + text_fields + switches:
label = element.get_attribute("label") or element.get_attribute("name")
assert label and len(label) > 0, \
f"Interactive element at ({element.rect}) has no accessibility label"
# Labels should not be technical identifiers
assert not label.startswith("btn_"), \
f"Label '{label}' looks like a technical ID, not a human description"
assert not label.startswith("ic_"), \
f"Label '{label}' looks like an icon identifier, not a description"
def test_images_have_content_descriptions(driver):
"""All meaningful images must have content descriptions (Android)."""
images = driver.find_elements(AppiumBy.CLASS_NAME, "android.widget.ImageView")
for img in images:
content_desc = img.get_attribute("contentDescription")
# Decorative images can have empty descriptions
# but must explicitly set importantForAccessibility="no"
important = img.get_attribute("importantForAccessibility")
if important != "no":
assert content_desc and len(content_desc) > 0, \
f"Image at ({img.rect}) has no content description"
Touch Target Sizing
WCAG 2.2 requires a minimum touch target size of 24x24 CSS pixels. Apple recommends 44x44 points. Google recommends 48x48 dp. These are minimum sizes -- larger targets are always better.
def test_minimum_touch_target_size(driver):
"""All interactive elements must meet minimum touch target requirements."""
# Platform-specific minimum sizes
platform = driver.capabilities.get("platformName", "").lower()
min_size = 44 if platform == "ios" else 48 # points (iOS) or dp (Android)
interactive_elements = (
driver.find_elements(AppiumBy.CLASS_NAME, "XCUIElementTypeButton") +
driver.find_elements(AppiumBy.CLASS_NAME, "XCUIElementTypeLink") +
driver.find_elements(AppiumBy.CLASS_NAME, "XCUIElementTypeSwitch")
)
violations = []
for el in interactive_elements:
rect = el.rect
if rect["width"] < min_size or rect["height"] < min_size:
label = el.get_attribute("label") or "unknown"
violations.append(
f"'{label}' is {rect['width']}x{rect['height']}, "
f"minimum is {min_size}x{min_size}"
)
assert len(violations) == 0, \
f"Touch target violations:\n" + "\n".join(violations)
def test_touch_targets_have_adequate_spacing(driver):
"""Touch targets should have at least 8dp spacing between them."""
min_spacing = 8
buttons = driver.find_elements(AppiumBy.CLASS_NAME, "XCUIElementTypeButton")
rects = [(b, b.rect) for b in buttons if b.is_displayed()]
violations = []
for i, (el_a, rect_a) in enumerate(rects):
for el_b, rect_b in rects[i+1:]:
# Calculate gap between elements
h_gap = max(0, max(rect_b["x"] - (rect_a["x"] + rect_a["width"]),
rect_a["x"] - (rect_b["x"] + rect_b["width"])))
v_gap = max(0, max(rect_b["y"] - (rect_a["y"] + rect_a["height"]),
rect_a["y"] - (rect_b["y"] + rect_b["height"])))
if h_gap < min_spacing and v_gap < min_spacing:
label_a = el_a.get_attribute("label") or "?"
label_b = el_b.get_attribute("label") or "?"
violations.append(
f"'{label_a}' and '{label_b}' are only "
f"{min(h_gap, v_gap)}dp apart (minimum {min_spacing}dp)"
)
assert len(violations) == 0, \
f"Spacing violations:\n" + "\n".join(violations[:10])
Dynamic Type (iOS) and Font Scaling (Android)
Users who increase system font size must not be punished with broken layouts. This is both an accessibility requirement and a legal obligation.
def test_dynamic_type_does_not_break_layout(driver):
"""App must remain usable at largest dynamic type setting."""
# Set accessibility font scale to maximum
if driver.capabilities["platformName"] == "iOS":
# iOS Dynamic Type: AX_EXTRA_EXTRA_EXTRA_LARGE
driver.execute_script("mobile: configureLocalization", {
"accessibilityPreferences": {
"preferredContentSizeCategory": "UICTContentSizeCategoryAccessibilityExtraExtraExtraLarge"
}
})
else:
# Android: maximum font scale is typically 2.0
driver.execute_script("mobile: shell", {
"command": "settings",
"args": ["put", "system", "font_scale", "2.0"]
})
# Navigate through critical screens
screens = ["home", "search", "product-detail", "cart", "checkout"]
for screen_id in screens:
driver.find_element(AppiumBy.ACCESSIBILITY_ID, f"{screen_id}-tab").click()
# Verify no text is clipped (truncated with ...)
text_elements = driver.find_elements(AppiumBy.CLASS_NAME, "XCUIElementTypeStaticText")
for text_el in text_elements:
label = text_el.get_attribute("label") or ""
# Check that critical text is not truncated
assert not label.endswith("...") or len(label) > 20, \
f"Text appears truncated at large font: '{label}' on {screen_id}"
# Verify no overlapping elements
all_rects = [el.rect for el in text_elements if el.is_displayed()]
for i, r1 in enumerate(all_rects):
for r2 in all_rects[i+1:]:
overlap = (
r1["x"] < r2["x"] + r2["width"] and
r1["x"] + r1["width"] > r2["x"] and
r1["y"] < r2["y"] + r2["height"] and
r1["y"] + r1["height"] > r2["y"]
)
if overlap:
overlap_area = (
min(r1["x"]+r1["width"], r2["x"]+r2["width"]) - max(r1["x"], r2["x"])
) * (
min(r1["y"]+r1["height"], r2["y"]+r2["height"]) - max(r1["y"], r2["y"])
)
assert overlap_area < 100, \
f"Significant element overlap detected on {screen_id} screen"
Mobile Accessibility Checklist
| Check | iOS | Android | Priority |
|---|---|---|---|
| All interactive elements labeled | VoiceOver reads label | TalkBack reads contentDescription | Critical |
| Navigation order is logical | VoiceOver swipe order | TalkBack swipe order | Critical |
| Touch targets >= platform minimum | 44x44 points | 48x48 dp | Critical |
| Dynamic type / font scaling | Test at XXXL | Test at 2.0x scale | High |
| Color is not sole indicator | Check color-blind modes | Check high-contrast mode | High |
| Gestures have alternatives | Shake-to-undo has button | Swipe has tap alternative | Medium |
| Focus management in modals | VoiceOver trapped in modal | TalkBack trapped in modal | High |
| Custom actions for complex gestures | Swipe-to-delete has button | Long-press has menu | Medium |
| Live regions for dynamic content | UIAccessibility.post(.announcement) | AccessibilityLiveRegion | High |
| Reduced motion respected | UIAccessibilityIsReduceMotionEnabled | Settings.Secure.TRANSITION_ANIMATION_SCALE | Medium |
Key Takeaways
- Device selection is a data-driven decision -- use your analytics to build a device matrix that covers 90% of users with the minimum number of devices.
- Responsive testing is not just screenshots -- AI agents can reason about whether a layout is functionally correct, not just visually similar.
- PWA testing requires offline-first thinking -- test service worker caching, push notifications, and install prompts as first-class features.
- Appium is evolving toward AI-native patterns -- image-based element finding and self-healing locators reduce maintenance burden.
- Cloud device farms are essential for scale -- no team can maintain the physical device diversity needed for confident releases.
- Mobile accessibility is non-negotiable -- test touch targets, screen reader navigation order, and dynamic type at maximum settings.
Interview Talking Point
"My approach to mobile and cross-platform testing starts with data: I pull device and browser analytics to build a three-tier testing matrix. Tier 1 devices -- typically 3 to 5 covering 60% of users -- run full regression on every PR. Tier 2 runs critical paths weekly. Tier 3 gets monthly smoke tests. For responsive design, I go beyond screenshot comparison by having AI agents resize viewports and verify functional correctness -- like confirming the hamburger menu appears at mobile widths and all navigation items remain accessible. For native mobile, I use Appium on cloud device farms like BrowserStack, and I am tracking Appium's evolution toward AI-native testing where image-based element finding and self-healing locators reduce the maintenance burden that has historically made mobile automation brittle. I always include mobile accessibility in my test plans: VoiceOver traversal order, minimum touch targets of 44pt on iOS, and dynamic type testing at maximum font scale."