Vedran Burojevic
← WritingOctober 4, 2026

Accessibility identifiers that survive redesigns

How to name and maintain accessibility identifiers so UI tests and automation keep working through visual redesigns and component refactors.

On this page

Accessibility identifiers are not visual details. Treating them like labels stuck onto views is why UI tests collapse every time the design system gets a new coat of paint.

A redesign changes layout, hierarchy, copy, components, and sometimes the entire navigation shape. The user still signs in, edits a trip, saves a recipe, exports a document, or confirms a purchase. Good identifiers follow those product concepts. Bad identifiers follow the current arrangement of stacks, rows, and buttons until the next design pass invalidates them.

This matters beyond UI tests. Identifiers support automation, QA tooling, screenshot capture, customer support reproduction, accessibility audits, analytics-adjacent diagnostics, and coding agents that need a stable handle on the interface. They are part of the app's operability surface.

1. Separate identifiers from accessibility labels

An accessibility label is for people. An accessibility identifier is for tools.

That distinction sounds obvious until a test starts looking for a button by its visible copy:

app.buttons["Save"].tap()

This works until the copy changes to “Done,” the app gets localized, the button moves into a toolbar menu, or two screens contain a Save button. Now the test suite is coupled to presentation text instead of product behavior.

Prefer a stable identifier:

app.buttons["trip.editor.save"].tap()

The label can change. The button can move. The product action remains the same: save the trip editor.

Use each accessibility field for its actual job:

  • Label: what VoiceOver should say.
  • Hint: extra user-facing guidance when it helps.
  • Value: current state or selected value.
  • Identifier: stable handle for tests and automation.

Do not compromise the user-facing label to satisfy a test. That is backwards. Tests are cheaper to fix than people trying to use your app.

2. Name product actions, not screen furniture

Identifiers should describe what the element means in the product, not how it happens to be built today.

Weak identifiers name implementation details:

primaryButton
bottomButton
blueCTA
vstackButton2
cell_0_title

Those names are fragile because the implementation is fragile. A redesign turns the bottom button into a toolbar item. A list becomes a grid. A primary button becomes a swipe action. The product action did not change, but the identifier already sounds wrong.

Better identifiers name product concepts:

trip.editor.save
trip.editor.cancel
trip.item.nameField
trip.item.deleteConfirmation.confirm
settings.subscription.manage
onboarding.permissions.location.continue

The structure is boring on purpose:

<area>.<flow>.<element-or-action>

Use enough hierarchy to avoid collisions, but do not build a taxonomy museum. If an identifier requires six segments to explain itself, the UI probably needs a clearer boundary too.

For repeated content, include the domain identity when the test needs a specific object:

packing.item.row.passport
packing.item.toggle.passport

For generated data, do not use display text if the text is editable or localized. Prefer stable domain IDs when they are safe to expose in test builds, or test fixture aliases when real IDs are noisy.

3. Keep identifiers close to the component boundary

Identifiers drift when they are scattered across feature code as string literals.

This is the usual failure mode:

Button("Save") {
    save()
}
.accessibilityIdentifier("saveButton")

The next screen adds its own saveButton. A redesign moves the button into a shared ToolbarAction. A UI test starts guessing which save button it found. The string became cheap; the debugging time did not.

Give the feature or component an explicit namespace:

enum TripEditorIdentifiers {
    static let save = "trip.editor.save"
    static let cancel = "trip.editor.cancel"
    static let nameField = "trip.editor.nameField"
    static let destinationField = "trip.editor.destinationField"
}

Then apply those identifiers at the boundary that owns the product behavior:

ToolbarItem(placement: .confirmationAction) {
    Button("Save", action: save)
        .accessibilityIdentifier(TripEditorIdentifiers.save)
}

If a shared component needs identifiers, pass them in rather than letting the component invent generic names:

PrimaryActionButton(
    title: "Save",
    identifier: TripEditorIdentifiers.save,
    action: save
)

The component owns rendering. The feature owns product meaning. Do not make the design system guess business language from inside a button style.

4. Treat lists as identity problems

Lists are where most accessibility identifier schemes quietly rot.

A row identifier like this is almost never stable:

ForEach(items.indices, id: \.self) { index in
    ItemRow(item: items[index])
        .accessibilityIdentifier("item.row.\(index)")
}

That identifier follows position, not the item. Sorting, filtering, insertion, deletion, sync, and search results will all change the meaning of item.row.2.

Use the product identity instead:

ForEach(items) { item in
    ItemRow(item: item)
        .accessibilityIdentifier("packing.item.row.\(item.id.rawValue)")
}

That is better, but be careful about exposing raw identifiers if screenshots, logs, or support tools can leave the device. For test fixtures, readable stable aliases often work better:

struct PackingItemFixture {
    static let passport = PackingItem(
        id: PackingItem.ID("fixture-passport"),
        name: "Passport"
    )
}

