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-0oder: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
- WHATWG HTML Living Standard — id attribute
- MDN — id global attribute
- W3C WCAG 2.1 H93 — duplicate id
- Kent C. Dodds — Making your UI tests resilient to change
- Testing Library — Priority of queries
- Playwright — Locators
- Playwright — Best Practices
- Cypress — Best Practices: Selecting Elements
- Selenium — Encouraged Locators
- Google Testing Blog — Flaky Tests at Google (2016)
- Atlassian Engineering — Taming test flakiness (2024)
- TUM — Cost of flaky tests in CI (2024)
- eslint-plugin-test-selectors
- @axe-core/playwright
- Storybook addon-a11y
- React useId — Issue 31653 (SSR/hydration mismatch)
- Angular Material Issue 7883 (mat-input id mismatch)