← Alle Artikel

Fehlende GUI-IDs kosten richtig Geld

von Rainer Haupt

Bei Google sind laut Testing-Blog rund 84 % der Pass-zu-Fail-Übergänge in der CI keine echten Bugs, sondern Flakes. Atlassian beziffert den Schaden im Jira-Frontend auf über 150’000 Entwickler-Stunden pro Jahr. Eine industrielle TUM-Studie misst 2.5 % der produktiven Entwicklungszeit, die in instabile Tests fliesst. Die Hauptursache wird in Studien und Tool-Dokumentationen konsistent benannt: instabile Selektoren in GUI-Tests.

Die Lösung kostet wenig — wenn sie früh getroffen wird. Ein data-testid-Attribut kostet einen Tastaturanschlag pro interaktivem Element zur Schreibzeit. Nachträglich kostet dasselbe Attribut 1–4 Entwicklungsstunden pro Element, plus QA-Zyklen, plus Regressionsrisiko. Bei einer SPA mit 500 interaktiven Elementen summiert sich der Retrofit auf 60’000–240’000 Franken. Die Kernthese ist deshalb pragmatisch, nicht ideologisch: Test-IDs sind ein Architekturentscheid und gehören ab Tag 1 ins Komponenten-Design.

Was die Norm sagt — und was Frameworks daraus machen

Der WHATWG HTML Living Standard ist eindeutig: «The id attribute value must be unique amongst all the IDs in the element’s tree». MDN formuliert es identisch. WCAG 2.x erfasst das Thema unter dem Erfolgskriterium 4.1.1 (Parsing) — duplizierte IDs sind nicht nur ein stilistisches Problem, sondern ein Accessibility-Konformitäts-Versagen.

Was passiert in der Praxis bei doppelten IDs? getElementById() und querySelector('#foo') geben nur den ersten Treffer zurück, ohne Fehler. CSS #foo {…} formatiert alle, optisch sieht alles richtig aus. <a href="#foo"> springt nur zum ersten. aria-labelledby="foo" löst auf den ersten auf — Screen-Reader sprechen das falsche Label vor. Vuetify-Issue 15676 dokumentiert genau diesen Fall, in dem NVDA Inhalte doppelt vorlas.

Bei modernen Frameworks ist das HTML-id-Attribut für Tests sogar grundsätzlich ungeeignet, weil die generierten Werte bewusst instabil sind:

  • React useId (seit v18) emittiert :r0:, :r1:, :rd:. Issue facebook/react #31653 dokumentiert, dass IDs zwischen SSR (:R6:) und Hydration (:R2:) wechseln.
  • Angular Material erzeugt mat-input-0, mat-form-field-4. Die Reihenfolge hängt von der Erzeugungsreihenfolge ab — ein zusätzliches Geschwister-Feld verschiebt jede nachgelagerte ID.
  • Vuetify, MUI, Ant Design und Chakra nutzen interne Counter oder useId-Wrapper.

Die Konsequenz ist trivial, wird aber regelmässig übersehen: cy.get('#mat-input-0') und screen.getByTestId(':r3:') sind garantiert brüchig. Native id taugt in Component-Frameworks nicht als Testvertrag.

data-testid als expliziter Vertrag

Kent C. Dodds bringt das Prinzip auf einen Satz: «The more your tests resemble the way your software is used, the more confidence they can give you.» Testing Library setzt eine kanonische Query-Priorität: Role > Label > Placeholder > Text > AltText > Title > TestId. Test-IDs sind die explizite Escape-Hatch, wenn Role- und Label-Queries nicht passen oder nicht reichen.

Die Playwright-Dokumentation formuliert es noch deutlicher: «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 nennt data-cy in seiner offiziellen Selektor-Tabelle als einzige «Always (Best)»-Option, weil das Attribut nicht durch CSS- oder JS-Verhaltensänderungen kippt.

Der Vorteil ist nicht nur funktional, sondern semantisch: Der Attributname data-testid zeigt die Absicht. Wer CSS refactort, sieht auf einen Blick, dass dieses Attribut nicht angefasst werden darf. Tests koppeln nicht mehr an Layout, Klassen oder i18n.

Das übliche Gegenargument — Payload — entfällt. Build-Tools strippen data-testid aus dem Production-Bundle:

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

Eine Konfigurationszeile, kein zusätzliches Byte in Production.

Naming Conventions — eines wählen, dokumentieren, durchsetzen

Drei Muster haben sich etabliert. Welches gewählt wird, ist weniger wichtig als die Konsistenz im Projekt.

  • Page-Section-Element: login-form-submit-button, checkout-payment-creditcard-input
  • BEM-inspired: login-form__submit-button--loading, product-card__price--discounted
  • Hierarchisch-namespaced für grosse SPAs: auth.login-form.submit-btn — passt zu einem zentralen Konstanten-Modul

