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_titleThose 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.continueThe 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.passportFor 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:
- Does the product action still exist?
- Does the existing identifier still describe it accurately?
- Do tests or automation depend on it?
- Would keeping it hide a real semantic change?
- 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.publishKeeping 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.deleteActionThat 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, andcell - 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:
- onboarding
- authentication
- purchase or subscription management
- primary creation/editing flows
- destructive confirmations
- 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.