Then the UI test can target:

app.cells["packing.item.row.fixture-passport"].tap()

For production data with private identifiers, use row-level identifiers for broad assertions and model-driven test setup for specific assertions. Do not leak sensitive database keys just because a test wanted a shortcut.

5. Build a small UI test vocabulary

Raw identifier strings should not dominate UI tests.

If every test repeats string keys directly, a rename becomes a repo-wide hunt. Worse, tests start mixing product intent with query mechanics:

app.textFields["trip.editor.nameField"].tap()
app.textFields["trip.editor.nameField"].typeText("Oslo")
app.buttons["trip.editor.save"].tap()

A thin screen object keeps the test readable and localizes identifier usage:

struct TripEditorScreen {
    let app: XCUIApplication
 
    var nameField: XCUIElement {
        app.textFields["trip.editor.nameField"]
    }
 
    var saveButton: XCUIElement {
        app.buttons["trip.editor.save"]
    }
 
    func renameTrip(to name: String) {
        nameField.tap()
        nameField.clearAndType(name)
        saveButton.tap()
    }
}

Now the test describes the workflow:

let editor = TripEditorScreen(app: app)
editor.renameTrip(to: "Oslo")

This is not ceremony. It is insulation. The app can move from a text field to a custom input, from a button to a toolbar action, or from a sheet to a split-view detail. The test should only change where the interaction contract changes.

6. Version identifiers deliberately during redesigns

A redesign is a good time to review identifiers. It is not a good time to randomly rename them because the view hierarchy changed.

Before removing or changing an identifier, ask:

  1. Does the product action still exist?
  2. Does the existing identifier still describe it accurately?
  3. Do tests or automation depend on it?
  4. Would keeping it hide a real semantic change?
  5. Is there a migration path for test code?

If the action is the same, keep the identifier. Stability is the point.

If the action changed, rename it deliberately and update the test vocabulary in the same commit. For example, splitting one Save action into Save Draft and Publish should produce new identifiers:

article.editor.saveDraft
article.editor.publish

Keeping the old article.editor.save in that case creates ambiguity. A stable identifier should mean one product action, not whatever button happens to be nearby this month.

For large redesigns, keep a short migration table in the pull request:

Old identifier                    New identifier
trip.editor.save                  unchanged
trip.editor.destinationField      trip.editor.destination.searchField
trip.item.delete                  trip.item.row.deleteAction

That table helps reviewers catch accidental churn. It also keeps UI test failures from becoming a scavenger hunt through renamed controls.

7. Audit identifiers like public API

Accessibility identifiers are not public API in the App Store sense. They are internal API for your test suite, automation, and tooling. That means they need ownership.

A practical audit catches:

  • duplicate identifiers on the same screen
  • generic identifiers like button, title, and cell
  • identifiers based on localized copy
  • identifiers based on array position
  • missing identifiers for critical flows
  • stale identifiers on removed or unreachable views
  • tests using labels where identifiers should exist

You do not need a grand platform initiative. Start with critical flows:

  1. onboarding
  2. authentication
  3. purchase or subscription management
  4. primary creation/editing flows
  5. destructive confirmations
  6. export, sync, or release-sensitive operations

For each flow, make sure the test can find the control by product meaning. If it has to walk the view tree or select the second button in a group, the app is asking automation to practice divination.

8. Make identifiers part of the design system contract

A design system component should not erase automation hooks.

If your button, row, field, or menu wrapper does not expose an identifier parameter, feature code will either skip identifiers or attach them outside the component in fragile places. Both choices age badly.

Design system primitives should allow this:

struct FormTextField: View {
    let title: LocalizedStringKey
    let text: Binding<String>
    let identifier: String
 
    var body: some View {
        TextField(title, text: text)
            .accessibilityIdentifier(identifier)
    }
}

For more complex components, expose identifiers for meaningful sub-elements:

struct SearchPanelIdentifiers {
    let field: String
    let clearButton: String
    let resultList: String
}

Do not expose identifiers for every decorative view. Automation does not need to know about spacer number three. It needs stable handles for user-relevant actions and state.

9. Prefer boring names that stay true

The best identifier system is not clever. It is consistent enough that engineers can guess the next name and stable enough that redesigns do not shred the test suite.

A good rule:

If the product concept survives the redesign, the identifier should usually survive too.

That forces the useful conversation. Are we changing the product action, or only changing its presentation? If it is presentation, keep the identifier. If it is product behavior, rename it and update the tests with intent.

That discipline pays off when the UI evolves, when a coding agent needs to drive the app, when QA records a reproducible path, and when the release branch needs one last verification pass without everyone arguing about which blue button used to be where.

Identifiers are small strings. They should behave like infrastructure.

Command menu

Navigate the site or run an action