VoiceOver Testing on iOS: Automating Accessibility Audits with XCTest

VoiceOver Testing on iOS: Automating Accessibility Audits with XCTest

VoiceOver is the primary screen reader on iOS, and testing it manually doesn't scale. XCTest's accessibility audit APIs let you catch labeling gaps, broken focus order, and trait mismatches in CI before they reach real users. This guide covers the full automation stack from XCUIAccessibility queries to the Accessibility Inspector.

Roughly 1 in 6 people worldwide lives with some form of disability, and on mobile, screen readers like VoiceOver are often the only viable navigation method. Despite this, accessibility testing on iOS is frequently an afterthought — a manual pass before a release, if it happens at all. The good news is that Xcode and XCTest now expose a rich set of APIs that make automated accessibility testing practical, repeatable, and something you can run in CI on every pull request.

Understanding VoiceOver Navigation

Before writing tests, it helps to understand what VoiceOver actually does. VoiceOver moves through elements in a linear focus order, announcing each element's label, hint, and traits in sequence. It also supports swipe gestures to move between elements, double-tap to activate, and a rotor for navigating by headings, links, or form controls.

When you automate, you're checking the same information VoiceOver uses: accessibilityLabel, accessibilityHint, accessibilityTraits, accessibilityValue, and element ordering.

XCUIAccessibility APIs

XCTest exposes accessibility through XCUIElement properties that mirror what VoiceOver reads. Every UI element you can query in a UI test has:

element.label       // read from accessibilityLabel
element.value       // read from accessibilityValue
element.isEnabled   // derived from UIAccessibilityTrait.notEnabled

To query elements by their accessibility attributes, use the XCUIElementQuery API:

func testProfileImageHasAccessibilityLabel() throws {
    let app = XCUIApplication()
    app.launch()

    // Query by accessibility identifier
    let profileImage = app.images["profile-avatar"]
    XCTAssertTrue(profileImage.exists, "Profile image must exist")
    XCTAssertFalse(profileImage.label.isEmpty, "Profile image must have a label for VoiceOver")
    XCTAssertNotEqual(profileImage.label, "image", "Label should be descriptive, not generic")
}

For buttons, check both the label and that traits are set correctly:

func testSubmitButtonTraits() throws {
    let app = XCUIApplication()
    app.launch()

    let submitButton = app.buttons["Submit"]
    XCTAssertTrue(submitButton.exists)
    // Verify the element is actually a button (not just a tappable view)
    XCTAssertEqual(submitButton.elementType, .button)
    XCTAssertFalse(submitButton.label.isEmpty)
}

performAccessibilityAudit — Automated Rule Checking

Xcode 15 introduced XCUIApplication.performAccessibilityAudit(), which runs a battery of accessibility checks against the current screen state. This is the single most powerful accessibility automation API available on iOS.

func testHomeScreenAccessibility() throws {
    let app = XCUIApplication()
    app.launch()

    // Navigate to the screen you want to audit
    app.buttons["Get Started"].tap()

    // Run the full audit
    try app.performAccessibilityAudit()
}

By default, performAccessibilityAudit() checks for:

  • Elements with no accessibility label
  • Elements with contrast ratio violations
  • Interactive elements that are too small to tap
  • Elements with conflicting or missing traits

You can also target specific audit categories:

func testAccessibilityAuditContrastOnly() throws {
    let app = XCUIApplication()
    app.launch()

    try app.performAccessibilityAudit(for: [.contrast, .textClipping])
}

Available audit types include .contrast, .dynamicType, .elementDetection, .hitRegion, .sufficientElementDescription, .textClipping, and .trait.

To handle known issues without silencing the entire audit, use the issue handler:

func testAuditWithKnownIssues() throws {
    let app = XCUIApplication()
    app.launch()

    try app.performAccessibilityAudit { issue in
        // Suppress a known contrast issue on the legacy header
        if issue.auditType == .contrast,
           issue.element?.label == "Beta Banner" {
            return true // suppress this issue
        }
        return false // fail on everything else
    }
}

This pattern lets you track known issues as intentional suppressions rather than silently ignoring entire categories.

Testing accessibilityLabel, accessibilityHint, and accessibilityTraits

Good VoiceOver announcements require all three properties to be correct. Here's how to test each one explicitly:

func testAccessibilityPropertiesOnPasswordField() throws {
    let app = XCUIApplication()
    app.launch()

    app.buttons["Log In"].tap()

    let passwordField = app.secureTextFields["Password"]
    XCTAssertTrue(passwordField.exists)

    // Label should describe the field purpose
    XCTAssertEqual(passwordField.label, "Password")

    // Placeholder text should not be the only label
    // (this is a common mistake — placeholder disappears when user types)
    XCTAssertFalse(passwordField.label.isEmpty)

    // Check that the field is marked as a text field (not just a generic view)
    XCTAssertEqual(passwordField.elementType, .secureTextField)
}

func testToggleButtonHasCorrectStateAnnouncement() throws {
    let app = XCUIApplication()
    app.launch()

    let notificationsToggle = app.switches["Enable Notifications"]
    XCTAssertTrue(notificationsToggle.exists)

    // Check initial state is announced
    let initialValue = notificationsToggle.value as? String
    XCTAssertNotNil(initialValue, "Toggle must expose its state as a value")

    // Tap and verify state changes
    notificationsToggle.tap()
    let newValue = notificationsToggle.value as? String
    XCTAssertNotEqual(initialValue, newValue, "Toggle value must change after tap")
}

