Automatisierte Tests mit Playwright skalieren oft nur bis zu einem bestimmten Punkt. Was als übersichtliche Codebasis beginnt, verwandelt sich mit wachsender Testanzahl in ein Wartungschaos: Änderungen an Page Objects erfordern Updates in Hunderten von Dateien, parallele Testläufe überschreiben sich gegenseitig Daten, und neue Teammitglieder verstehen den Testaufbau nicht mehr. Die Lösung liegt nicht in Disziplin, sondern in der Architektur. Drei bewährte Regeln zeigen, wie Flows, Fixtures und Page Objects so strukturiert werden, dass Refaktorierungen zukünftig in Stunden statt Tagen erledigt sind.
Warum Test-Architektur oft zum Flaschenhals wird
Ein typisches Szenario: Ein Team startet mit 50 Playwright-Tests und einer klaren Trennung zwischen Page Objects, Flows und Testfällen. Alles funktioniert reibungslos – bis das Projekt wächst. Plötzlich gibt es 300 Tests, und eine scheinbar harmlose Änderung, etwa die Anpassung eines Konstruktors, zieht manuelle Updates in über 150 Dateien nach sich. Zwei Hauptprobleme verschärfen sich dabei:
- Parallelisierte Testläufe führen zu Datenkonflikten: Zwei Worker erzeugen gleichzeitig einen Nutzer namens "Ivan". Ein Test greift versehentlich auf die Daten des anderen zu, und plötzlich scheitern Tests, für die der Code selbst nicht verantwortlich ist.
- Tests verlieren ihre Lesbarkeit: Vor den eigentlichen Testschritten häufen sich Zeilen mit Setup-Code an. Neue Entwickler:innen können nicht mehr erkennen, was getestet wird und was nur technischer Overhead ist.
Diese Probleme sind keine Frage von Unachtsamkeit, sondern von Architektur. Page Objects, die direkt im Test instanziiert werden, und Flows, die Zustände in Konstruktoren erfassen, machen Code unnötig starr. Die gute Nachricht: Mit gezielten Änderungen lässt sich das Problem vorab lösen.
Regel 1: Fixtures statt new – Dependency Injection für Tests
Der häufigste Fehler: Page Objects und Flows werden direkt im Testfall mit dem new-Operator erstellt. Das sieht auf den ersten Blick harmlos aus:
test('Bestellvorgang', async ({ page }) => {
const warenkorbSeite = new WarenkorbSeite(page);
const checkoutSeite = new CheckoutSeite(page);
const checkoutFlow = new CheckoutFlow(warenkorbSeite, checkoutSeite);
await checkoutFlow.bestellungAbschicken();
});Das Problem wird erst sichtbar, wenn sich die Abhängigkeiten ändern. Muss WarenkorbSeite künftig einen Logger, eine API-Schnittstelle oder eine Konfiguration erhalten, müssen alle 150 Tests, die WarenkorbSeite instanziieren, angepasst werden. Das kann Tage dauern.
Die Lösung: Fixtures als zentraler Dependency-Container
Playwright bietet eine elegante Alternative: Fixtures. Diese fungieren als Dependency-Injection-Container und zentralisieren die Erstellung von Objekten. Die Tests selbst bleiben frei von Implementierungsdetails:
// fixtures.ts
export const test = base.extend({
warenkorbSeite: async ({ page }, use) => {
await use(new WarenkorbSeite(page));
},
checkoutFlow: async ({ warenkorbSeite, checkoutSeite }, use) => {
await use(new CheckoutFlow(warenkorbSeite, checkoutSeite));
},
});Der Test liest sich nun wie eine klare Spezifikation:
test('Bestellvorgang', async ({ checkoutFlow }) => {
await checkoutFlow.bestellungAbschicken();
});Ändert sich der Konstruktor von WarenkorbSeite, muss nur die Fixture in fixtures.ts angepasst werden. Alle Tests bleiben unverändert – ein einziger Ort für Wartung.
Wann Fixtures sinnvoll sind – und wann nicht
Fixtures eignen sich besonders für Objekte, die:
- einen Page-Kontext benötigen (z. B.
pageaus Playwright), - Setup- oder Teardown-Logik erfordern (z. B. Datenbereinigung nach jedem Test),
- Zustände verwalten, die sich im Laufe der Zeit ändern.
Statische Hilfsfunktionen wie formatDatum() oder mathematische Operationen gehören dagegen nicht in Fixtures. Hier reichen einfache ES6-Importe aus – sie sind schneller und vermeiden unnötige Komplexität.
Regel 2: Getter statt Konstruktoren – warum async in Page Objects tabu ist
Ein häufig unterschätztes Muster ist die Nutzung von async-Code im Konstruktor eines Page Objects. Viele Tutorials zeigen dies als Standardlösung:
class WarenkorbSeite {
private artikelAnzahl: Locator;
constructor(page: Page) {
this.artikelAnzahl = page.locator('.artikel');
}
}Auf den ersten Blick scheint dies unproblematisch, da Playwright-Locators erst bei Interaktion mit dem DOM ausgewertet werden. Doch der Teufel steckt im Detail: Entwickler:innen neigen dazu, den Konstruktor für die Erfassung von Zustand zu missbrauchen:
// Nie so umsetzen!
constructor(page: Page) {
(async () => {
this.artikelAnzahl = await page.locator('.artikel').count();
})();
}Dies führt zu einem schwer zu debuggenden Race Condition. Der Test greift möglicherweise auf artikelAnzahl zu, bevor die async-Funktion im Konstruktor abgeschlossen ist. Das Ergebnis: zufällige Fehlschläge in der CI-Pipeline, die lokal nicht reproduzierbar sind.
Die bessere Alternative: Lazy Getter
Getter bieten eine strukturelle Lösung. Sie verhindern, dass Zustand im Konstruktor erfasst wird, und zwingen Entwickler:innen, den Code so zu schreiben, dass er nicht async sein kann:
class WarenkorbSeite {
get artikelAnzahl() {
return this.page.locator('.artikel');
}
}Getter sind synchron, sodass unerwünschte async-Operationen gar nicht erst möglich sind. Zudem wird der Locator erst bei der ersten Verwendung ausgewertet – genau dann, wenn er tatsächlich benötigt wird. Dies macht den Code nicht nur sicherer, sondern auch wartbarer.
Regel 3: Testdaten reproduzierbar gestalten – der Schlüssel zu parallelen Läufen
Parallele Testausführungen sind ein Segen für die Geschwindigkeit, aber ein Fluch für die Stabilität, wenn Testdaten nicht isoliert werden. Ein klassisches Beispiel:
- Test A erstellt einen Nutzer namens "Ivan".
- Test B erstellt ebenfalls einen Nutzer namens "Ivan".
- Beide Tests laufen parallel und überschreiben sich gegenseitig die Daten.
Die Lösung liegt in der Kombination aus testId, RUN_ID und repeatEachIndex. Diese drei Werte ermöglichen eindeutige Identifikatoren für jeden Testlauf:
test('Nutzerregistrierung', async ({ page }) => {
const eindeutigeId = `ivan_${process.env.RUN_ID}_${test.info().repeatEachIndex}`;
await page.locator('input[name="name"]').fill(eindeutigeId);
// ... weitere Testschritte
});testId: Eindeutige Kennung des Tests.RUN_ID: Ein von der CI-Umgebung oder Playwright bereitgestellter Wert, der den aktuellen Testlauf identifiziert.repeatEachIndex: Ermöglicht die Wiederholung desselben Tests mit unterschiedlichen Daten.
Durch diese Kombination wird sichergestellt, dass jeder Test seine eigenen, isolierten Daten verwendet – selbst bei paralleler Ausführung.
Fazit: Architektur heute spart Zeit morgen
Die Umstellung auf eine saubere Playwright-Architektur erfordert zunächst einen Aufwand von einigen Stunden. Doch dieser lohnt sich: Refaktorisierungen werden zur Routine, parallele Testläufe verlaufen stabil, und neue Teammitglieder verstehen den Code schneller. Die drei vorgestellten Regeln – Fixtures statt new, Getter statt Konstruktoren und reproduzierbare Testdaten – bilden das Fundament für eine skalierbare Testsuite. Wer sie jetzt umsetzt, vermeidet später Tage des Debuggings und der manuellen Anpassungen. Die Investition in Architektur zahlt sich aus – nicht nur in der Codequalität, sondern auch in der Produktivität des gesamten Teams.
KI-Zusammenfassung
Learn how to refactor and scale Playwright tests with fixture-based DI, lazy getters, and strategic data seeding to reduce CI failures and speed up refactoring cycles.