Das ist kein Randfall. Automatisierte Accessibility-Tools — axe, Lighthouse, WAVE — erkennen zuverlässig strukturelle Fehler: fehlendes alt -Attribut, unverknüpfte Form-Labels, ungültiger lang -Tag. Was sie nicht können: Interaktionen simulieren, Kontext verstehen oder prüfen, ob ein Label inhaltlich sinnvoll ist. Der Scanner liest den DOM – er navigiert nicht.
Playwright schließt diese Lücke. Mit @axe-core/playwright lassen sich axe-Scans in Testläufe einbetten – aber entscheidender ist, was man vor dem Scan tut: Zustände triggern, die ein statischer Scan nie sieht. Wer statt Playwright mit Cypress arbeitet, findet dort mit cypress-axe eine funktional vergleichbare Integration; die Testmuster unten lassen sich übertragen.
npm install --save-dev @playwright/test @axe-core/playwright
Der Basis-Scan als Ausgangspunkt:
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('Basis-Accessibility-Scan', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
Das ist der Ausgangspunkt, nicht das Ziel.
Kontrast-Violations auf Hover- oder Focus-Styles sind für einen Standard-Scan unsichtbar – die Stile existieren nur im aktiven Zustand. Playwright kann den Zustand setzen, bevor axe prüft:
test('Kontrast im Hover-Zustand', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Anmelden' }).hover();
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
test('Kontrast im Focus-Zustand', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Anmelden' }).focus();
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
Ein Scan im Light Mode sagt nichts über Dark-Mode-Kontraste aus. emulateMedia setzt das Farbschema vor dem Seitenaufruf:
test('Accessibility im Dark Mode', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
test('Accessibility im Light Mode', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
Ein Skip-Link ist das erste fokussierbare Element einer Seite – er erlaubt Tastaturnutzern, die Navigation zu überspringen. Landmarks ( <main> , <nav> ) lösen dieses Problem nicht vollständig: sie helfen Screenreader-Nutzern beim Springen zwischen Regionen, aber Tastaturnutzer ohne Screenreader tabben trotzdem durch jedes Navigationselement.
Der Test prüft, ob der Skip-Link vorhanden ist, korrekt beschriftet ist und tatsächlich zum Hauptinhalt springt:
test('Skip-Link ist erstes fokussierbares Element', async ({ page }) => {
await page.goto('/');
await page.keyboard.press('Tab');
const focused = page.locator(':focus');
await expect(focused).toHaveAttribute('href', '#maincontent');
await expect(focused).toContainText('Zum Inhalt springen');
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/#maincontent$/);
});
„Mehr lesen“, „Hier klicken“, „Weitere Infos“ – strukturell valide, für Screenreader-Nutzer nutzlos. Wer per Tab durch Links navigiert, hört diese Texte ohne Kontext. axe beanstandet sie nicht.
test('Keine mehrdeutigen Linktexte', async ({ page }) => {
await page.goto('/');
const ambiguous = [
'mehr lesen', 'weiterlesen', 'hier klicken', 'hier',
'klicken sie hier', 'weitere infos', 'mehr', 'details'
];
const links = await page.getByRole('link').all();
for (const link of links) {
const text = (await link.innerText()).toLowerCase().trim();
expect(
ambiguous,
`Mehrdeutiger Linktext gefunden: "${text}"`
).not.toContain(text);
}
});
Besonders relevant bei dynamisch generierten Teaser-Listen, wo CMS-Redakteure „Mehr lesen“ als Standard-CTA setzen.
Ein Toggle-Button mit aria-label=„Zu Dunkelmodus wechseln“ besteht den axe-Scan problemlos – auch wenn die Seite bereits im Dunkelmodus ist. Das Label ist präsent und syntaktisch korrekt, inhaltlich aber falsch.
Der Test prüft, ob das Label den tatsächlichen Zustand widerspiegelt:
test('Toggle-Label spiegelt aktuellen Zustand wider', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await page.goto('/');
const toggle = page.getByRole('button', { name: /wechseln/i });
await expect(toggle).toHaveAttribute('aria-label', 'Zu Dunkelmodus wechseln');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-label', 'Zu Hellmodus wechseln');
});
alt=„Bild“ besteht den axe-Check – das Attribut ist gesetzt. Screenreader-Nutzer hören „Bild“ und wissen nichts Nützliches. Der Test prüft nicht nur auf fehlendes alt , sondern auch auf wertlose Füllwerte:
test('Kein generisches Alt-Attribut', async ({ page }) => {
await page.goto('/');
const generic = ['bild', 'foto', 'grafik', 'icon', 'image', 'photo', 'img', 'banner'];
const images = await page.locator('img[alt]').all();
for (const img of images) {
const alt = (await img.getAttribute('alt') ?? '').toLowerCase().trim();
if (alt !== '') {
// Leeres alt ist korrekt für dekorative Bilder – nicht anfassen
expect(
generic,
`Generisches Alt-Attribut gefunden: "${alt}"`
).not.toContain(alt);
}
}
});
Ein leeres alt="" ist kein Versehen — es ist die korrekte Auszeichnung für dekorative Bilder. Es weist Screenreader explizit an, das Element zu überspringen. Wer es durch alt="dekoratives Bild" ersetzt, weil ein Linter oder ein Kollege es für einen vergessenen Wert hält, macht es schlechter: Screenreader kündigen ab sofort auf jeder Seite "dekoratives Bild" an.
Dasselbe gilt für aria-hidden="true" auf Icons innerhalb beschrifteter Buttons. Das Attribut ist dort bewusst gesetzt, weil der Button seinen zugänglichen Namen bereits aus dem sichtbaren Text bezieht. Es zu entfernen und durch aria-label="icon" zu ersetzen besteht den Scan — und verschlechtert die tatsächliche Erfahrung.
Beide Muster verdienen einen Kommentar im Code, der die Absicht dokumentiert:
<!-- Leeres alt intentional: dekoratives Bild, Screenreader sollen es überspringen -->
<img src="divider.svg" alt="" />
<!-- aria-hidden intentional: Button-Label kommt aus dem sichtbaren Text -->
<button>
<svg aria-hidden="true">...</svg>
Absenden
</button>
Ein automatisierter Test kann diese semantische Absicht nicht schützen. Was hilft: Konventionen im Team etablieren und Kommentare als Schutz vor gut gemeinten Korrekturen nutzen.
Automatisierte Scans prüfen, ob ein Modal role=„dialog“ hat. Was sie nicht prüfen: ob der Fokus beim Öffnen ins Modal springt, ob er darin bleibt und ob Escape das Modal schließt. Das sind die häufigsten Keyboard-Accessibility-Fehler bei interaktiven Komponenten.
test('Modal sperrt Fokus und schließt per Escape', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Kontakt öffnen' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Fokus muss nach dem Öffnen im Dialog liegen
for (let i = 0; i < 8; i++) {
await page.keyboard.press('Tab');
const insideDialog = await page.evaluate(() =>
document.activeElement?.closest('[role="dialog"]') !== null
);
expect(insideDialog).toBe(true);
}
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible();
});
test('Modal gibt Fokus beim Schließen zurück', async ({ page }) => {
await page.goto('/');
const trigger = page.getByRole('button', { name: 'Kontakt öffnen' });
await trigger.click();
await page.keyboard.press('Escape');
await expect(trigger).toBeFocused();
});
Accordions, die nur auf Mausklick reagieren, sind für Tastaturnutzer nicht bedienbar. Enter und Space müssen beide funktionieren — und aria-expanded muss den tatsächlichen Zustand widerspiegeln, nicht nur beim ersten Klick, sondern beim Auf- und Zuklappen:
test('Accordion öffnet und schließt per Tastatur', async ({ page }) => {
await page.goto('/');
const trigger = page.getByRole('button', { name: 'Häufige Fragen' });
const panel = page.getByRole('region', { name: 'Häufige Fragen' });
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await expect(panel).not.toBeVisible();
// Enter öffnet
await trigger.press('Enter');
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
await expect(panel).toBeVisible();
// Space schließt
await trigger.press('Space');
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await expect(panel).not.toBeVisible();
});
Der Test setzt voraus, dass das Panel mit role="region" und passendem aria-labelledby ausgezeichnet ist — was gleichzeitig eine sinnvolle Anforderung an die Implementierung ist.
Playwright-Tests decken reproduzierbare Muster ab. Was sie nicht können: prüfen, ob die Reihenfolge der Überschriften sinnvoll ist, ob die Navigation mit echtem Screenreader-Verhalten zusammenpasst oder ob Fehlermeldungen in Formularen verständlich formuliert sind.
Eine manuelle Prüfung mit Tastaturnavigation und – zumindest gelegentlich – mit VoiceOver (macOS/iOS) oder NVDA (Windows, kostenlos) bleibt unersetzlich. Die Tests hier sind kein Ersatz dafür, sondern das Netz, das Regressionen abfängt, bevor sie in Produktion gehen.
Ein Hinweis für alle, die unter macOS testen: Standardmäßig erreicht Tab dort nur Textfelder und Buttons – in Safari, Chrome und Firefox gleichermaßen. Vollständige Tastaturnavigation muss erst aktiviert werden: Systemeinstellungen → Tastatur → Tastaturnavigation. Wer das nicht weiß, testet unter macOS in allen Browsern mit demselben blinden Fleck. Unter Windows und Linux ist die vollständige Tab-Navigation ohne zusätzliche Einstellung aktiv.
Playwright hat inzwischen einen offiziellen MCP-Server — damit lässt sich der Browser direkt aus Claude heraus steuern, ohne eine Testdatei zu schreiben. Dazu erscheint in Kürze ein eigener Artikel.