Grüner Test, kaputtes Bild: Wie man Playwright dazu bringt, ehrlich zu sein
28. Aug. 2026 • Paul Kalbitzer

Grüne Tests in der CI beruhigen ungemein. Bis die Webseite live plötzlich mit leeren Platzhaltern und kaputten Bild-Icons dasteht, obwohl die Pipeline seit Wochen grün durchläuft. Genau diesen Spagat zwischen „Test technisch grün“ und „für Nutzer:innen kaputt“ nehmen wir uns hier vor.
Stell dir folgenden Test vor:
test('Produktseite zeigt das Produktbild', async ({ page }) => {
await page.goto('/products/42');
await expect(page.locator('#product-image')).toBeVisible();
});
Er läuft seit Wochen grün. Und trotzdem sehen Kund:innen auf der Produktseite ein kaputtes Bild-Icon statt des Produkts, das sie kaufen sollen. Wie kann das sein?
Die Antwort steckt in einem Missverständnis darüber, was toBeVisible() eigentlich prüft. Und sie führt am Ende zu einem der nützlichsten, aber am wenigsten genutzten Werkzeuge in Playwright: eigenen Matchern.
Warum „sichtbar“ nicht „geladen“ bedeutet
toBeVisible() schaut ausschließlich auf den DOM: Existiert das Element? Hat es eine Bounding Box ungleich null? Ist es nicht per display: none oder visibility: hidden versteckt? Ein <img>-Tag mit einer toten Bild-URL erfüllt alle drei Kriterien. Der Browser reserviert Platz für das Bild und zeigt an dessen Stelle das bekannte kaputte Symbol. Der Bereich ist da, er ist sichtbar – er ist eben nur leer.
Ein fehlgeschlagener Bild-Request wirft keinen JavaScript-Fehler, bricht page.goto() nicht ab und lässt den Seitenaufbau ansonsten unangetastet. Aus Sicht des DOM ist alles in Ordnung. Nur die Nutzer:innen sehen das Problem. Der Test stellte einfach die falsche Frage.
Die richtige Frage lautet: Hat das Bild tatsächlich geladen? Und diese Information gibt der Browser nicht von selbst preis – man muss gezielt danach fragen.
Der Hinweis, den der Browser bereithält
Jedes <img>-Element besitzt zwei Eigenschaften, die zusammen die Wahrheit verraten:
naturalWidth— die tatsächliche Pixelbreite der geladenen Bilddatei. Bei Erfolg ein Wert größer null, bei einem fehlgeschlagenen Request exakt0.complete— ein Boolean, der anzeigt, ob der Browser den Ladeversuch abgeschlossen hat (egal ob erfolgreich oder nicht).
Daraus ergibt sich eine simple Logik:
complete |
naturalWidth |
Bedeutung |
|---|---|---|
false |
– | Lädt noch, noch kein Urteil fällen |
true |
> 0 |
Erfolgreich geladen |
true |
0 |
Ladeversuch beendet, aber fehlgeschlagen |
Das ist die Grundlage für alles Weitere.
Schritt 1: Eine Prüfung, die nicht zu früh urteilt
Die naheliegende Umsetzung sieht so aus:
const geladen = await page.locator('#product-image').evaluate(
(img: HTMLImageElement) => img.complete && img.naturalWidth > 0
);
expect(geladen).toBe(true);
Das Problem: evaluate() prüft genau einmal, sofort. Lädt das Bild in diesem Moment noch, ist complete schlicht false – der Test scheitert, obwohl das Bild eine Sekunde später einwandfrei da gewesen wäre. Aus einem falschen „bestanden“ wird ein falsches „durchgefallen“. Nichts gewonnen.
Was hier fehlt, ist Playwrights automatisches Wiederholen, wie man es von toBeVisible() oder toHaveText() kennt. Mit expect.poll() lässt sich genau das nachrüsten:
await expect
.poll(
() =>
page.locator('#product-image').evaluate(
(img: HTMLImageElement) => img.complete && img.naturalWidth > 0
),
{
message: 'Bild #product-image wurde nie erfolgreich geladen',
timeout: 3_000,
}
)
.toBe(true);
expect.poll() ruft die übergebene Funktion so lange erneut auf, bis der Rückgabewert die Bedingung erfüllt oder das Timeout erreicht ist. Ein langsames CDN? Kein Problem, der Poll wartet. Ein wirklich kaputtes Bild? complete wird irgendwann true, naturalWidth bleibt bei 0, und der Test scheitert kontrolliert – inklusive eigener, verständlicher Fehlermeldung.
Schritt 2: Nicht nur ein Bild, sondern die ganze Seite im Blick
Ein Produkt-Screen lädt selten nur ein einziges Bild. Schriften, Skripte, Stylesheets, weitere Grafiken – jede dieser Ressourcen kann fehlschlagen. Statt jede einzeln zu prüfen, lohnt sich ein Netzwerk-Listener, der die gesamte Seite überwacht:
test('Produktseite lädt ohne fehlerhafte Ressourcen', async ({ page }) => {
const fehlgeschlagen: string[] = [];
page.on('response', (response) => {
if (response.status() >= 400) {
fehlgeschlagen.push(`${response.status()} ← ${response.url()}`);
}
});
await page.goto('/products/42');
await page.waitForLoadState('networkidle');
expect(fehlgeschlagen, `Fehlerhafte Ressourcen:\n${fehlgeschlagen.join('\n')}`)
.toHaveLength(0);
});
Drei Details entscheiden hier über Erfolg oder stille Lücken:
- Der Listener muss vor
goto()registriert werden. Antworten, die vor dem Start des Listeners eintreffen, sind unwiderruflich verloren. waitForLoadState('networkidle')gibt spät nachgeladenen Ressourcen Zeit, überhaupt erst zu starten. Auf Seiten mit dauerhaftem Hintergrund-Polling kann dieser Zustand allerdings nie eintreten. Dort empfiehlt sich ein expliziter Timeout stattnetworkidle.- Eine eigene Fehlermeldung als zweites Argument von
expect()verwandelt ein nichtssagendesexpected 1 to be 0in eine direkt verwertbare Information für den nächtlichen CI-Alarm.
In der Praxis will man außerdem Drittanbieter-Rauschen ausblenden. Tracking-Skripte werfen gerne mal einen 400er, der niemanden interessiert:
page.on('response', (response) => {
const url = new URL(response.url());
const eigeneHosts = ['app.myshop.com', 'cdn.myshop.com'];
if (response.status() >= 400 && eigeneHosts.includes(url.hostname)) {
fehlgeschlagen.push(`${response.status()} ← ${response.url()}`);
}
});
Schritt 3: URLs direkt abklopfen, ohne aufs Rendering zu warten
Manchmal reicht eine schnellere Frage: Lösen sich alle Bild-URLs einer Seite überhaupt auf? Playwright bringt mit page.request einen HTTP-Client mit, der sich Cookies mit dem Browser teilt – auch geschützte Bild-URLs funktionieren also ohne Zusatzaufwand:
const urls = await page.locator('img').evaluateAll((imgs) =>
imgs.map((img) => (img as HTMLImageElement).src).filter(Boolean)
);
for (const url of urls) {
const response = await page.request.get(url);
expect.soft(response.status(), `Kaputtes Bild: ${url}`).toBe(200);
}
expect.soft() ist hier der entscheidende Kniff: Eine normale expect()-Prüfung würde beim ersten Fehler abbrechen. Die weiche Variante sammelt stattdessen alle Fehlschläge und lässt den Test bis zum Ende durchlaufen. Am Schluss scheitert er trotzdem – aber mit einer vollständigen Liste statt eines einzigen Symptoms pro CI-Lauf.
Wichtig zu wissen: Ein HTTP-Status 200 garantiert nicht, dass das Bild auch tatsächlich sichtbar gerendert wird (denkbar sind etwa ein korruptes Dateiformat oder ein falscher Content-Type). Schritt 3 eignet sich hervorragend, um schnell viele URLs zu überfliegen; Schritt 1 bleibt die verlässlichste Methode, um zu verifizieren, was ein Mensch tatsächlich sehen würde.
Von der Kopie zum eigenen Matcher
Sobald dieselbe Sieben-Zeilen-expect.poll()-Logik in mehreren Testdateien auftaucht, beginnt das eigentliche Problem: Kopien driften auseinander. Eine Datei prüft naturalWidth, eine andere vergisst es. Aus „der Bild-Check“ werden über Zeit mehrere leicht unterschiedliche Bild-Checks, und niemand weiß mehr genau, welcher was wirklich abdeckt. Dazu kommt: expect(zustand).toBe(true) verrät einer Reviewerin nichts über die eigentliche Absicht, und im Fehlerfall bekommt man nur ein nichtssagendes expected true, received false.
Playwrights Antwort darauf ist expect.extend(). Man bringt expect einmalig eine neue, benannte Prüfung bei – und jeder Test bekommt danach eine lesbare, automatisch wiederholende, selbsterklärende Einzeiler-Assertion.
Der Vertrag hinter jedem Matcher
Ein eigener Matcher ist im Kern eine Funktion mit einer festen Signatur:
- Eingabe: das Objekt aus
expect(...)als erstes Argument, plus alle weiteren Argumente aus dem eigentlichen Methodenaufruf. - Ausgabe: ein Objekt der Form
{ pass: boolean, message: () => string }.
Registriert wird das so:
// src/fixtures/matchers.ts
import { expect as base } from '@playwright/test';
export const expect = base.extend({
// eigene Matcher kommen hier rein
});
Der Import von expect as base ist kein Stilmittel, sondern notwendig: Man kann in derselben Datei nicht zweimal eine Variable expect haben. Das Original bekommt den Namen base, die erweiterte Version übernimmt den vertrauten Namen expect. Tests importieren danach einfach aus der eigenen Datei statt aus @playwright/test – und alles fühlt sich an wie eingebaute Playwright-Funktionalität.
Ein Mini-Beispiel zum Aufwärmen
Am einfachsten lässt sich das Prinzip ohne Browser und ohne async zeigen:
export const expect = base.extend({
toBeWithinRange(erhalten: number, min: number, max: number) {
const pass = erhalten >= min && erhalten <= max;
return {
pass,
message: () =>
pass
? `Erwartet: ${erhalten} liegt NICHT in [${min}, ${max}]`
: `Erwartet: ${erhalten} liegt in [${min}, ${max}]`,
};
},
});
Verwendet wird das so: await expect(anzahlArtikel).toBeWithinRange(1, 5);
Drei Dinge fallen dabei auf, die für jeden Matcher gelten:
- Der Methodenname ist der Assertion-Name —
toBeWithinRangeheißt in Tests exakt so. - Das erste Argument (
erhalten) füllt Playwright automatisch mit dem Inhalt vonexpect(...). Alles danach (min,max) kommt aus dem eigentlichen Methodenaufruf. - Es gibt zwei Nachrichtenvarianten, obwohl doch nur der Fehlerfall interessant scheint. Der Grund dafür wird gleich klar.
Sobald man mit dem Browser sprechen muss — etwa um textContent() eines Elements zu lesen — wird die Methode zusätzlich async, und statt einer Zahl landet ein Locator als erstes Argument in der Funktion. Ansonsten bleibt der Aufbau identisch.
Der produktionsreife Matcher: toBeProperlyLoaded
Alles zusammengeführt ergibt genau die Prüfung, die eingangs gefehlt hat — jetzt aber als wiederverwendbarer, robuster Baustein:
// src/fixtures/matchers.ts
import { expect as base, Locator } from '@playwright/test';
export const expect = base.extend({
async toBeProperlyLoaded(locator: Locator, options?: { timeout?: number }) {
const timeout = options?.timeout ?? 10_000;
let letzterZustand = 'unbekannt';
try {
await base
.poll(
async () => {
const zustand = await locator.evaluate((img: HTMLImageElement) => ({
complete: img.complete,
naturalWidth: img.naturalWidth,
src: img.currentSrc || img.src,
}));
letzterZustand = JSON.stringify(zustand);
return zustand.complete && zustand.naturalWidth > 0;
},
{ timeout }
)
.toBe(true);
return { pass: true, message: () => 'Bild wurde erfolgreich geladen' };
} catch {
return {
pass: false,
message: () =>
`Erwartet, dass das Bild vollständig geladen ist — war es aber nicht.\n` +
`Letzter beobachteter Zustand: ${letzterZustand}\n` +
`Hinweis: naturalWidth 0 bei complete true bedeutet, der Request ist fehlgeschlagen ` +
`oder die Datei ist beschädigt.`,
};
}
},
});
Ein paar Details lohnen einen genaueren Blick:
options?: { timeout?: number }erlaubt drei gültige Aufrufe: ganz ohne Argument, mit leerem Objekt, oder mit explizitem Timeout.options?.timeout ?? 10_000kombiniert Optional Chaining mit Nullish Coalescing. Zehn Sekunden sind bewusst großzügiger als ein typisches Assertion-Timeout, weil Bildladezeiten stark von CDN und Netzwerk abhängen.letzterZustandfungiert als Flugschreiber: Bei jedem Poll-Durchlauf wird der zuletzt beobachtete Zustand überschrieben und landet im Fehlerfall direkt in der Meldung — inklusive der defekten URL.base.poll, nichtexpect.poll— innerhalb dieser Datei istexpectja gerade die Sache, die gebaut wird;basebleibt das fertige, ursprüngliche Playwright-expect.- Der
try/catch-Block übersetzt zwischen zwei unterschiedlichen Fehlerkonzepten:poll()kommuniziert ein Timeout, indem es wirft. Ein Matcher muss stattdessen{ pass: false, ... }zurückgeben.
Am Ende schrumpft der Testcode wieder auf die ursprüngliche Länge — nur stellt er jetzt die richtige Frage:
import { test } from '@playwright/test';
import { expect } from '../src/fixtures/matchers';
test('Produktseite zeigt das Produktbild', async ({ page }) => {
await page.goto('/products/42');
await expect(page.locator('#product-image')).toBeProperlyLoaded();
});
Warum message eine Funktion ist
Eine Fehlermeldung als Funktion statt als fertiger String zu übergeben wirkt zunächst unnötig umständlich — bis man bedenkt, dass in einer gesunden Testsuite die überwiegende Mehrheit der Assertions besteht. Der aufwendige Teil lohnt sich nur, wenn er tatsächlich gebraucht wird.
Der zweite Grund ist .not. Jeder eigene Matcher funktioniert automatisch auch negiert:
await expect(page.locator('.platzhalter-bild')).not.toBeProperlyLoaded();
Gibt der Matcher pass: true zurück, während der Test .not verwendet hat, schlägt die Assertion fehl — und Playwright zeigt genau die Nachricht aus dem pass: true-Zweig an. Deshalb formuliert man im Mini-Beispiel den Erfolgsfall als „liegt NICHT in […]“. Beide Nachrichtenzweige müssen für sich alleine Sinn ergeben.
Checkliste zum Mitnehmen
Flakiness zeigt sich nicht nur in Tests, die zufällig scheitern. Ihr unauffälligerer Zwilling sind Tests, die fälschlich bestehen — und kaputte Bilder sind dafür das Paradebeispiel: unsichtbar für toBeVisible(), unsichtbar für page.goto(), aber sehr sichtbar für alle Kund:innen.
Fünf Werkzeuge, die sich in jedes Playwright-Setup übertragen lassen:
naturalWidth+complete— das Geständnis des Browsers, wenn ein Bild fehlgeschlagen ist.expect.poll()— macht aus jedem berechenbaren Wert eine automatisch wiederholende Assertion.page.on('response')— eine Stolperdraht-Überwachung für sämtliche 4xx/5xx-Antworten einer Seite.expect.soft()— sammelt alle Fehlschläge in einem Lauf, statt sie einzeln über mehrere CI-Durchläufe zu enthüllen.expect.extend()— verpackt die richtige Prüfung hinter einem sprechenden Namen, der sich liest wie ein eingebauter Teil von Playwright.
Der eigentliche Gewinn liegt nicht in einer einzelnen Zeile Code, sondern in der Gewohnheit dahinter: Immer wenn ein Team eine Prüfung wiederholt falsch oder unvollständig schreibt, lohnt es sich, die richtige Version einmal sauber zu definieren und ihr einen Namen zu geben. Nach ein paar Monaten hat jedes ausgereifte Playwright-Projekt so ein kleines eigenes Vokabular — toBeProperlyLoaded(), toBeSortedBy(), toMatchCurrency() — und Tests lesen sich plötzlich wie eine gemeinsame Sprache des Teams statt wie zusammengewürfelte Assertions.
Habt ihr in euren Suites schon so einen „grünen, aber kaputten“ Fall erwischt — oder baut ihr bereits eigene Matcher? Schreibt’s uns gerne.
Hinweis der Redaktion: Die Kernideen und wichtigsten Punkte dieses Beitrags stammen von Gastautorin Sewmini Jayaweera aus dem Newsletter Software Testing Weekly (Original auf Medium). Wir haben den Text für euch zusammengetragen und ins Deutsche übersetzt.