← All articles

Missing GUI IDs cost real money

by Rainer Haupt

According to Google’s Testing Blog, around 84 % of pass-to-fail transitions in CI are not real bugs but flakes. Atlassian quantifies the damage in the Jira frontend at over 150,000 developer-hours per year. An industrial study from TUM measures 2.5 % of productive development time spent on unstable tests. Studies and tool documentation consistently name the same leading cause: unstable selectors in GUI tests.

The fix is cheap if it is taken early. A data-testid attribute costs one keystroke per interactive element at write-time. Retrofitted, the same attribute costs 1–4 developer-hours per element, plus QA cycles, plus regression risk. For an SPA with 500 interactive elements the retrofit budget runs to USD 60,000–240,000. The thesis is therefore pragmatic, not ideological: test IDs are an architecture decision and belong in component design from day one.

What the spec says — and what frameworks make of it

The WHATWG HTML Living Standard is unambiguous: “The id attribute value must be unique amongst all the IDs in the element’s tree.” MDN says the same. WCAG 2.x covers it under Success Criterion 4.1.1 (Parsing) — duplicate IDs are not just stylistic, they are an accessibility-conformance failure.

What actually happens with duplicate IDs in practice? getElementById() and querySelector('#foo') return only the first match, with no error. CSS #foo {…} styles all of them; visually nothing looks wrong. <a href="#foo"> jumps only to the first. aria-labelledby="foo" resolves only to the first — screen readers announce the wrong label. Vuetify issue 15676 documents exactly that case, where NVDA read content twice.

In modern frameworks the HTML id attribute is unsuitable as a test contract on principle, because the generated values are deliberately unstable:

  • React useId (since v18) emits :r0:, :r1:, :rd:. Issue facebook/react #31653 documents IDs flipping between SSR (:R6:) and hydration (:R2:).
  • Angular Material generates mat-input-0, mat-form-field-4. Order depends on creation order — adding a sibling form field shifts every downstream ID.
  • Vuetify, MUI, Ant Design and Chakra all use internal counters or useId wrappers.

The consequence is trivial but routinely overlooked: cy.get('#mat-input-0') and screen.getByTestId(':r3:') are guaranteed to break. Native id is not a test contract in component frameworks.

data-testid as an explicit contract

Kent C. Dodds reduces the principle to one line: “The more your tests resemble the way your software is used, the more confidence they can give you.” Testing Library defines a canonical query priority: Role > Label > Placeholder > Text > AltText > Title > TestId. Test IDs are the explicit escape hatch when role and label queries do not fit.

Playwright’s documentation puts it more bluntly: “Testing by test ids is the most resilient way of testing… QA’s and developers should define explicit test ids and query them with page.getByTestId().” Cypress lists data-cy in its official selector table as the only “Always (Best)” option, because the attribute does not flip on CSS or JS behavioural changes.

The benefit is not only functional, it is semantic. The attribute name data-testid announces intent. Anyone refactoring CSS sees at a glance not to touch it. Tests no longer couple to layout, classes or i18n.

The standard counter-argument — payload — does not apply. Build tools strip data-testid from production bundles:

  • Next.js / SWC: compiler.reactRemoveProperties: { properties: ['^data-testid$'] }
  • Babel: babel-plugin-react-remove-properties
  • Vite: vite-plugin-react-remove-attributes

One config line, no extra byte in production.

Naming conventions — pick one, document it, enforce it

Three patterns have established themselves. Which one is chosen matters less than consistency across the project.

  • Page-section-element: login-form-submit-button, checkout-payment-creditcard-input
  • BEM-inspired: login-form__submit-button--loading, product-card__price--discounted
  • Hierarchical-namespaced for large SPAs: auth.login-form.submit-btn — pairs well with a centralised constants module

Anti-patterns that recur and produce expensive maintenance:

  • Indices like button-1, item-3 — break on reorder
  • Build-generated hashes like btn-x7f9k2 — regenerate on every build
  • Localised wording like button-login (translated) — breaks under i18n
  • Framework auto-IDs like mat-input-0 or :r3: — see above
  • Class names as selectors like .btn-primary — couples tests to styling

At component-library level it is enough that every base component accepts a data-testid prop and forwards it:

type ButtonProps = { 'data-testid'?: string } &
  React.ButtonHTMLAttributes<HTMLButtonElement>;

export const Button = ({ 'data-testid': testId, ...rest }) => (
  <button data-testid={testId} {...rest} />
);

