End-to-End- und Akzeptanztests mit Playwright: Kundentests im echten Browser
https://bildung.social/@oerinformatik/
https://oer-informatik.de/vue-e2e-akzeptanztest-playwright
Playwright, End-to-End-Test, Abnahmetest, Systemtest, Kundentest, Locator, Auto-Waiting, Trace Viewer, page.route, Barrierefreiheit, Vue 3, Extreme Programming
tl/dr; (ca. 41 min Lesezeit): Bisher prüfen Integrationstests mit nachgebildetem Backend die Kundentests der Web-App, und der Kunde klickt sie in der Kundenstunde noch einmal von Hand durch. Jetzt steuert Playwright einen echten Browser durch den Produktions-Build. Der Artikel zeigt, wie Locator Elemente so finden, wie Nutzende sie sehen, warum Aktionen und Assertions von selbst warten, wie
page.route()Backend und Keycloak nachbildet und wie der Trace Viewer zeigt, was in einem gescheiterten Test passiert ist. Die Kundentests aus den Story Cards werden zu End-to-End-Tests, die Karte zur Bedienung per Tastatur und Screenreader entsteht test-first, ein zweites Projekt prüft die echte Auslieferung, und zum Schluss steht der Vergleich mit Selenium [AI-Assisted]. (Zuletzt geändert am 11.11.2026)
Dieser Artikel ist Teil einer Artikelreihe über die Web-App mit Vue 3 und TypeScript: Projektübersicht / XP-Projektstart / TypeScript für Java-Entwickler*innen / Komponenten testgetrieben / API-Client aus OpenAPI / Router und Continuous Integration / Formulare und Validierung / Refactoring mit Pinia / Anmeldung mit Keycloak / Fahrer-Sicht und Coding Standards / Build und Auslieferung / End-to-End-Tests mit Playwright / Change Request Live-Aktualisierung
Schon fit in diesem Thema? Nutze die Kann-Liste und die Leitfragen unter dem Artikel als Checkliste, ob Du die relevanten Kompetenzen bereits hast.
Dieser Artikel baut auf der Kann-Liste des Artikels Build und Auslieferung auf.
Als Grundlagen zu End-to-End-Tests im Browser kann ich nach Durchlesen des Artikels…
… End-to-End-Tests gegenüber Komponenten- und Integrationstests abgrenzen und den Systemtest vom Abnahmetest unterscheiden.
… beschreiben, wie Playwright Test, Browser und die zu testende App zusammenarbeiten und wozu
webServer,baseURLund Projekte inplaywright.config.tsdienen.… Locator nach Rolle, Beschriftung und Text wählen und begründen, warum sie stabiler sind als CSS-Selektoren.
… erklären, warum Playwright vor Aktionen und in Assertions wie
toHaveText()von selbst wartet und warum feste Wartezeiten Tests langsam und unzuverlässig machen.
Darüber hinaus kann ich als Expertenwissen zu End-to-End- und Abnahmetests…
… Kundentests aus Story Cards als End-to-End-Tests schreiben und eine neue Karte test-first mit ihnen umsetzen.
… Backend und Anmeldung mit
page.route()und vorbereiteten Browserdaten nachbilden und abwägen, was ein Test gegen das echte Backend zusätzlich zeigt.… einen gescheiterten oder instabilen Test mit Trace Viewer und UI-Modus untersuchen und die Ursache beheben.
… Bedienbarkeit per Tastatur und Screenreader (Rollen, Namen, Live-Regionen) mit End-to-End-Tests prüfen.
… Playwright und Selenium WebDriver vergleichen und für ein Projekt begründet auswählen.
Stand im Projekt: Iteration 5, zweite Hälfte
Seit dem letzten Artikel läuft die Web-App unter https://app.mitfahrlabor.example, Release 2 ist als Version 0.2.0 ausgeliefert. Von Iteration 5 im Mitfahrlabor ist noch eine Karte offen:
| Karte | Punkte | Kundentests | Stand |
|---|---|---|---|
| WEB-10 App unter der Adresse der Schule aufrufen | 2 | KT-10.1, KT-10.2 | abgenommen |
| WEB-11 Vollständig per Tastatur und Screenreader bedienbar | 3 | KT-11.1, KT-11.2 | dieser Artikel |
Bei WEB-11 hilft der bisherige Weg nicht weiter. Die Integrationstests mit Vitest und MSW rendern Komponenten in jsdom, einer Nachbildung des Browsers ohne Darstellung: Dort gibt es keine echte Tab-Reihenfolge, keinen Fokus, wie ihn ein Browser verwaltet, und keine Seite, die aus Router, Store und allen Komponenten zusammengesetzt ist. Schon bei KT-05.4 („Der Button ist mit Tab erreichbar und mit Enter auslösbar“) prüfte der Test nur das Element, den Rest hat Thomas Becker als Kunde vor Ort in jeder Kundenstunde von Hand durchgeklickt.
Das Team zieht daraus eine Aufgabe, die keine eigene Karte ist, sondern zur Arbeitsweise gehört: Die Kundentests sollen automatisch im echten Browser laufen. In Extreme Programming heißen sie Kundentests (Customer Tests, früher Acceptance Tests): Die Kund*in legt fest, woran sie erkennt, dass eine Karte fertig ist, das Team automatisiert diese Prüfungen, und sie laufen bei jeder Integration. Eine Karte gilt erst als erledigt, wenn ihre Kundentests grün sind. Für WEB-11 heißt das: zuerst die Tests, dann der Code.
Wo End-to-End-Tests stehen
Ein End-to-End-Test (kurz E2E-Test) bedient die fertige Anwendung so, wie Nutzende es tun: Er öffnet eine Adresse im Browser, klickt, tippt und prüft, was auf dem Bildschirm steht. Er kennt keine Komponenten, keinen Store und keine Funktionen, nur die Oberfläche. Im V-Modell gehört er zu zwei Teststufen, je nachdem, welche Frage er beantwortet:
- Systemtest: Erfüllt das ganze System die Anforderungen? Geprüft vom Team, gegen die Spezifikation, in einer Umgebung, die dem Betrieb möglichst nahe kommt.
- Abnahmetest: Ist die Kund*in einverstanden? Die Prüfkriterien stammen von ihr, das Ergebnis entscheidet über die Abnahme. Automatisierte Kundentests sind Abnahmetests, die das Team für die Kund*in ausführt.
Wie im Backend (siehe Systemtest und Abnahmetest) ergänzen sich die Teststufen. Die Testpyramide bleibt: viele schnelle Komponententests, weniger Integrationstests, wenige End-to-End-Tests.
| Merkmal | Komponenten- und Integrationstest (Vitest, MSW) | End-to-End-Test (Playwright) |
|---|---|---|
| Was läuft | einzelne Komponenten oder Seiten in jsdom | der Produktions-Build in einem echten Browser |
| Was wird geprüft | Logik, Darstellung einer Komponente, Zusammenspiel mit dem nachgebildeten Backend | Abläufe aus Sicht der Nutzenden: Router, Fokus, Tastatur, Anmeldung, Zusammenspiel aller Teile |
| Laufzeit je Test | Millisekunden | Zehntelsekunden bis Sekunden |
| Fehlersuche | Fehler zeigt meist direkt auf eine Funktion | Fehler zeigt auf einen Ablauf, Ursache muss man suchen |
| Anzahl im Projekt | 107 Tests | 12 Tests, je Kundentest einer |
Nicht jeder Kundentest braucht einen End-to-End-Test. Das Team automatisiert im Browser die Kundentests, bei denen der Browser etwas beiträgt (Router, Fokus, Tastatur, Anmeldung), und je Karte mindestens einen Ablauf. Fachliche Feinheiten wie die Meldungen zu jeder Ablehnung (WEB-06) bleiben in den schnelleren Integrationstests.
Playwright einrichten
Playwright ist ein Werkzeug von Microsoft, das Chromium, Firefox und WebKit (die Engine von Safari) fernsteuert. Dazu gehört Playwright Test, ein eigener Testrunner mit Assertions, Parallelisierung, Berichten und Traces. Die Alternative im Vue-Umfeld ist Cypress, in Java-Projekten Selenium WebDriver (Vergleich am Ende). Das Team wählt Playwright, weil die Tests in TypeScript geschrieben werden wie die App und weil Playwright alle drei Browser-Engines mit einer Schnittstelle bedient.
Installiert wird als Entwicklungsabhängigkeit, dazu einmalig der Browser. Playwright lädt dafür einen eigenen Chromium herunter, der genau zur Version passt:
npm install --save-dev @playwright/test
npx playwright install chromiumDer Befehl npm init playwright@latest legt dieselben Dateien mit Beispielen an; das Paar baut die Konfiguration lieber selbst, um jede Zeile zu verstehen. Sie steht in playwright.config.ts neben vite.config.ts:
import { defineConfig, devices } from '@playwright/test'
// Adresse einer laufenden Auslieferung mit echtem Backend und Keycloak, z. B. https://app.mitfahrlabor.example
const backendAdresse = process.env.E2E_BACKEND_ADRESSE
export default defineConfig({
testDir: './e2e',
fullyParallel: true, // jede Testdatei und jeder Test in einem eigenen, frischen Browserkontext
forbidOnly: !!process.env.CI, // ein vergessenes test.only lässt die Pipeline scheitern
retries: process.env.CI ? 2 : 0, // in der Pipeline zwei Wiederholungen, lokal keine
reporter: process.env.CI
? [['list'], ['html', { open: 'never' }], ['junit', { outputFile: 'reports/e2e-junit.xml' }]]
: [['list'], ['html', { open: 'on-failure' }]],
use: {
trace: 'on-first-retry', // Trace nur bei der ersten Wiederholung eines gescheiterten Tests aufzeichnen
},
projects: [
{
// gegen den Produktions-Build mit nachgebildetem Backend (page.route): schnell, unabhängig, in jeder Pipeline
name: 'nachgebildet',
testIgnore: /backend\//,
use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:4173' },
},
// gegen die echte Auslieferung mit Backend und Keycloak: nur, wenn eine Adresse gesetzt ist
...(backendAdresse
? [
{
name: 'backend',
testMatch: /backend\/.*\.spec\.ts/,
use: { ...devices['Desktop Chrome'], baseURL: backendAdresse },
},
]
: []),
],
// baut die App wie für den Server und startet die Vorschau, bevor die Tests beginnen
webServer: {
command: 'npm run build-only && npm run preview -- --port 4173 --strictPort',
url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})Die wichtigsten Teile:
webServerstartet vor den Tests einen Befehl und wartet, bis die Adresse antwortet. Getestet wird der Produktions-Build ausdist/in der Vorschau von Vite, also genau das, was später auf den Server kommt, und nicht der Entwicklungsserver mit seinen Hilfswerkzeugen. Lokal nutzt Playwright eine schon laufende Vorschau (reuseExistingServer), in der Pipeline startet es immer neu.baseURLmacht Adressen in den Tests relativ:page.goto('/angebote/1')öffnethttp://localhost:4173/angebote/1. Liefert man die App unter einem anderen Pfad aus, ändert sich nur diese Zeile.- Projekte führen Tests mit unterschiedlichen Einstellungen aus, etwa in mehreren Browsern oder, wie hier, gegen zwei Umgebungen.
devices['Desktop Chrome']setzt Browser, Fenstergröße und User-Agent. retries,forbidOnly,tracegehören zur Fehlersuche; sie folgen weiter unten.
Damit vue-tsc und ESLint auch die Tests prüfen, bekommt das Projekt eine tsconfig.e2e.json (Node.js-Typen und DOM-Typen, weil manche Zeilen im Browser laufen). Vitest ignoriert den Ordner e2e/ schon seit dem Projektstart, und die Berichtsordner playwright-report/ und test-results/ kommen in .gitignore. In package.json stehen zwei neue Skripte: test:e2e (playwright test) und test:e2e:ui (playwright test --ui).
Der erste Kundentest im Browser
Das Paar beginnt mit einer Karte, deren Kundentests schon als Integrationstests grün sind, damit ein roter Test nur am neuen Werkzeug liegen kann. KT-01.1 und KT-01.2 lauten: „Mit den Beispieldaten erscheinen die Angebote 1, 2, 4 und 5, nicht aber 3 (voll), 6 (durchgeführt) und 7 (abgesagt)“ und „Angebot 1 erscheint als ‚07:00 Uhr, 2 Plätze frei‘“.
import { expect, test } from '@playwright/test'
import { KAFFEETASSE, meldeAnAls } from './hilfen/anmeldung'
import { bildeBackendNach } from './hilfen/backend'
// Kundentests der Karten WEB-01 bis WEB-04 (stories.md), im Browser gegen das nachgebildete Backend
test.beforeEach(async ({ page }) => {
await bildeBackendNach(page)
await meldeAnAls(page, KAFFEETASSE)
})
test.describe('WEB-01 Suchergebnis als Liste anzeigen', () => {
test('KT-01.1: offene Fahrten erscheinen, volle, durchgeführte und abgesagte nicht', async ({ page }) => {
await page.goto('/')
const fahrten = page.getByRole('listitem')
await expect(fahrten).toHaveCount(4)
await expect(fahrten.filter({ hasText: 'Halle (Westf.)' })).toHaveCount(0) // Angebot 3 ist voll
})
test('KT-01.2: Angebot 1 zeigt Abfahrt und freie Plätze', async ({ page }) => {
await page.goto('/')
const angebot1 = page.getByRole('listitem').filter({ hasText: 'Gütersloh zur Schule' })
await expect(angebot1).toContainText('07:00 Uhr, 2 Plätze frei')
})
})Der Aufbau erinnert an Vitest, ist aber nicht derselbe Testrunner: test und expect kommen aus @playwright/test. Jeder Test bekommt über die Fixture page eine neue Browserseite in einem eigenen Browserkontext, vergleichbar mit einem frischen privaten Fenster: keine Cookies, kein sessionStorage, nichts aus dem vorigen Test. Deshalb hängen Tests nicht voneinander ab und können parallel laufen. Was bildeBackendNach() und meldeAnAls() tun, zeigt der Abschnitt Backend und Anmeldung nachbilden.
Die Namen der Tests beginnen mit der Nummer des Kundentests, die describe-Blöcke mit der Karte. So zeigt der Bericht direkt, welche Karte betroffen ist, wenn etwas rot wird, und Thomas kann die Ausgabe ohne Kenntnis des Codes lesen. Mit npx playwright test laufen alle Tests (Ausgabe gekürzt):
Running 12 tests using 1 worker
ok 1 [nachgebildet] › e2e/web-01-04-suche.spec.ts:12:3 › WEB-01 Suchergebnis als Liste anzeigen › KT-01.1: offene Fahrten erscheinen, volle, durchgeführte und abgesagte nicht (260ms)
ok 2 [nachgebildet] › e2e/web-01-04-suche.spec.ts:20:3 › WEB-01 Suchergebnis als Liste anzeigen › KT-01.2: Angebot 1 zeigt Abfahrt und freie Plätze (189ms)
ok 3 [nachgebildet] › e2e/web-01-04-suche.spec.ts:29:3 › WEB-02 Fahrten filtern › KT-02.1: Gütersloh, zur Schule, 19.10.2026 findet nur Angebot 1 (296ms)
…
12 passed (5.6s)
Am Zeilenanfang zeigt Playwright für jeden grünen Test ein Häkchen, hier als „ok“ wiedergegeben. Die Zeit von 5,6 Sekunden enthält das Bauen der App und den Start der Vorschau; die Tests selbst brauchen je etwa eine Viertelsekunde.
Locator: Elemente finden wie Nutzende
Ein Locator beschreibt, wie Playwright ein Element findet. Er sucht nicht sofort, sondern erst, wenn eine Aktion oder Assertion ihn braucht, und dann jedes Mal neu. Deshalb kann man ihn einmal anlegen (const fahrten = page.getByRole('listitem')) und später nutzen, auch wenn Vue die Liste zwischendurch neu gerendert hat.
Playwright empfiehlt Locator, die sich an dem orientieren, was Nutzende wahrnehmen, und nicht an der Struktur des HTML. Die Reihenfolge, in der das Team sie wählt:
| Locator | findet über | Beispiel | Wann |
|---|---|---|---|
getByRole() |
Rolle und zugänglichen Namen (Accessibility Tree) | getByRole('button', { name: 'Platz anfragen' }) |
erste Wahl für Buttons, Links, Überschriften, Listen, Meldungen |
getByLabel() |
die Beschriftung eines Formularfelds | getByLabel('Haltepunkt') |
Eingabefelder und Auswahllisten |
getByText() |
sichtbaren Text | getByText('Angemeldet als') |
Text ohne passende Rolle |
getByTestId() |
Attribut data-testid |
getByTestId('angebot-1') |
wenn nichts Sichtbares eindeutig ist |
locator() |
CSS-Selektor oder XPath | locator('#such-haltepunkt') |
Notlösung, z. B. für fremde Seiten |
getByRole() nutzt den Accessibility Tree, den der Browser auch Screenreadern anbietet. Ein <button> hat die Rolle button, sein Text ist sein zugänglicher Name; ein <li> hat die Rolle listitem, ein <dd> die Rolle definition, ein <p role="status"> die Rolle status. Ein Test, der einen Button über Rolle und Namen findet, prüft also nebenbei, dass ein Screenreader ihn genauso findet. Ändert jemand die CSS-Klassen oder schachtelt ein div mehr, bleibt der Test grün; ändert sich die Beschriftung, die auch Nutzende lesen, wird er rot. Genau das ist gewollt.
Locator lassen sich verketten und filtern. Für KT-04.1 sucht der Test den Link „Details“ nur in dem Listeneintrag, der „Gütersloh zur Schule“ enthält:
await page
.getByRole('listitem')
.filter({ hasText: 'Gütersloh zur Schule' })
.getByRole('link', { name: 'Details' })
.click()
await expect(page).toHaveURL('/angebote/1')
await expect(page.getByRole('heading', { level: 2 })).toHaveText('Fahrt 1: Gütersloh zur Schule')Würde der Test nur page.getByRole('link', { name: 'Details' }).click() schreiben, bräche Playwright mit einem Fehler ab: Es gibt vier solche Links. Locator sind strikt: Eine Aktion auf einen Locator, der mehrere Elemente findet, ist ein Fehler, damit der Test nicht zufällig das falsche Element anklickt. Nur Assertions über die Anzahl (toHaveCount()) oder über mehrere Texte arbeiten mit Mehrfachtreffern.
Formularfelder findet der Test über ihre Beschriftung. KT-02.1 füllt das Suchformular aus:
await page.getByLabel('Datum').fill('2026-10-19')
await page.getByLabel('Richtung').selectOption({ label: 'zur Schule' })
await page.getByLabel('Haltepunkt').selectOption({ label: 'Gütersloh' })
await page.getByRole('button', { name: 'Suchen' }).click()
await expect(page.getByRole('listitem')).toHaveCount(1)getByLabel('Haltepunkt') findet das Feld nur, weil SuchFormular.vue seit Stand 03 jedes Feld mit <label for="…"> beschriftet. Fehlt die Beschriftung, findet auch ein Screenreader keinen Namen für das Feld. Den Rekorder npx playwright codegen http://localhost:4173 kann man nutzen, um sich Locator vorschlagen zu lassen: Er öffnet einen Browser und schreibt bei jedem Klick eine Zeile Testcode mit, meist schon mit getByRole(). Das Ergebnis ist ein Entwurf, kein fertiger Test, denn die Prüfungen muss man selbst ergänzen.
Auto-Waiting: Aktionen und Assertions, die warten
Eine Web-App ist nie sofort fertig. Nach page.goto('/') lädt der Browser die App-Hülle und die Bundles, die App fragt das Backend, Vue rendert die Liste. Ein Test, der sofort nachsieht, findet eine leere Seite. Playwright löst das an zwei Stellen, ohne dass der Test Wartezeiten angibt.
Vor jeder Aktion wie click() oder fill() prüft Playwright, ob das Element bereit ist (Actionability Checks): Es muss im DOM hängen, sichtbar sein, darf sich nicht mehr bewegen (keine laufende Animation), muss Ereignisse empfangen (kein anderes Element liegt darüber) und darf nicht deaktiviert sein. Erst dann klickt es. Ist das Element nach der Zeit für Aktionen nicht bereit, scheitert der Test mit einer Meldung, welche Prüfung fehlte.
In Assertions wie await expect(locator).toHaveText(…) sucht Playwright das Element immer wieder neu und vergleicht, bis die Bedingung erfüllt ist oder die Zeit abläuft (Standard 5 Sekunden). Diese Web-First Assertions erkennt man am await vor expect und daran, dass sie einen Locator bekommen, keinen Wert. Das Sequenzdiagramm zeigt den Ablauf für KT-02.1 bei einem langsamen Backend:
Der Unterschied zu einer gewöhnlichen Prüfung ist klein im Code und groß in der Wirkung:
// wartet nicht: liest die Zahl jetzt, vergleicht einmal
expect(await page.getByRole('listitem').count()).toBe(1)
// wartet: fragt wiederholt nach, bis es 1 Eintrag gibt (höchstens 5 Sekunden)
await expect(page.getByRole('listitem')).toHaveCount(1)Die erste Zeile ist auf einem schnellen Rechner oft grün und in einer ausgelasteten Pipeline manchmal rot, je nachdem, ob die Antwort schon da war. Solche Tests heißen instabil (flaky). Die Versuchung ist groß, mit await page.waitForTimeout(1000) eine Sekunde zu warten. Das macht jeden Lauf eine Sekunde langsamer und hilft trotzdem nicht, wenn das Backend einmal zwei Sekunden braucht. Feste Wartezeiten sind in Playwright-Tests deshalb tabu; gewartet wird auf eine Bedingung, die Nutzende auch sehen würden.
Backend und Anmeldung nachbilden
Die App braucht ein Backend und eine Anmeldung bei Keycloak. Für die meisten Tests sollen beide nicht laufen müssen: Die Tests sollen schnell sein, in jeder Pipeline starten und immer dieselben Daten sehen. Wie MSW in den Integrationstests übernimmt das im Browser page.route(): Playwright fängt Anfragen ab, die zu einem Muster passen, bevor sie das Netz erreichen, und der Test bestimmt die Antwort.
export async function bildeBackendNach(page: Page, { verzoegerungMs = 0 } = {}): Promise<void> {
await page.route('**/v1/**', async (route) => {
await new Promise((fertig) => setTimeout(fertig, verzoegerungMs)) // langsames Netz oder Backend nachstellen
const anfrage = route.request()
const pfad = new URL(anfrage.url()).pathname
if (!(await anfrage.headerValue('authorization'))?.startsWith('Bearer ')) {
return route.fulfill({ status: 401, headers: { 'WWW-Authenticate': 'Bearer' } })
}
if (anfrage.method() === 'GET' && pfad === '/v1/angebote') {
return route.fulfill({ json: angebote.filter((a) => a.status === 'offen') })
}
const detail = /^\/v1\/angebote\/(\d+)$/.exec(pfad)
if (anfrage.method() === 'GET' && detail) {
const angebot = angebote.find((a) => a.id === Number(detail[1]))
return angebot
? route.fulfill({ json: angebot })
: problem(route, 404, 'Not Found', `Angebot ${detail[1]} gibt es nicht`)
}
// … POST /v1/angebote/{id}/buchungen antwortet mit 201, alles andere mit 501
})
}Die Daten kommen aus src/domain/beispieldaten.ts, also aus demselben Ausschnitt von data.sql, den auch die Integrationstests nutzen. route.fulfill() antwortet selbst, route.abort() simuliert eine fehlende Verbindung, route.continue() lässt die Anfrage durch, route.fallback() gibt sie an eine früher registrierte Route weiter. Registriert ein Test später eine zweite Route für dieselbe Adresse, wird diese zuerst gefragt. So kann ein einzelner Test das nachgebildete Backend für einen Fehlerfall überschreiben, ohne die Hilfsfunktion zu ändern.
Die Anmeldung ist kniffliger. Ohne Anmeldung leitet die App zu Keycloak weiter (KT-08.1), mit Anmeldung liest sie beim Start, was oidc-client-ts im sessionStorage abgelegt hat. Der Test legt dort vor dem ersten Skript der App einen Eintrag ab, der aussieht wie nach einer erfolgreichen Rückkehr von Keycloak:
export async function meldeAnAls(page: Page, person: Testperson): Promise<void> {
const jetzt = Math.floor(Date.now() / 1000)
const nutzlast = { sub: `e2e-${person.nutzerId}`, realm_access: { roles: person.rollen } }
const user = {
access_token: [base64url('{"alg":"none"}'), base64url(JSON.stringify(nutzlast)), 'e2e'].join('.'),
token_type: 'Bearer',
scope: 'openid',
expires_at: jetzt + 3600,
profile: { sub: `e2e-${person.nutzerId}`, iss: AUTHORITY, aud: CLIENT_ID, iat: jetzt, exp: jetzt + 3600,
pseudonym: person.pseudonym, nutzer_id: person.nutzerId },
}
// läuft in jeder neuen Seite vor dem JavaScript der App, auch nach einem Neuladen
await page.addInitScript(
([schluessel, wert]) => window.sessionStorage.setItem(schluessel, wert),
[`oidc.user:${AUTHORITY}:${CLIENT_ID}`, JSON.stringify(user)] as const,
)
}addInitScript() führt eine Funktion im Browser aus, bevor die Seite ihr eigenes JavaScript startet. Der Schlüssel folgt dem Muster der Bibliothek (oidc.user:<Issuer>:<Client>), der Issuer ist der aus .env.production. Das Token ist kein gültiges JWT, und das ist in Ordnung: Die App liest daraus nur die Rollen, um Buttons ein- oder auszublenden; die Signatur prüft erst das Backend, und das ist hier nachgebildet. Personen und Pseudonyme stammen aus dem Datenbestand des Backends: KAFFEETASSE ist Oksana Melnyk (ID 3), FUNKENFLUG ist Ben Richter (ID 1), der Angebot 1 fährt.
Auch der Weg ohne Anmeldung lässt sich prüfen, ohne dass Keycloak läuft. oidc-client-ts fragt zuerst die Konfiguration des Realms ab; der Test beantwortet diese Anfrage und liefert für die Anmeldeseite ein kleines HTML-Dokument:
test('KT-08.1: Ohne Anmeldung leitet die App zu Keycloak weiter (mit PKCE)', async ({ page }) => {
// Keycloak nachbilden: Die Bibliothek fragt zuerst die Konfiguration des Realms ab
await page.route(`${AUTHORITY}/.well-known/openid-configuration`, (route) =>
route.fulfill({
json: {
issuer: AUTHORITY,
authorization_endpoint: `${AUTHORITY}/protocol/openid-connect/auth`,
token_endpoint: `${AUTHORITY}/protocol/openid-connect/token`,
end_session_endpoint: `${AUTHORITY}/protocol/openid-connect/logout`,
},
}),
)
await page.route(`${AUTHORITY}/protocol/openid-connect/auth?**`, (route) =>
route.fulfill({ contentType: 'text/html', body: '<h1>Anmeldeseite (nachgebildet)</h1>' }),
)
await page.goto('/angebote/1')
await expect(page.getByRole('heading', { name: 'Anmeldeseite (nachgebildet)' })).toBeVisible()
const adresse = new URL(page.url())
expect(adresse.searchParams.get('client_id')).toBe('mitfahrlabor-web')
expect(adresse.searchParams.get('code_challenge_method')).toBe('S256')
})Der Test sieht, was im Artikel Anmeldung mit Keycloak nur im Netzwerk-Tab der Entwicklerwerkzeuge zu sehen war: Die App leitet mit dem richtigen Client und mit PKCE (S256) weiter. Die beiden expect() am Ende prüfen Werte, keine Locator, und warten deshalb nicht; das ist hier richtig, weil die Adresse nach der sichtbaren Überschrift schon feststeht.
Was das Nachbilden nicht zeigt: ob das echte Backend die Anfragen so beantwortet und ob die echte Anmeldeseite funktioniert. Dafür gibt es das zweite Projekt.
Kundentests aus den Story Cards
Mit diesen Hilfen schreibt das Paar die Kundentests der Karten als End-to-End-Tests. Die Formulierung auf der Karte bleibt, wie Thomas sie geschrieben hat; der Test übersetzt sie in Schritte und Prüfungen. KT-05.1 („Oksana öffnet Angebot 1 und fragt an: Meldung ‚Anfrage gesendet‘, der Button ist danach deaktiviert“):
test('KT-05.1: Oksana fragt bei Angebot 1 an', async ({ page }) => {
await meldeAnAls(page, KAFFEETASSE)
await page.goto('/angebote/1')
const anfragen = page.getByRole('button', { name: 'Platz anfragen' })
await anfragen.click()
await expect(page.getByRole('status')).toHaveText('Anfrage gesendet')
await expect(anfragen).toBeDisabled()
})Für KT-05.2 („Ben öffnet sein eigenes Angebot 1: kein Button ‚Platz anfragen‘“) braucht der Test eine Prüfung auf Abwesenheit. toHaveCount(0) allein würde sofort grün, solange die Seite noch lädt. Deshalb wartet der Test zuerst auf die Überschrift der Fahrt und prüft erst dann, dass der Button fehlt:
await expect(page.getByRole('heading', { name: 'Fahrt 1: Gütersloh zur Schule' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Platz anfragen' })).toHaveCount(0)Die Tests liegen je Kartengruppe in einer Datei. Welche Kundentests wo geprüft werden, steht jetzt in stories.md:
| Datei | Karten | Kundentests im Browser |
|---|---|---|
e2e/web-01-04-suche.spec.ts |
WEB-01, WEB-02, WEB-04 | KT-01.1, KT-01.2, KT-02.1, KT-04.1, KT-04.2 |
e2e/web-05-08-anfrage-anmeldung.spec.ts |
WEB-05, WEB-08 | KT-05.1, KT-05.2, KT-05.4, KT-08.1, KT-08.2 |
e2e/web-11-tastatur-screenreader.spec.ts |
WEB-11 | KT-11.1, KT-11.2 |
e2e/backend/kundentests-backend.spec.ts |
WEB-03, WEB-04, WEB-08 | KT-03.1, KT-04.1, KT-08.1, KT-08.2 gegen das echte Backend |
Thomas liest die Testnamen in der Kundenstunde mit. Er muss den Code nicht verstehen, aber er erkennt seine Sätze wieder und sieht, welche fehlen. Wer Kundentests noch näher an der Sprache der Kund*in haben möchte, schreibt sie in Gherkin (Gegeben, Wenn, Dann) und verbindet sie über ein Werkzeug wie Cucumber mit dem Code; so hat es das Team des Flurmonitors im Artikel Akzeptanztest gemacht. Für die Web-App bleibt es bei sprechenden Testnamen: eine Schicht weniger, und Thomas ist mit den Sätzen auf den Karten zufrieden.
WEB-11 test-first: Tastatur und Screenreader
Für WEB-11 setzt sich Thomas mit Lukas Hofmann zusammen, der die App mit Screenreader nutzt. Die beiden Kundentests der Karte werden genauer:
- KT-11.1: Lukas sucht Fahrten ab Herford, öffnet die Detailseite und fragt einen Platz an, nur mit Tab, Buchstaben und Enter.
- KT-11.2: Das Suchergebnis ist als Liste ausgezeichnet, jeder Button hat einen vorlesbaren Namen, und die Zahl der Treffer wird nach jeder Suche vorgelesen.
Das Paar arbeitet die Karte so ab, wie es das Team ab jetzt für jede Karte tut: erst die Kundentests als End-to-End-Tests, dann in kleinen Schritten die App ändern, bis alle Tests grün sind, und zum Schluss die Abnahme in der Kundenstunde.
Das Paar schreibt beide Tests, bevor es eine Zeile der App ändert. Für die Tastatur braucht es eine kleine Hilfsfunktion, die Tab drückt, bis ein bestimmtes Element den Fokus hat; so hängt der Test nicht davon ab, wie viele Elemente davor liegen, prüft aber, dass das Ziel überhaupt per Tab erreichbar ist:
/** drückt Tab, bis das Element den Fokus hat (höchstens max-mal), wie eine Person ohne Maus */
async function tabBis(page: Page, ziel: Locator, max = 15): Promise<void> {
for (let i = 0; i < max; i++) {
if (await ziel.evaluate((el) => el === document.activeElement)) {
return
}
await page.keyboard.press('Tab')
}
await expect(ziel).toBeFocused() // scheitert mit verständlicher Meldung
}
test('KT-11.1: Suche, Detailseite und Anfrage gelingen nur mit der Tastatur', async ({ page }) => {
await page.goto('/')
await tabBis(page, page.getByLabel('Haltepunkt'))
await page.keyboard.type('Herford') // springt in der geschlossenen Auswahl zum passenden Eintrag
await tabBis(page, page.getByRole('button', { name: 'Suchen' }))
await page.keyboard.press('Enter')
await expect(page.getByRole('listitem')).toHaveCount(1)
await tabBis(page, page.getByRole('link', { name: 'Details' }))
await page.keyboard.press('Enter')
await expect(page).toHaveURL('/angebote/4')
await tabBis(page, page.getByRole('button', { name: 'Platz anfragen' }))
await page.keyboard.press('Enter')
await expect(page.getByRole('status')).toHaveText('Anfrage gesendet')
})page.keyboard schickt echte Tastaturereignisse an den Browser, so als drücke jemand die Tasten; kein Test ruft hier click() auf. Der zweite Test prüft, was der Screenreader bekommt:
test('KT-11.2: Liste, Namen der Buttons und vorgelesene Trefferzahl', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('list').getByRole('listitem')).toHaveCount(4)
for (const button of await page.getByRole('button').all()) {
await expect(button).toHaveAccessibleName(/\S/) // jeder Button hat einen vorlesbaren Namen
}
// Die Trefferzahl muss in einer Live-Region stehen, sonst liest der Screenreader sie nicht vor
await expect(page.getByRole('status')).toHaveText('4 Fahrten gefunden')
await page.getByLabel('Haltepunkt').selectOption({ label: 'Gütersloh' })
await page.getByRole('button', { name: 'Suchen' }).click()
await expect(page.getByRole('status')).toHaveText('2 Fahrten gefunden')
})Der erste Lauf bringt eine Überraschung, eine gute und eine schlechte. KT-11.1 ist sofort grün: Weil das Team seit Iteration 1 echte Buttons, Links und beschriftete Felder verwendet, funktioniert die Tastatur schon. KT-11.2 dagegen ist rot:
1) [nachgebildet] › e2e/web-11-tastatur-screenreader.spec.ts:40:3 › WEB-11 Vollständig per Tastatur und Screenreader bedienbar › KT-11.2: Liste, Namen der Buttons und vorgelesene Trefferzahl
Error: expect(locator).toHaveText(expected) failed
Locator: getByRole('status')
Expected: "4 Fahrten gefunden"
Timeout: 5000ms
Error: element(s) not found
Die Trefferzahl steht zwar auf der Seite, aber in einem gewöhnlichen Absatz. Ein Screenreader liest nur vor, wo der Fokus gerade ist. Ändert sich anderswo etwas, bekommt Lukas davon nichts mit, es sei denn, die Stelle ist eine Live-Region: Ein Element mit role="status" (gleichbedeutend mit aria-live="polite") meldet jede Änderung seines Textes, sobald der Screenreader fertig gesprochen hat; role="alert" unterbricht sofort und ist Fehlern vorbehalten. Wichtig ist, dass die Live-Region schon im DOM steht, bevor sich ihr Text ändert. Ein Element, das erst mit dem Text erscheint, wird von vielen Screenreadern nicht angesagt. Genau so war FahrtenSuche.vue gebaut: Der Absatz mit der Trefferzahl entstand per v-if erst mit dem Ergebnis.
Die Korrektur zieht den Absatz aus dem v-if heraus. Er steht jetzt immer da und zeigt nacheinander „Fahrten werden geladen …“ und die Trefferzahl; auch der Hinweis „Keine passende Fahrt gefunden.“ wandert hinein:
<template>
<section>
<SuchFormular :haltepunkte="props.haltepunkte" @suchen="filter = $event" />
<!-- WEB-11: Live-Region steht immer im DOM, damit Screenreader jede neue Trefferzahl vorlesen (KT-11.2) -->
<p class="treffer" :class="{ 'text-secondary': laedt }" role="status">
{{ laedt ? 'Fahrten werden geladen …' : trefferText }}
</p>
<template v-if="!laedt && ergebnis">
<p v-if="ergebnis.art === 'fehler'" class="alert alert-danger" role="alert">{{ ergebnis.meldung }}</p>
<AngebotListe
v-else-if="ergebnis.art === 'treffer'"
:angebote="ergebnis.angebote"
:haltepunkte="props.haltepunkte"
/>
</template>
</section>
</template>Danach sind alle zwölf End-to-End-Tests grün, und die 107 Komponenten- und Integrationstests auch. Einer von ihnen hatte beim ersten Versuch widersprochen: Für eine leere Suche liest er den Hinweis „Keine passende Fahrt gefunden.“ aus dem ersten Element mit role="status" und fand dort den leeren Absatz für die Trefferzahl. Zwei Live-Regionen untereinander wären auch für Lukas verwirrend gewesen; deshalb steht der Hinweis jetzt in derselben Live-Region. Ob ein Screenreader wirklich vorliest, kann der Test nicht hören. Er prüft, dass die Voraussetzungen stimmen; das Vorlesen selbst probiert Lukas in der Kundenstunde aus. Für eine breitere Prüfung auf bekannte Barrieren gibt es zusätzlich Werkzeuge wie axe-core, die sich in Playwright einbinden lassen; das Team notiert das als Idee für später.
Wenn ein Test scheitert: Trace Viewer und UI-Modus
Ein roter End-to-End-Test sagt, welcher Ablauf nicht klappt, aber selten, warum. Playwright hat dafür zwei Werkzeuge.
Der Trace Viewer zeigt eine Aufzeichnung des Testlaufs: jede Aktion mit Zeitpunkt, einen Schnappschuss des DOM vor und nach jedem Schritt (man kann darin Elemente untersuchen wie in den Entwicklerwerkzeugen), die Netzwerkanfragen mit Antworten, die Konsolenausgaben und die Zeile im Testcode. Aufgezeichnet wird nach der Einstellung trace in der Konfiguration: 'on-first-retry' zeichnet nur auf, wenn ein gescheiterter Test wiederholt wird, kostet also bei grünen Läufen nichts. Lokal schaltet man sie für einen Lauf ein und öffnet das Ergebnis:
npx playwright test --trace on
npx playwright show-reportIm HTML-Bericht führt ein Klick auf einen Test zu seinem Trace. Eine einzelne Datei öffnet npx playwright show-trace test-results/<test>/trace.zip. Weil der Trace eine Datei ist, lässt er sich aus der Pipeline herunterladen und auf dem eigenen Rechner ansehen; der Fehler muss nicht noch einmal auftreten.
Der UI-Modus (npm run test:e2e:ui) ist für die Arbeit am Test gedacht: Er listet alle Tests, startet sie einzeln, zeigt dieselbe Zeitleiste wie der Trace Viewer und führt Tests bei jeder Änderung der Datei neu aus. Mit dem Locator-Werkzeug im UI-Modus klickt man auf ein Element und bekommt den passenden getByRole()-Aufruf.
Für instabile Tests gilt dieselbe Reihenfolge wie bei jedem Fehler: erst verstehen, dann beheben. Die häufigsten Ursachen und was hilft:
| Ursache | Erkennungszeichen | Abhilfe |
|---|---|---|
Prüfung ohne Warten (count(), textContent(), isVisible() mit expect(wert)) |
„Received“ zeigt den Zustand vor dem Laden, z. B. 0 statt 2 | Web-First Assertion auf den Locator: await expect(locator).toHaveCount(2) |
feste Wartezeit (waitForTimeout) |
mal grün, mal rot, je nach Last; Tests langsam | auf eine sichtbare Bedingung warten |
Locator über Aufbau oder Reihenfolge (li:nth-child(2), form button) |
rot nach Änderungen am Layout, ohne Fehler der App | getByRole(), getByLabel(), filter({ hasText }) |
| gemeinsame Daten mehrerer Tests | rot nur bei paralleler Ausführung oder in anderer Reihenfolge | eigene Daten je Test, Backend je Test nachbilden |
| echte Zeit und Zufall | rot an bestimmten Tagen oder Uhrzeiten | feste Daten, Uhr mit page.clock festlegen |
Wiederholungen (retries) heilen keine instabilen Tests, sie machen sie nur leiser. Playwright meldet einen Test, der erst bei der Wiederholung grün wird, im Bericht als „flaky“; das Team behandelt diese Meldung wie einen roten Test.
Gegen das echte Backend
Die Tests mit nachgebildetem Backend prüfen die App, nicht das Zusammenspiel mit Backend und Keycloak. Ob nginx /v1 richtig weiterreicht, ob das Backend das Token von Keycloak annimmt und ob die Daten zur Anzeige passen, zeigt erst ein Test gegen eine echte Auslieferung. Dafür gibt es das Projekt backend. Es läuft nur, wenn die Umgebungsvariable E2E_BACKEND_ADRESSE gesetzt ist, und nimmt nur die Tests im Ordner e2e/backend/:
// Konten und Passwort aus dem Realm-Export des Test-Realms (keycloak/mitfahrlabor-realm.json).
const PASSWORT = process.env.E2E_PASSWORT ?? 'mitfahren'
/** meldet sich auf der Anmeldeseite von Keycloak an; die IDs stammen aus dem Standard-Design von Keycloak */
async function meldeAnBeiKeycloak(page: Page, benutzername: string): Promise<void> {
await page.locator('#username').fill(benutzername)
await page.locator('#password').fill(PASSWORT)
await page.locator('#kc-login').click()
}
test('KT-03.1 und KT-04.1: Liste und Detailseite mit Daten des Backends', async ({ page }) => {
await page.goto('/angebote/1')
await meldeAnBeiKeycloak(page, 'kaffeetasse')
await expect(page).toHaveURL(/\/angebote\/1$/) // nach der Anmeldung zurück zum ursprünglichen Ziel
await expect(page.getByRole('heading', { level: 2 })).toHaveText('Fahrt 1: Gütersloh zur Schule')
await expect(page.getByRole('definition').first()).toHaveText('Hauptbahnhof, ZOB Steig 3')
})Hier nutzt der Test ausnahmsweise CSS-Selektoren mit IDs: Die Anmeldeseite gehört nicht dem Team, ihre Beschriftungen hängen von Sprache und Design des Realms ab, die IDs username, password und kc-login dagegen bleiben im Standard-Design von Keycloak gleich. Die Tests im Projekt backend lesen nur Daten. Ein Test, der eine Anfrage stellt oder bestätigt, würde den Datenbestand ändern, und der nächste Lauf fände andere freie Plätze vor. Solche Abläufe prüft das Team gegen das nachgebildete Backend oder gegen eine Testumgebung, die vor jedem Lauf frisch startet (das Backend lädt data.sql beim Start in die H2-Datenbank).
Gestartet wird das Projekt mit gesetzter Adresse:
$env:E2E_BACKEND_ADRESSE = "https://app.mitfahrlabor.example"
npx playwright test --project=backendGegen die lokale Entwicklungsumgebung (Backend auf Port 8080, Keycloak auf Port 8180, App mit npm run preview auf Port 4173) funktioniert dasselbe mit http://localhost:4173, wenn die App vorher mit dem lokalen Keycloak gebaut wurde (VITE_OIDC_AUTHORITY in .env.production.local). Damit ergänzt das Projekt backend den Rauchtest aus dem vorigen Artikel: Der prüft mit curl, ob die Auslieferung antwortet, Playwright prüft, ob man sich anmelden und Fahrten sehen kann.
End-to-End-Tests in der Pipeline
In der Pipeline läuft das Projekt nachgebildet bei jedem Push. Am einfachsten geht das im offiziellen Image von Playwright, in dem Node.js, die Browser und alle Bibliotheken des Betriebssystems schon installiert sind. Die Version im Namen des Images muss zur Version von @playwright/test in package.json passen:
e2e:
stage: testen
image: mcr.microsoft.com/playwright:v1.65.0-noble # Node, Chromium und alle Bibliotheken, passend zur Version in package.json
script:
- npm run test:e2e # baut die App, startet die Vorschau, testet gegen das nachgebildete Backend
artifacts:
when: always
paths:
- playwright-report/ # HTML-Bericht mit Traces der wiederholten Tests
reports:
junit: reports/e2e-junit.xml
expire_in: 14 daysGitLab setzt in jedem Job die Variable CI; damit gelten die Einstellungen der Konfiguration für die Pipeline: zwei Wiederholungen, Trace bei der ersten Wiederholung, kein vergessenes test.only, JUnit-Bericht für die Anzeige im Merge Request. Der HTML-Bericht bleibt als Artefakt auch dann erhalten, wenn Tests scheitern (when: always). Ohne das Image müsste der Job vorher npx playwright install --with-deps chromium ausführen, was bei jedem Lauf den Browser und Systempakete herunterlädt.
Playwright und Selenium im Vergleich
Das Team des Flurmonitors hat seine GUI-Tests mit Selenium WebDriver in Java geschrieben (siehe GUI-Tests mit Selenium). Beide Werkzeuge lösen dieselbe Aufgabe auf unterschiedlichen Wegen:
| Merkmal | Selenium WebDriver (Serie Flurmonitor) | Playwright (Web-App) |
|---|---|---|
| Verbindung zum Browser | W3C-Standard WebDriver über einen Browsertreiber (z. B. ChromeDriver), WebDriver BiDi als Erweiterung | eigenes Protokoll zu mitgelieferten Browsern (Chromium, Firefox, WebKit) |
| Browser beschaffen | Selenium Manager lädt den passenden Treiber, der Browser ist installiert | npx playwright install lädt die passenden Browser |
| Warten | explizit mit WebDriverWait und Bedingungen |
eingebaut: Actionability Checks und Web-First Assertions |
| Elemente finden | By.id, By.cssSelector, By.xpath, mit Page Objects gekapselt |
getByRole(), getByLabel() und weitere, Page Objects möglich |
| Netzwerk nachbilden | nur eingeschränkt, meist über ein nachgebildetes Backend außerhalb des Browsers | page.route() im Test |
| Sprachen | Java, Python, C#, JavaScript, Ruby und weitere | TypeScript/JavaScript, Python, Java, .NET |
| Fehlersuche | Bildschirmfotos und Protokolle, selbst einzurichten | Trace Viewer, UI-Modus, HTML-Bericht |
| Testrunner | JUnit, TestNG oder ein anderer der Sprache | eigener Runner Playwright Test (für TypeScript) |
Selenium spricht einen Standard, den alle großen Browser selbst umsetzen, und läuft mit dem Browser, den Nutzende installiert haben; dafür muss man mehr selbst bauen. Playwright bringt Browser, Runner, Warten und Werkzeuge in einem Paket mit; dafür testet es gegen eigene Browser-Builds und bindet das Projekt an ein Werkzeug. Für ein Java-Projekt mit Thymeleaf liegt Selenium nahe, weil Tests und Anwendung dieselbe Sprache und denselben Build nutzen. Für eine TypeScript-App mit Vite liegt Playwright nahe, aus demselben Grund.
Iteration 5 abgeschlossen
In der Kundenstunde am Ende der Iteration läuft npm run test:e2e auf dem Beamer: zwölf Zeilen, jede mit einer Nummer, die Thomas von seinen Karten kennt. Danach probiert Lukas WEB-11 mit seinem Screenreader aus: Er sucht ab Herford, hört „1 Fahrt gefunden“, öffnet die Fahrt per Tastatur und fragt an. Thomas nimmt die Karte ab. Velocity von Iteration 5: 5 Punkte (WEB-10 mit 2, WEB-11 mit 3). Weil die Änderung für Nutzende sichtbar, aber klein ist, hebt das Paar die Version auf 0.2.1 und trägt die Live-Region im CHANGELOG.md unter „Behoben“ ein.
Zwei Gewohnheiten bleiben: Jede neue Karte bekommt ihre Kundentests zuerst als End-to-End-Test, wo der Browser etwas beiträgt, und das Projekt backend läuft nach jedem Ausliefern, gleich nach dem Rauchtest.
Die Ergebnisse im Repository
Den vollständigen Code-Stand findest Du im Ordner src/11-vue-e2e-akzeptanztest-playwright des Repositorys dieser Reihe: die Konfiguration in playwright.config.ts, die Tests im Ordner e2e/ (Hilfen für Anmeldung und nachgebildetes Backend in e2e/hilfen/, Tests gegen das echte Backend in e2e/backend/), die korrigierte Live-Region in src/components/FahrtenSuche.vue, tsconfig.e2e.json und den Job e2e in .gitlab-ci.yml. README.md, stories.md und CHANGELOG.md sind fortgeschrieben, die Version ist 0.2.1.
Nächster Schritt: ein Änderungswunsch
Thomas hat in der Kundenstunde angekündigt, dass die Schule etwas Neues möchte: Wer auf eine Antwort wartet, soll nicht ständig neu laden müssen. In Extreme Programming ist eine solche Änderung kein Störfall, sondern Alltag (Embrace Change). Im Artikel Change Request Live-Aktualisierung nimmt das Team den Wunsch auf, und die Tests aus allen bisherigen Artikeln sichern ab, dass dabei nichts kaputtgeht.
In den Übungsaufgaben zu diesem Artikel wählst Du Locator für die Seite der Mitfahrgesuche, schreibst End-to-End-Tests für das Einstellen eines Gesuchs und für Fehlerfälle und reparierst einen Test, der mal grün und mal rot ist.
Leitfragen
- Was unterscheidet einen End-to-End-Test von einem Integrationstest mit Vitest und MSW, und warum braucht WEB-11 einen End-to-End-Test?
Der End-to-End-Test bedient den Produktions-Build in einem echten Browser so, wie Nutzende es tun; der Integrationstest rendert Komponenten in jsdom ohne Darstellung, echten Fokus und Tab-Reihenfolge. Ob sich die App nur mit der Tastatur bedienen lässt, zeigt nur ein echter Browser. siehe Abschnitt „Wo End-to-End-Tests stehen“
- Wozu dienen
webServer,baseURLund Projekte inplaywright.config.ts?
webServer baut die App und startet die Vorschau, bevor die Tests beginnen; baseURL macht Adressen in den Tests relativ; Projekte führen Tests mit unterschiedlichen Einstellungen aus, hier gegen das nachgebildete und gegen das echte Backend. siehe Abschnitt „Playwright einrichten“
- Warum wählt das Team
getByRole()vorgetByLabel(),getByText(),getByTestId()und CSS-Selektoren?
getByRole() findet Elemente über Rolle und zugänglichen Namen, also so, wie Nutzende und Screenreader sie wahrnehmen. Änderungen an Klassen und Aufbau lassen den Test grün, geänderte Beschriftungen machen ihn rot. CSS-Selektoren hängen am Aufbau des HTML und brechen bei Umbauten ohne Fehler der App. siehe Abschnitt „Locator“
- Warum ist
expect(await locator.count()).toBe(1)unzuverlässiger alsawait expect(locator).toHaveCount(1)?
Die erste Form liest die Zahl einmal sofort und vergleicht; ist die Antwort des Backends noch nicht da, ist sie falsch. Die zweite ist eine Web-First Assertion: Playwright fragt wiederholt nach, bis die Bedingung erfüllt ist oder die Zeit (Standard 5 Sekunden) abläuft. siehe Abschnitt „Auto-Waiting“
- Wie bilden die Tests Backend und Anmeldung nach, und was zeigen sie deshalb nicht?
page.route() fängt Anfragen an /v1 (und für KT-08.1 an Keycloak) ab und antwortet mit den Daten aus data.sql; addInitScript() legt vor dem Start der App eine Anmeldung in den sessionStorage. Nicht gezeigt wird, ob das echte Backend so antwortet, das Token annimmt und ob die echte Anmeldeseite funktioniert; das prüft das Projekt backend. siehe Abschnitt „Backend und Anmeldung nachbilden“
- Was ist eine Live-Region, und warum hat KT-11.2 die Trefferzahl zuerst nicht gefunden?
Ein Element mit role="status" (oder aria-live), dessen Textänderungen ein Screenreader vorliest, auch wenn der Fokus woanders ist. Die Trefferzahl stand in einem gewöhnlichen Absatz, der zudem erst mit dem Ergebnis per v-if entstand. Die Live-Region muss vorher im DOM stehen. siehe Abschnitt „WEB-11 test-first“
- Was zeigt der Trace Viewer, und warum steht
traceauf'on-first-retry'?
Jede Aktion mit DOM-Schnappschüssen vor und nach dem Schritt, Netzwerkanfragen, Konsolenausgaben und die Zeile im Test. Mit 'on-first-retry' wird nur aufgezeichnet, wenn ein gescheiterter Test wiederholt wird; grüne Läufe kosten keine Zeit und keinen Speicher. siehe Abschnitt „Wenn ein Test scheitert“
- Wann würdest Du für ein Projekt Selenium, wann Playwright wählen?
Selenium spricht den W3C-Standard WebDriver, passt zu Java-Projekten und testet mit installierten Browsern, verlangt aber mehr Eigenbau (Warten, Fehlersuche). Playwright bringt Browser, Runner, Auto-Waiting, Netzwerk-Nachbildung und Trace Viewer mit und passt zu TypeScript-Projekten. Entscheidend sind Sprache und Build des Projekts und welche Browser geprüft werden müssen. siehe Abschnitt „Playwright und Selenium im Vergleich“
Links und weitere Informationen
für den Einstieg: Build und Auslieferung mit dem Produktions-Build, den die Tests prüfen
als Hintergrund: Extreme Programming mit den Kundentests und Systemtest und Abnahmetest aus der Backend-Reihe
zum Vergleich: GUI-Tests mit Selenium und Akzeptanztest mit Cucumber aus der Reihe zum Flurmonitor
als Nachschlagewerk: Playwright: Writing tests, Playwright: Locators, Playwright: Auto-waiting und Playwright: Best Practices
als Nachschlagewerk: Playwright: Mock APIs, Playwright: Trace viewer und Playwright: Continuous Integration
als Nachschlagewerk: MDN: ARIA live regions und MDN: ARIA status role
Hinweis zur Nachnutzung als Open Educational Resource (OER)
Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “End-to-End- und Akzeptanztests mit Playwright: Kundentests im echten Browser” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/vue-e2e-akzeptanztest-playwright veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/programmierung/vue. Erstellt mit KI-Assistenz; von mir umfassend redaktionell überarbeitet, geprüft und freigegeben. Stand: 11.11.2026.
[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]