Dynamic Type Testing

Dynamic Type is one of the most commonly broken accessibility features. Users who have set a larger text size expect every text element in your app to scale accordingly.

func testDynamicTypeScaling() throws {
    // Launch with extra-large text size
    let app = XCUIApplication()
    app.launchArguments = ["-UIPreferredContentSizeCategoryName",
                           "UICTContentSizeCategoryAccessibilityExtraExtraExtraLarge"]
    app.launch()

    // Check that the header text is still visible (not clipped)
    let header = app.staticTexts["Welcome to HelpMeTest"]
    XCTAssertTrue(header.exists, "Header must be visible at extra-large text size")
    XCTAssertTrue(header.isHittable, "Header must not be obscured by other elements")

    // Use the dynamic type audit category
    try app.performAccessibilityAudit(for: [.dynamicType, .textClipping])
}

You can also test multiple size categories in a loop:

func testDynamicTypeAcrossSizes() throws {
    let sizes = [
        "UICTContentSizeCategorySmall",
        "UICTContentSizeCategoryLarge",
        "UICTContentSizeCategoryAccessibilityExtraExtraLarge"
    ]

    for size in sizes {
        let app = XCUIApplication()
        app.launchArguments = ["-UIPreferredContentSizeCategoryName", size]
        app.launch()

        try app.performAccessibilityAudit(for: [.textClipping])
        app.terminate()
    }
}

Focus Order Validation

VoiceOver navigates elements in a logical top-to-bottom, left-to-right order by default. When this order doesn't match visual layout — common in apps with custom UIView hierarchies — users get confused.

To verify focus order, traverse elements manually:

func testFocusOrderOnLoginScreen() throws {
    let app = XCUIApplication()
    app.launch()

    app.buttons["Log In"].tap()

    // Elements should appear in this logical order
    let expectedOrder = ["Email", "Password", "Forgot Password", "Sign In"]

    // Get all interactive elements on the screen
    let interactiveElements = app.descendants(matching: .any)
        .matching(NSPredicate(format: "isAccessibilityElement == true"))
        .allElementsBoundByIndex

    let labels = interactiveElements.map { $0.label }.filter { !$0.isEmpty }

    for (index, expected) in expectedOrder.enumerated() {
        XCTAssertTrue(labels.contains(expected),
                      "'\(expected)' not found in accessibility tree")
    }
}

Accessibility Inspector Integration

The Accessibility Inspector in Xcode provides a visual overlay for manual inspection that complements automated tests. You can trigger inspection programmatically to capture state:

Run xcrun accessibilityinspector from the command line to open the inspector, or use the Xcode menu: Xcode → Open Developer Tool → Accessibility Inspector. In the inspector, you can:

  • Run an audit against the simulator and export results as a JSON report
  • Inspect the full accessibility tree including elements that are invisible to XCUIElement queries
  • Check isAccessibilityElement, accessibilityViewIsModal, and container grouping

A practical CI workflow pairs the automated performAccessibilityAudit() tests with a manual Accessibility Inspector sweep before each release. The automated tests catch regressions; the manual review catches structural issues.

Putting It Together: A Full Audit Test Class

import XCTest

final class AccessibilityAuditTests: XCTestCase {
    var app: XCUIApplication!

    override func setUpWithError() throws {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launch()
    }

    func testLaunchScreenAccessibility() throws {
        try app.performAccessibilityAudit()
    }

    func testOnboardingFlowAccessibility() throws {
        let screens = ["Welcome", "Features", "Sign Up"]

        for screen in screens {
            // Each screen audit includes all default checks
            try app.performAccessibilityAudit { issue in
                // Log issues before deciding to suppress
                print("Accessibility issue on \(screen): \(issue.description)")
                return false
            }

            // Advance to next screen if button exists
            if app.buttons["Continue"].exists {
                app.buttons["Continue"].tap()
            }
        }
    }

    func testAllButtonsHaveLabels() throws {
        let buttons = app.buttons.allElementsBoundByIndex
        for button in buttons {
            XCTAssertFalse(button.label.isEmpty,
                           "Button at \(button.frame) has no accessibility label")
        }
    }

    func testAllImagesHaveDescriptions() throws {
        let images = app.images.allElementsBoundByIndex
        for image in images {
            // decorative images should be marked as not accessibility elements
            // if the image IS an accessibility element, it needs a label
            if image.isHittable {
                XCTAssertFalse(image.label.isEmpty,
                               "Interactive image at \(image.frame) has no label")
            }
        }
    }
}

Running Accessibility Tests in CI

Add accessibility tests to your xcodebuild command with no special flags — they're standard XCTest targets:

xcodebuild test \
  -scheme MyApp \
  -destination <span class="hljs-string">'platform=iOS Simulator,name=iPhone 15,OS=17.4' \
  -only-testing:AccessibilityTests \
  <span class="hljs-pipe">| xcpretty

For accessibility-specific reporting, capture the test output and parse issue descriptions to generate a summary report.

Tools like HelpMeTest can layer continuous monitoring on top of your XCTest suite, running accessibility audits against your live app on a schedule and alerting you when new VoiceOver regressions appear between release cycles.

Automated VoiceOver testing won't replace occasional manual testing with VoiceOver enabled on a real device, but it will catch the majority of regressions before they ship — and that's exactly where accessibility bugs are cheapest to fix.

Read more