MUI, Ant Design and Chakra already forward data-* attributes. The convention itself belongs in a TESTING.md in the repository.

Enforcement — the toolchain

A convention on paper does not help. Four stages that together create real pressure.

1. ESLint at write-time. The eslint-plugin-test-selectors package enforces the presence of data-testid on interactive elements. eslint-plugin-testing-library adds consistent-data-testid to enforce a naming pattern via regex.

{
  "plugins": ["test-selectors", "testing-library"],
  "rules": {
    "test-selectors/button":   ["error", "always", { "testAttribute": "data-testid" }],
    "test-selectors/input":    ["error", "always", { "testAttribute": "data-testid" }],
    "test-selectors/anchor":   ["error", "always", { "testAttribute": "data-testid" }],
    "test-selectors/onClick":  ["error", "always", { "testAttribute": "data-testid" }],
    "testing-library/consistent-data-testid": ["error", {
      "testIdPattern": "^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)*$",
      "testIdAttribute": ["data-testid"]
    }]
  }
}

ESLint is static and cannot detect duplicates in the rendered DOM — that needs a runtime stage.

2. HTML linter and pre-commit hook. HTMLHint (id-unique) and html-validate (no-dup-id, valid-id, id-pattern) check rendered HTML. Husky plus lint-staged hooks them into pre-commit:

{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": ["eslint --max-warnings=0 --fix"],
    "*.html":            ["htmlhint --config .htmlhintrc"],
    "*.{css,scss}":      ["stylelint --fix"]
  }
}

The hook can be bypassed with git commit --no-verify — CI is therefore the indispensable backstop.

3. axe-core in CI. axe-core checks the running browser for duplicate IDs (duplicate-id-aria remains active under WCAG 2.2) and additional ARIA violations. Wired into Playwright:

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('no duplicate IDs', async ({ page }) => {
  await page.goto('/');
  const r = await new AxeBuilder({ page })
    .options({ rules: {
      'duplicate-id-aria':   { enabled: true },
      'duplicate-id':        { enabled: true },
      'duplicate-id-active': { enabled: true }
    }})
    .analyze();
  expect(r.violations).toEqual([]);
});

axe does not check data-testid for uniqueness — that belongs in an additional custom fixture that runs a DOM inventory after each test.

4. Storybook and Definition of Done. @storybook/addon-a11y runs the same axe rules at component level, well before integration. The Definition-of-Done entry closes the loop: “Every interactive element has data-testid. No native id used as a test selector. axe-core CI green. Production build strips data-testid.”

As a diagnostic in the DevTools console, a small snippet lists all duplicates:

(() => {
  const map = new Map();
  document.querySelectorAll('[id]').forEach(el => {
    const id = el.id.trim();
    if (!id) return;
    if (!map.has(id)) map.set(id, []);
    map.get(id).push(el);
  });
  const dups = [...map.entries()].filter(([, els]) => els.length > 1);
  if (!dups.length) return console.log('All IDs unique');
  console.group(`${dups.length} duplicate id(s)`);
  dups.forEach(([id, els]) => {
    console.groupCollapsed(`#${id} – ${els.length}×`);
    els.forEach(el => console.log(el));
    console.groupEnd();
  });
  console.groupEnd();
})();

Three direct before-and-after comparisons

// bad — coupled to DOM depth
driver.findElement(By.xpath("//div[@class='container']/div[3]/form/div[2]/button"));

// good — coupled to intent
driver.findElement(By.cssSelector('[data-testid="checkout-submit"]'));
// bad — breaks on reorder
cy.get('.cart-items > div:nth-child(2) .btn-primary').click();

// good — semantically unambiguous
cy.get('[data-cy="cart-item-remove"]').first().click();
// bad — breaks under i18n
await page.getByText('Submit').click();

// good — language-independent
await page.getByTestId('checkout-submit').click();

Codegen tools like Playwright Codegen, Cypress Studio and Selenium IDE automatically write getByTestId or [data-testid="…"] selectors as soon as they find anchors in the DOM. Without those anchors they fall back to nth-child, absolute XPath or class chains — exactly the selectors that break later.

Verdict

Test IDs are not a QA topic. They are an architecture decision at component level and a contract between development and quality assurance. Teams that plan them from day one regain a measurable share of productive engineering time, keep codegen tooling productive, and remove the source consistently named in studies as the leading cause of flaky tests. Teams that retrofit pay roughly a hundred times more, per element. The simplest place to enforce this remains the Definition of Done: an interactive element without data-testid is not “done”.

Sources

Request callback