Anti-Patterns, die regelmässig auftreten und teure Wartung erzeugen:

  • Indizes wie button-1, item-3 — brechen bei Reorder
  • Build-generierte Hashes wie btn-x7f9k2 — regenerieren bei jedem Build
  • Lokalisiertes Wording wie schaltflaeche-anmelden — bricht bei i18n
  • Framework-Auto-IDs wie mat-input-0 oder :r3: — siehe oben
  • Class-Namen als Selektoren wie .btn-primary — koppelt Tests an Styling

Auf Komponenten-Bibliothek-Ebene reicht es, dass jede Basis-Komponente einen data-testid-Prop entgegennimmt und durchreicht:

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

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

MUI, Ant Design und Chakra reichen data-*-Attribute bereits durch. Die Konvention selbst gehört in eine TESTING.md im Repository.

Durchsetzen — die Werkzeugkette

Eine Konvention auf Papier hilft nicht. Vier Stufen, die gemeinsam einen ernsthaften Zwang erzeugen.

1. ESLint zur Schreibzeit. Das Paket eslint-plugin-test-selectors erzwingt die Anwesenheit von data-testid auf interaktiven Elementen. eslint-plugin-testing-library ergänzt mit consistent-data-testid ein 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 ist statisch und erkennt keine Duplikate im gerenderten DOM — dafür braucht es eine Laufzeit-Stufe.

2. HTML-Linter und Pre-Commit-Hook. HTMLHint (id-unique) und html-validate (no-dup-id, valid-id, id-pattern) prüfen das gerenderte HTML. Husky plus lint-staged binden das in den Pre-Commit-Hook:

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

Der Hook lässt sich mit git commit --no-verify umgehen — die CI ist deshalb der unverzichtbare Backstop.

3. axe-core in der CI. axe-core prüft im laufenden Browser auf duplizierte IDs (duplicate-id-aria bleibt unter WCAG 2.2 aktiv) und auf weitere ARIA-Verletzungen. Mit Playwright eingebunden:

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

test('keine doppelten 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 prüft kein data-testid auf Eindeutigkeit — das gehört in eine zusätzliche Custom-Fixture, die nach jedem Test eine DOM-Inventur fährt.

4. Storybook und Definition of Done. @storybook/addon-a11y führt dieselben axe-Regeln auf Komponenten-Ebene aus, lange bevor sie integriert werden. Der Definition-of-Done-Eintrag schliesst den Kreis: «Jedes interaktive Element hat data-testid. Keine native id als Testselektor. axe-core CI grün. Production-Build strippt data-testid

Als Diagnose-Werkzeug in den DevTools genügt ein kurzes Snippet, das alle Duplikate auflistet:

(() => {
  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('Alle IDs eindeutig');
  console.group(`${dups.length} doppelte ID(s)`);
  dups.forEach(([id, els]) => {
    console.groupCollapsed(`#${id} – ${els.length}×`);
    els.forEach(el => console.log(el));
    console.groupEnd();
  });
  console.groupEnd();
})();

Drei direkte Vorher-Nachher-Vergleiche

// schlecht — gekoppelt an DOM-Tiefe
driver.findElement(By.xpath("//div[@class='container']/div[3]/form/div[2]/button"));

// gut — gekoppelt an die Absicht
driver.findElement(By.cssSelector('[data-testid="checkout-submit"]'));
// schlecht — bricht bei Reorder
cy.get('.cart-items > div:nth-child(2) .btn-primary').click();

// gut — semantisch eindeutig
cy.get('[data-cy="cart-item-remove"]').first().click();
// schlecht — bricht in der DE-Version
await page.getByText('Submit').click();

// gut — sprachunabhängig
await page.getByTestId('checkout-submit').click();

Codegen-Werkzeuge wie Playwright Codegen, Cypress Studio und Selenium IDE schreiben automatisch getByTestId oder [data-testid="…"]-Selektoren, sobald sie im DOM Anker finden. Ohne diese Anker fallen sie auf nth-child, absoluten XPath oder Klassenketten zurück — also genau die Selektoren, die später brechen.

Einordnung

Test-IDs sind kein QA-Thema. Sie sind ein Architekturentscheid auf Komponenten-Ebene und ein Vertrag zwischen Entwicklung und Qualitätssicherung. Wer sie ab Tag 1 plant, gewinnt einen messbaren Anteil produktiver Entwicklungszeit zurück, hält die Codegen-Werkzeuge produktiv und reduziert die Quelle, die in Studien konsistent als Hauptursache flaky Tests genannt wird. Wer sie nachrüstet, zahlt das Hundertfache — pro Element. Die einfachste Empfehlung deshalb auch im Definition of Done: ein interaktives Element ohne data-testid ist nicht «done».

Quellen

Rückruf anfordern