iOS 26 TabView UI Tests: Making Identifiers Work Everywhere

Your UI tests pass on iPad but fail on iPhone. Or vice versa. With iOS 26’s Liquid Glass design, TabView renders completely differently across devices - and accessibility identifiers come along for the ride.

Let’s fix that.

The Two Faces of TabView

Think of iOS 26’s TabView like a restaurant with two locations - same menu, different layouts.

On iPad, you get the modern minimalist branch: tabs float in a sleek toolbar at the top. Each tab is a proper button that proudly wears its accessibility identifier like a name badge.

On iPhone, you get the classic diner: tabs live in the traditional tab bar at the bottom. But here’s the twist - the tab bar buttons refuse to wear name badges. They’ll tell you their label if you ask nicely, but identifiers? Nope.

Same SwiftUI code. Same .accessibilityIdentifier() modifier. Completely different behavior.

What You’ll See in the Debug Output

When things don’t work, print the hierarchy - don’t guess:

print("DEBUG: tabBars count = \(app.tabBars.count)")
print("DEBUG: buttons = \(app.buttons.debugDescription)")

iPad (Liquid Glass toolbar):

DEBUG: tabBars count = 0
DEBUG: buttons =
  Button, identifier: 'tab_trends', label: 'Trends'  ← identifier works!

iPhone (traditional tab bar):

DEBUG: tabBars count = 1
DEBUG: button[0] label='Current' identifier=''  ← identifier EMPTY
DEBUG: button[1] label='Trends' identifier=''   ← identifier EMPTY

Notice: iPad has zero tab bars because tabs render as toolbar buttons. iPhone has a proper tab bar, but the buttons inside it ignore your identifiers.

This isn’t new - it’s a known iOS bug since iOS 10. iOS 26 just makes it more obvious because the two devices now look completely different.

The Bilingual Approach: Identifier + Index

Since we’re dealing with two different “dialects” (identifier-speaking iPad, index-speaking iPhone), let’s define both:

// AccessibilityIdentifiers.swift

enum Tab {
    // For iPad (speaks identifier)
    static let current = "tab_current"
    static let trends = "tab_trends"
    static let settings = "tab_settings"

    // For iPhone (speaks index)
    static let currentIndex = 0
    static let trendsIndex = 1
    static let settingsIndex = 2
}

Now your tests can try the preferred language first, then fall back:

// Try identifier first (iPad understands this)
var trendsTab = app.buttons[Identifiers.Tab.trends].firstMatch

if !trendsTab.waitForExistence(timeout: 1) {
    // Fall back to index (iPhone needs this)
    trendsTab = app.tabBars.buttons.element(boundBy: Identifiers.Tab.trendsIndex)
}

if trendsTab.exists {
    trendsTab.tap()
}

A Reusable Helper

Wrap this pattern in an extension:

extension XCUIApplication {
    /// Taps a tab, handling iPad vs iPhone differences
    func tapTab(identifier: String, index: Int) {
        // iPad: tabs are toolbar buttons with identifiers
        let byIdentifier = buttons[identifier].firstMatch

        if byIdentifier.waitForExistence(timeout: 1) {
            byIdentifier.tap()
            return
        }

        // iPhone: tabs are in tab bar, use index
        let tabBarButton = tabBars.buttons.element(boundBy: index)
        if tabBarButton.exists {
            tabBarButton.tap()
        }
    }
}

// Usage - clean and device-agnostic
app.tapTab(identifier: Identifiers.Tab.trends, index: Identifiers.Tab.trendsIndex)

One call, works everywhere.

The Liquid Glass Duplicate Mystery

On iPad, you might see this error:

Multiple matching elements found:
  ↳Button, identifier: 'tab_trends', label: 'Trends'
    ↳Button, identifier: 'tab_trends', label: 'Trends'

Two buttons? With the same identifier? Welcome to Liquid Glass rendering.

Before blindly using .firstMatch, investigate:

let matches = app.buttons.matching(identifier: "tab_trends")
print("Found \(matches.count) matches:")
for i in 0..<matches.count {
    let el = matches.element(boundBy: i)
    print("  [\(i)] frame=\(el.frame)")
}

Safe to use .firstMatch:

  • Both elements have identical frames (same position and size) - it’s just iOS rendering an overlay effect. This is common with Liquid Glass.

Investigate further:

  • Elements have different frames - you accidentally created duplicate identifiers somewhere
  • Elements have different labels - something’s wrong with your view hierarchy

If frames match, .firstMatch is safe. The Liquid Glass effect creates visual duplicates that occupy the same space.

Quick Reference

DeviceTabs LocationIdentifiers Work?Query Method
iPadToolbar (top)Yesapp.buttons[identifier]
iPhoneTab Bar (bottom)Noapp.tabBars.buttons.element(boundBy: index)

Key Takeaways

  1. Define both identifier AND index - iPad speaks identifier, iPhone speaks index, your code should be bilingual

  2. Query app.buttons for iPad - tabs live in the toolbar, not in app.tabBars

  3. Debug by printing, not guessing - debugDescription reveals exactly what XCUITest sees

  4. .firstMatch for Liquid Glass duplicates - safe when frames match, investigate when they don’t

  5. Centralize your fallback logic - one helper method beats scattered conditionals


The inconsistency is frustrating, but the workaround is straightforward. Your tests speak both dialects, and everyone gets along.