Stop using CSS and Test Ids to locate elements in tests
How should we locate elements when testing web pages?
Imagine you’re writing automated tests for a web page or a web app. It doesn’t matter if they’re targeting a component, a page, or a user journey. Either way, you must locate elements to automate user actions in the tests. For example, how do you find this button in such tests?
<button id="submitBtn">Submit</button>
An accessibility-oriented approach is the way to go, but let’s start with the typical approaches that rely on CSS, XPath, and test identifiers.
🛑 CSS and XPath selectors
The most immediate and common solution to locating elements is using CSS (e.g., by id, class, tag, or some combination) or XPath selectors.
page.locator("#submitBtn") // (Playwright)
cy.get('#submitBtn') // (Cypress)
browser.element('#submitBtn') // (Nightwatch)
dom.window.document.getElementById("submitBtn") // (jsdom)
At first glance, it looks good. So, what problems does it have?
🟠 Low readability: HTML identifiers/classes are technical concepts that harm tests’ readability. This is more striking for complex selectors. Sure, you can hide them behind constants, but you’re just adding one more level of indirection (more code, less cohesion…). You could also add/modify identifiers/classes, but changing the implementation running behind tests should raise some flags (in any case, it addresses only the symptom).
🛑 Tests fail for the wrong reasons. HTML identifiers/classes were invented to help developers style (with CSS) and manipulate (with JavaScript) page elements, not test user actions. Combined selectors (e.g., “#customers .selected > p”) also couple the tests to the HTML structure. However, HTML constantly evolves, so tests should not require us to keep identifiers/classes and structure intact. Coupling tests to such implementation details makes them brittle, yielding false positives; they often fail, which frustrates developers and thwarts continuous refactoring.
🛑 Tests don’t fail for the right reasons. What if you mistakenly hide a menu or change an essential string in the UI? Tests will still be reported as green, indicating false negatives.
We should never couple tests to the structure of the code. Why? Structure tends to be volatile, and that volatility keeps breaking the tests. The churn and the maintenance cost turns tests from assets to liabilities. Before we know it, we stop writing tests. ~ Alex Bunardzic
Tests need to be decoupled from implementation details, such as CSS/XPath selectors. What’s the alternative?
CSS and XPath are not recommended as the DOM can often change leading to non resilient tests. Instead, try to come up with a locator that is close to how the user perceives the page such as role locators or define an explicit testing contract using test ids. Locators | Playwright
🟠 Test Identifiers
Test identifiers are a widespread solution for locating web elements. We add a new data property to make the test possible:
<button data-testid='submitButton'>Submit</button>
Now, we can easily use it to locate our button:
page.$('[data-testid="submitButton"]') // (Puppeteer)
page.getByTestId('submitButton') // (Playwright)
✅ Test identifiers are stable, unlike CSS or XPath selectors. We solved the coupling to implementation details. Great. Can we stop here? Not so fast. There are a few problems:
🟠 Changing implementation to please tests is a testing smell. The HTML markup is now brimmed with test identifiers that exist only for testing purposes. It means extra visual/cognitive load. It’s an unnecessary abstraction. It tackles the symptom, not the problem. Besides, if you test after the fact, will you keep chasing developers to update the markup (pairing fixes that, but that’s not the topic here)? That said, test identifiers are still better than CSS/XPath selectors.
🛑 Tests don’t fail when they should. What if a checkbox is mistakenly hidden due to a CSS mishap? What if we write gibberish text in a button? Locating elements yields false positives — tests don’t fail when they should have, which is a bit scary. No one will know about user-facing issues until a complaint is made. It’s a lost opportunity.
🟠 ️️Not promoting accessibility. Tests pass even if you change an HTML tag to one that would reduce web accessibility. For example, if you change a button to a span, users with screen readers will have a hard time. You can fix the span’s button accessibility, but the tests did not indicate that.
❎ User-visible texts
We don’t want to be tied to technical details (i.e., false positives). We want tests to fail when they should (e.g., like a hidden link). We also don’t want to modify the HTML to make tests feasible (i.e., false negatives). The logical conclusion is that we want to “test as a user” as much as possible.
The more your tests resemble the way your software is used, the more confidence they can give you. ~ Kent Dodds
If we want to automate user interactions, why don’t we rely solely on things the user can see? The answer is to search by text:
driver.findElement(partialLinkText("Submit")) // (Selenium)
driver.findElement(text("Submit")) // (Selenium + Selenium Testing Library)
page.getByText('Submit') // (Playwright)
cy.contains('Submit') // (Cypress)
Relying on user-visible text (copy) brings us the following benefits:
✅ We don’t change the markup to please tests. We don’t need to go back to the HTML and manually add test identifiers (unnecessary visual and cognitive load).
✅ Reduce coupling to implementation details. Tests won’t fail for the wrong reasons, like tweaking CSS classes or modifying HTML identifiers.
✅ Tests fail for the right reasons. “Testing as a user” increases the tests’ safety net, which is the primary goal of automated testing. Tests should be coupled to observable behavior, not CSS/XPath and test identifiers. But aren’t tests more fragile due to copy-related changes? No, because:
» The copy doesn’t change significantly or frequently (and you can increase resilience using regex);
» If the copy changes a lot, it means a UI revamp that forces you to adapt tests regardless, and other locator techniques wouldn't have helped. You could be in an experimentation phase with a high UI churn rate, but you’d have fewer tests to create/update in that case.
» An inadvertent copy change is precisely why you’d want to be alerted (e.g., a hidden link or with the wrong text) — that’s observable behavior that could be broken.
But there’s still an issue:
🟠 Limited approach. There are web elements that are solely visual, such as images and icons. Text-only locators can’t find them, so you’d need to return to CSS selectors or test identifiers. Also, a text might be repeated on a page. Yes, we can use HTML tags to disambiguate (e.g., cy.get(‘button’).contains(‘Submit’)), but then we go back to coupling our tests to technical details. Also, by using HTML tags, tests may fail for the wrong reasons: no test should break if we want to use semantic HTML (e.g., swapping a div with a nav or a span with an h1). Therefore, we are not promoting web accessibility.
✅ Roles and accessible names
Let’s explore a role-based approach, which was made famous by the Testing Library. The Testing Library has a few locators, but let’s focus on the most important one, ByRole, which relies on roles and accessible names:
screen.getByRole('button', { name: 'Submit' }) // (DOM Testing Library)
browser.element.findByRole('button', {name:'Submit'}) // (Nightwatch)
page.getByRole('button', { name: 'Submit' }) // (Playwright)
What are the benefits?
✅ Tests as documentation. Tests that mimic the actual usage of the software are more effective in documenting its functionality (code self-documentation). They rely solely on the business/user language. You can glance through a test and understand the underlying user interactions and journeys. This helps readability and learning about the domain.
✅ No coupling to technical details. Beware that roles represent UI interaction concepts (i.e., semantic information about elements), not technical details. Roles and text are user-visible but stable; you can change the HTML as much as you want (even if using custom or third-party components) if they keep their roles and accessible text. Rather than targeting what developers care about (CSS and test identifiers), tests target what users care about (roles and text). Tests tend to become black-boxed and fail for the right reasons.
✅ Safety net. The main goal of tests is to give us confidence. Tests exist to help us spot real user-facing issues, not to hinder refactoring or be a burden. We can’t call it automated testing if we must update dozens of tests on every change. By focusing on roles and user-visible text, tests remain stable and reduce maintenance overhead, thus providing a reliable safety net and continuous, hassle-free refactoring.
If you’re concerned about relying on the UI copy, remember that it doesn’t change daily. Besides, you’d want to be alerted to a breaking change due to copy (test identifiers wouldn’t help). However, you could decouple the tests from the UI copy using customized accessible names or translation keys, although I don’t see the point.
✅ Writing better HTML. Often, it’s not easy to locate a UI element in a test. This forces us to revisit HTML to improve it, specifically by using semantic HTML. With such, we stop overusing primitives like divs and spans; instead, we use tags like article, main, footer, nav, aside, address, h1, and label. We end up with more meaningful and expressive HTML, and we can locate the elements in tests by role because semantic HTML has native roles.
✅ Improving web accessibility. When tests depend on roles and accessible names, they enable screen readers to work correctly. If you are having trouble locating something in a test, what does it tell you about its accessibility? The rule of thumb is that if an element is worth testing, it should be findable with a role and an accessible name. This approach helps us identify accessibility issues, such as hidden elements, icons without roles, images without accessible names, buttons with identical labels, unlabeled inputs, or divs that should be articles or sections.
A role-based approach can be applied in component/page testing (e.g., React Testing Library, DOM Testing Library) and higher-level testing (e.g., Cypress, Selenium, Playwright, Nightwatch, Puppeteer, Testcafe). It also applies to mobile testing, where we still want to test the app as a user, relying only on what they can interact with (e.g., React Native Testing Library, Maestro).
We recommend prioritizing role locators to locate elements, as it is the closest way to how users and assistive technology perceive the page. Playwright | Locators
getByRole […] should be your top preference for just about everything. There’s not much you can’t get with this (if you can’t, it’s possible your UI is inaccessible). About Queries | Testing Library
Cypress loves the Testing Library project. We use Testing Library internally, and our philosophy aligns closely with Testing Library’s ethos and approach to writing tests. We strongly endorse their best practices for situations where, as with cy.contains(), you want to fail a test if a specific piece of content or accessible role is not present. Best Practives | Cypress
Conclusion
You don’t have to be coupled to weird CSS selectors or fill in test identifiers everywhere. You read a test and feel in the users’ shoes. CSS, XPath, and test identifiers have nothing to do with real user interaction, so why use them to interact with UIS? The users couldn’t care less about them; they are solely a developer concern. Tests should focus on the user, not on technology. A user-oriented approach, combining roles and accessible names, enables us to test more confidently, document the system, and significantly improve web accessibility.
by: CSS TestId Text Role
Decouples from impl. details 〰️ ✅ ✅ ✅
Detects hidden elem / wrong text 〰️ 〰️ ✅ ✅
Promotes accessibility 〰️ 〰️ 〰️ ✅
Enhancing accessibility doesn’t incur additional costs; quite the opposite. Instead, you utilize what is already available, acting as a user. Accessibility should not be considered an afterthought or an option. It benefits everyone: developers (improved maintenance), testers (it documents the software as it’s meant to be used), all users, and the company (by optimizing SEO, broadening the target audience, and complying with legal requirements).
Web accessibility extends beyond accessible names/roles (e.g., contrast, keyboard navigability), but it’s a good start (check Accessibility Insights for Web extension).