Skip to content

The practical guide

data-testid best practices.

A data-testid is a custom HTML attribute that gives automated tests a stable name to find an element by. It does not change how the element looks or give it an accessible name.

One element. A stable name.
In your HTML
<button
  data-testid="checkout-submit"
>
  Place order
</button>
In your Playwright testawait page.getByTestId(
'checkout-submit').click()
A shared name between your UI and your test.

The short version

Use a test ID when you need a deliberate testing contract. Keep the name stable, make the target unambiguous, and still check what happened after the click.

See what that looks like

Start with what the test needs to prove.

If a test should fail when a button loses its accessible name, use its role and name. If the same action needs a stable hook across different labels or layouts, a test ID can be a better fit.

What mattersStart withExample
The control’s role and namegetByRolegetByRole('button',
{ name: 'Place order' })
A form field’s labelgetByLabelgetByLabel('Email')
An explicit test hookgetByTestIdgetByTestId('checkout-submit')
A known DOM attributeA short CSS selector[data-testid="checkout-submit"]

A test ID does not replace a label, alt text, or other accessibility work. It also cannot guarantee a test will pass through loading, network, or state problems.

Name the purpose, not the position.

A useful convention is feature-purpose in lowercase kebab-case: checkout-submit, account-email, or cart-remove-item. This is a team convention, not a requirement of HTML.

Tied to the current designright-column-button-2

A new layout makes the name misleading.

Tied to the actioncheckout-submit

The purpose survives a redesign.

Avoid color names, array positions, generated values, and personal data. In repeated components, a stable item key can help, but scope to a specific card or row when you already have a clear way to identify it.

Keep the attribute on the actual interactive element. Putting it on a wrapper can make the test click the wrapper instead of the button.

Change the UI. Watch the locator.

Switch the example below from its original design to a new label and layout. Then introduce a duplicate ID. The match count comes from the rendered elements in the example.

Try it hereInteractive HTML example
Everyday toteNatural · Quantity 1
$24
Total$24.00
Sample checkout. No payment required.
The locator stays the samepage.getByTestId(
'checkout-submit')
Checking matches…

One button has this test ID. The locator has a single target.

The count checks the HTML in this example. It does not run a Playwright test.

The lesson: a stable name helps only when it still identifies the intended element. A changed label might be harmless in one test and exactly the regression another test needs to catch.

Use it in your framework.

Start with the example for your test setup. The Playwright version is self-contained; the other examples explain what your project needs. Keep the assertion after the action so the test verifies an outcome.

A complete Playwright test with its own HTML fixture. Save it in your configured test directory and run npx playwright test checkout.spec.ts in a project with @playwright/test and its browsers installed.

checkout.spec.ts
import { test, expect } from '@playwright/test';

test('places an order', async ({ page }) => {
  // Self-contained fixture; no application server needed.
  await page.setContent(`<button data-testid="checkout-submit">
  Place order
</button>
<p role="status" data-testid="checkout-status"></p>
<script>
  document.querySelector('button').addEventListener('click', () => {
    document.querySelector('[role="status"]').textContent = 'Order placed';
  });
</script>`);

  await page.getByTestId('checkout-submit').click();
  await expect(page.getByRole('status')).toHaveText('Order placed');
});

Already using data-cy or data-test-id?

The attribute name has to match your configuration. For example, point Playwright’s getByTestId at data-cy in the test config:

playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: { testIdAttribute: 'data-cy' },
});

// <button data-cy="checkout-submit">Place order</button>
// page.getByTestId('checkout-submit')

Scope the component before the action.

Two product cards can legitimately contain the same add-to-cart test ID. The problem is clicking it without identifying which card you mean.

Repeated product cards
<article data-testid="product-card">
  <h3>Trail shoes</h3>
  <button data-testid="add-to-cart">Add to cart</button>
</article>
<article data-testid="product-card">
  <h3>City shoes</h3>
  <button data-testid="add-to-cart">Add to cart</button>
</article>

Select the intended product first, then find its button. Checking the count makes your expectation explicit.

Scoped Playwright locator
const product = page.getByTestId('product-card').filter({
  has: page.getByRole('heading', { name: 'Trail shoes' }),
});
const addToCart = product.getByTestId('add-to-cart');

await expect(addToCart).toHaveCount(1);
await addToCart.click();

A page-wide getByTestId('add-to-cart').click() is ambiguous here. Avoid reaching for .first() just to silence the error; a reordered list could make it click the wrong product.

Read the failure before changing the ID.

0

No elements match

Inspect the rendered HTML. Check the spelling, the configured attribute, and whether a React component forwards the prop to a DOM node. The element may also be in another frame or not rendered yet.

2+

More than one element matches

Scope to the intended container. Check for a second copy in a mobile menu, modal, or repeated component. Give different actions different names.

1

The element matches, but the test still fails

Look for a disabled control, an overlay, missing data, or an unexpected application state. Playwright waits for actionability, but a stable selector cannot fix the behavior itself.

A React prop needs to reach the DOM.

CheckoutButton.tsx
function CheckoutButton({ testId }: { testId: string }) {
  return <button data-testid={testId}>Place order</button>;
}

<CheckoutButton testId="checkout-submit" />

testId is just a prop on this custom component. The data-testid attribute on the native button is what the locator actually reads.

Make it part of the code review.

The component owner and the test author should agree on the name and scope. Treat renaming a test ID as a change to a shared interface: update its callers in the same pull request.

Before you merge
  • Use a role or label when that is what the test needs to verify.
  • Name the purpose; avoid styling, position, random values, and personal data.
  • Check that a single-element action resolves to one element in its scope.
  • Forward the test ID to the actual DOM element in a custom component.
  • Update the component and its tests together when the ID changes.
  • Assert the user-visible result after the action.

Document one attribute convention, decide who reviews changes, and test the same kind of build you intend to ship. If you strip test IDs from production, make sure your production checks do not depend on them.

From the example to your application

Take the same care with your real UI.

Inspect an element and review its selectors with our extension. When a bug depends on what happened earlier, record the steps and share the context with your teammate.

Free extension for desktop Chrome. Try it before signing up.

A few common questions.

Is data-testid the same as data-test-id or data-cy?

They are different attribute names. Playwright and Testing Library use data-testid by default. If your app uses data-test-id or data-cy, configure testIdAttribute to that exact attribute or query it with a CSS attribute selector. Pick one convention for the project.

Should every element have a data-testid?

No. Start with the behavior you need to test. A role, accessible name, or label often expresses it clearly. Add a test ID where you need an explicit, stable test hook, and keep the attribute on the element the test actually uses.

Do test IDs need to be unique?

The locator for a single-element action should resolve to one element. Repeated components can share test IDs when the test first scopes to a specific container. HTML id values are required to be unique in the document; data-testid values are not, so your test must establish an unambiguous scope.

Should I remove test IDs from production?

That depends on where your tests run. Removing the attributes breaks locators that rely on them in that build. Decide whether you need to test the deployed UI before stripping them. Test IDs are visible in the DOM, so never put secrets or personal data in their values.

Will data-testid fix flaky tests or accessibility issues?

It can reduce failures caused by changes to styling or structure, provided the ID stays stable. It cannot fix an inaccessible control, a blocked click, missing test data, or an application race. Keep accessible names and assert the result of the action.

Sources & further reading

Examples and guidance checked against the official documentation. Naming and review conventions are recommendations to adapt with your team.

  1. MDN · Using HTML data attributes
  2. Playwright · Locators and test IDs
  3. Testing Library · Choosing a query
  4. Cypress · Selecting elements