Choosing Locators in Front-End Tests: Brittle Where It Counts

Automated front-end tests describe user interactions with code: visiting a page or mounting a component, clicking and typing, then asserting on the result. A key structural pattern for these tests applies broadly: Arrange (get the app into a known state), Act (perform an interaction), Assert (check the expected outcome).

To perform the Act and Assert steps, tests need to target elements in the DOM. A locator is any mechanism that identifies an element to a test runner: an ID, text content, a CSS selector like .blog-post, or even a long chain like article > div.container > div > p:nth-child(12). It’s a truism that locators should be stable, but stability isn't the only trait to optimize for. The best locators are strong in one direction and tear in another: they survive minor code changes but fail when the actual functionality being tested is broken.

The "Good" Brittleness

Consider the directions: "Go past the yellow house, keep going 'til you hit the field where Mike's mother's friend's goat went missing that time, then turn left." This is a bad locator: if the yellow house gets repainted, the test fails for reasons unrelated to the gate's functionality. If a locator depends on irrelevant details like a CSS class or a DOM nesting level, it will break on harmless refactors.

A more stable approach: "Go to the gate with serial number 1234 and check if it opens." This is like using an element's ID. Uniqueness, however, is not enforced by the system—just as a UI with duplicate IDs can cause subtle test failures. A test finding the wrong element may still pass, giving false confidence.

The next move is to add an attribute solely for testing, analogous to data-testid. "Go to the gate with Test ID 'my-favorite-gate'." This is as robust as a locator can get. However, tests relying only on such attributes miss an opportunity to catch real user-facing issues. If the gate's serial number plate is painted over or the address label is missing, the test still passes even though the gate is effectively inaccessible to the people who need it.

The final iteration: "Go to the gate for house number 40 and check if it opens." This is a meaningful locator—it uses a label that actual users depend on. It introduces a potential reason for failure before the primary interaction is tested. This is the good brittle: if the gate can't be found by its human-facing label, that's a bug worth surfacing, regardless of the gate's mechanical function.

DOM Structure Is Not Irrelevant

A common testing rule is to avoid locators that depend on DOM structure so that refactors don't break tests. A better framing is to avoid locators that depend on irrelevant structure. The DOM exists to describe the content and semantics on the page, and it forms the accessibility tree. For users of assistive technology, the nature and structure of the content is the experience. Writing locators based on just technical markers, like data-test, misses a chance to build a more accessible app.

Consider a form submission test using Cypress:

// 👎 Not recommended
cy.get('#name').type('Mark')
cy.get('#comment').type('test comment')
cy.get('.submit-btn').click()
cy.get('.thank-you').should('be.visible')

cy.get() implicitly asserts that the element exists in the DOM. Actions like type and click add more implicit checks: the element must be visible, enabled, and not obstructed. Locating an input with #name creates dependencies on IDs, which have several drawbacks: they are prone to changing when a page needs multiple instances of a form, and they don't test anything a user would care about.

For interactive elements, the accessible name of the control is an ideal locator. It carries less risk than an arbitrary attribute because its absence is an actual UX regression.

// 👍 Recommended
cy.getByLabelText('Name').type('Mark')

The getByLabelText query (part of Cypress Testing Library) offers built-in checks on the accessibility of the form field. Interactive elements are required to have an accessible name for assistive technology. In HTML, labels usually provide that name via the label element associated with the form field by ID.

This locator also ensures the markup is correct. Invalid HTML like a labeled div cannot be accurately associated with a label, so a test targeting it should fail early:

<!-- 👎 Not recommended  -->
<label for="my-custom-input">Editable DIV element:</label>
<div id="my-custom-input" contenteditable="true" />

Using a genuine input element makes the association valid:

<!-- 👍 Recommended -->
<label for="my-real-input">Real input:</label>
<input id="my-real-input" type="text" />

With meaningful selectors, a test failure after a code change indicates a break in part of the DOM that matters—not just a structural shuffle.

Working With Non-Interactive Elements

For content, the judgement shifts. Before defaulting to test-only attributes, consider whether an element has an important role in the page's meaning for assistive technologies. If components are generic container elements, tests that use them can set an expectation for that generic markup, making accessibility improvements less likely later. Unjustified div and span usage is a signal to refine the application code itself. If the aim for the HTML is clear—say, the form status message isn't just visually styled but is also announced via a status role—then tests have a chance to be more ambitious than just checking element existence.

An assertion against .thank-you or [class=error] might only be testing a styling hook:

// 👎 Not recommended
cy.get('.thank-you').should('be.visible')
// 👎 Not recommended
cy.get('[data-testid="thank-you-message"]').should('be.visible')

A more meaningful test uses cy.contains() to verify the expected text appears in a semantically appropriate container:

// 👍 Recommended
cy.contains('[role="status"]', 'Thank you, we have received your message')
  .should('be.visible')

This test is valuable—it checks the content is present and that it's inside an element with a specific role. The new trade-off is its dependency on hardcoded text. That introduced brittleness is manageable when shared code sources drive both the component content and its tests.

Human-Readable Text as Magic Numbers

Hardcoded human-readable strings inside templates and tests are in the same category as magic numbers in traditional code. When messages and labels are rendered, they're absolute values existing throughout the codebase. For maintainability, and especially for multilingual apps or content managed in a CMS, the text isn't a part of the code itself but comes from elsewhere:

<label for="name">
  <!-- prints "Name" in English but something else in a different language -->
  {{content[currentLanguage].contactForm.name}}
</label>

Sharing the same string constants between the app and its tests increases robustness:

const text = content.en.contactFrom // we would do this once and all tests in the file can read from it

cy.contains(text.nameLabel, '[role="status"]').should('be.visible')

Importing text into tests does reduce the test's independence, and some teams reasonably prefer tests that assert an independent copy of expected content. Either path is acceptable as long as its trade-offs are conscious. At minimum, keeping content out of component code is a good practice, even if tests sometimes still hardcode an expected string in an end-to-end scenario.

Using data-test Intentionally

The point is not to entirely discard data-test or data-cy attributes; they are still the best option for tapping into dynamic content or reaching elements by a means no one argues about. The crucial thing to avoid is using these attributes when a stable, user-relevant locator exists. When required, data-test can be used alongside accessible assertions:

cy.get('h2[data-test="intro-subheading"]')

This will target a top-level subheading (h2) even if the content of that element is dynamic. For static content, data attributes ensure content is rendered in the right location:

cy.contains('h2[data-test="intro-subheading"]', 'Welcome to Testing!')

The same pattern works to scope assertions to a specific region of the page:

cy.get('article[data-test="ablum-card-blur-great-escape"]').within(() => {
  cy.contains('h2', 'The Great Escape').should('be.visible')
  cy.contains('p', '1995 Album by Blur').should('be.visible')
  cy.get('[data-test="stars"]').should('have.length', 5)
})

Often, the most meaningful options are surrounding the test setup—like asserting its specific position within the layout. These decisions, and bringing accessibility awareness into the test authoring process, create the right amount of strength and flexibility in test suites. This approach favors using elements, roles, and accessible names whenever they are part of the functionality being confirmed, and reserves test-specific attributes for the genuinely hard-to-reach content the suite still needs to exercise.