Strategy

Test ID Conventions: data-testid, data-cy, data-test and How to Agree on One

Test ids are the most robust locator hook there is, if the team actually uses them consistently. A practical convention covering attribute name, naming scheme, where to put them, and how XPath and every framework consume them.

strategydata-testidbest-practicesconventions

Why an attribute just for tests

Every other locator hook has another job. Ids are for CSS and anchors. Classes are for styling. Text is for users. Roles are for accessibility. All of them change for reasons unrelated to tests.

A test id has one job. Changing it breaks tests and nothing else, so nobody changes it casually. That is the whole argument, and it is why Playwright, Cypress and Testing Library all have first-class support.

Pick one attribute name

AttributeUsed by default in
data-testidTesting Library, Playwright (getByTestId), most React and Vue projects
data-cyCypress documentation
data-testolder guides, some Angular projects
data-qa, data-automation-identerprise conventions, Microsoft Fluent

data-testid is the most common and the safest default. If your project already uses another, keep it; consistency matters more than the name. Playwright and Testing Library let you configure the attribute (testIdAttribute in Playwright config), and XPath does not care.

Do not mix. //*[@data-testid='x' or @data-test='x' or @data-cy='x'] is a symptom of a missing decision.

Naming scheme

Test ids are read by humans in failure messages, so they should describe the element, not its implementation.

Good

login-form
login-email
login-password
login-submit
cart-row            (on each repeated row)
cart-row-remove     (the button inside a row)
toast-success
dialog-confirm-delete

Avoid

btn1, input2                  meaningless
LoginFormEmailInputField      too long; case-sensitive in XPath and CSS
login_email vs login-email    pick one separator

A widely used pattern is area-element[-qualifier]: checkout-total, checkout-pay, nav-link-pricing. Kebab-case everywhere.

Repeated components

For lists, put a static id on the repeating unit and identify the instance by content, not by a numbered id:

<tr data-testid="cart-row">
  <td data-testid="cart-row-name">USB-C Hub</td>
  <td><button data-testid="cart-row-remove">Remove</button></td>
</tr>
//tr[@data-testid='cart-row'][.//*[@data-testid='cart-row-name'][normalize-space()='USB-C Hub']]//button[@data-testid='cart-row-remove']

Numbered ids (cart-row-1, cart-row-2) tempt tests to depend on order and are only stable when the data is fixed. If a natural key exists, expose it as a separate attribute: data-testid="cart-row" data-product-id="sku-4421".

Where to put them

  • Every interactive element: buttons, links, inputs, selects, toggles, tabs, menu items.
  • Every container tests will scope into: forms, dialogs, cards, rows, panels, toasts.
  • Elements whose text is asserted: totals, status badges, headings that change.
  • Not on purely decorative or layout elements.

Component libraries

Design-system components should accept a test id prop and forward it to the root element and, where useful, to internal parts:

<TextField data-testid="login-email" />
// renders: <div data-testid="login-email"><label/><input data-testid="login-email-input"/></div>

Document what the internal ids are. Otherwise every team writes //*[@data-testid='login-email']//input, which is fine, but should be a known pattern rather than a rediscovery.

Consuming test ids

page.getByTestId('login-submit')                      // Playwright
cy.get('[data-testid=login-submit]')                  // Cypress
screen.getByTestId('login-submit')                    // Testing Library
driver.findElement(By.cssSelector('[data-testid=login-submit]'))   // Selenium CSS
driver.findElement(By.xpath("//*[@data-testid='login-submit']"))   // Selenium XPath

XPath earns its place when you combine a test id with structure:

//*[@data-testid='cart-row'][.//*[normalize-space()='USB-C Hub']]//button
//*[@data-testid='dialog-confirm-delete']//button[normalize-space()='Delete']
//*[@data-testid='checkout-total'][normalize-space()='$149.00']

Stripping them from production

Some teams remove test ids in production builds with a Babel or Vite plugin. It saves a few bytes and hides nothing of value. The cost is that production smoke tests and monitoring cannot use them, and that the shipped DOM differs from the tested DOM. Most teams leave them in. Decide deliberately.

Getting buy-in

Test ids need developers to add them. What works:

  • A short written convention (this page, adapted) in the repo.
  • A lint rule or PR checklist item: “interactive elements have a test id”.
  • Showing the before and after locator: /html/body/div[2]/div/form/div[3]/button versus //*[@data-testid='login-submit'] makes the case by itself.
  • Testers submitting the PRs that add ids, at least initially.