End-to-End-Teststrategien mit Playwright und TypeScript
Zuverlässige End-to-End-Suites mit Playwright: Page Object Models, Testisolation, Netzwerk-Intercepting, visuelle Regression und CI ohne Flakiness.

Warum E2E-Tests immer noch wichtig sind
Unit-Tests prüfen Funktionen. Integrationstests prüfen Module. Keiner sagt dir, ob ein Nutzer tatsächlich bezahlen kann. End-to-End-Tests schließen diese Lücke, indem sie die komplette Anwendung vom Browser bis zur Datenbank durchlaufen. Der Kompromiss ist Geschwindigkeit und Instabilität – aber Playwrights Architektur minimiert beides.
Projekt-Setup und Konfiguration
Playwright unterstützt Chromium, Firefox und WebKit über eine einzige API. Konfiguriere es so, dass alle drei in CI laufen, lokal aber nur Chromium – aus Geschwindigkeitsgründen.
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: process.env.CI
? [["html"], ["github"]]
: [["list"]],
use: {
baseURL: "http://localhost:3000",
screenshot: "only-on-failure",
trace: "retain-on-failure",
video: "retain-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
...(process.env.CI
? [
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
]
: []),
],
webServer: {
command: "npm run dev",
port: 3000,
reuseExistingServer: !process.env.CI,
},
});Page Object Model
Rohe Selektoren, die über Testdateien verteilt sind, werden kaum noch wartbar. Page Objects kapseln die Seitenstruktur und stellen Aktionen als Methoden bereit.
// ❌ Selectors repeated across tests — one UI change breaks dozens of tests
// await page.click('[data-testid="add-to-cart"]');
// await page.fill('[data-testid="email-input"]', 'user@example.com');
// ✅ Page object encapsulates selectors and actions
class CheckoutPage {
constructor(private page: Page) {}
private get emailInput() {
return this.page.getByLabel("Email address");
}
private get cartItems() {
return this.page.getByTestId("cart-item");
}
private get placeOrderButton() {
return this.page.getByRole("button", { name: "Place order" });
}
private get orderConfirmation() {
return this.page.getByTestId("order-confirmation");
}
async fillEmail(email: string): Promise<void> {
await this.emailInput.fill(email);
}
async getCartItemCount(): Promise<number> {
return this.cartItems.count();
}
async placeOrder(): Promise<void> {
await this.placeOrderButton.click();
await this.orderConfirmation.waitFor({ state: "visible" });
}
async getOrderId(): Promise<string> {
const text = await this.orderConfirmation.textContent();
const match = text?.match(/Order #(\w+)/);
if (!match) throw new Error("Order ID not found in confirmation");
return match[1];
}
}Testisolierung mit Fixtures
Jeder Test sollte von einem sauberen Zustand aus starten. Playwrights Fixtures ermöglichen Setup und Teardown pro Test ohne globale Seiteneffekte.
import { test as base, expect } from "@playwright/test";
interface TestFixtures {
authenticatedPage: Page;
testUser: { email: string; password: string };
}
const test = base.extend<TestFixtures>({
testUser: async ({}, use) => {
// Create user via API before test
const email = `test-${Date.now()}@example.com`;
const password = "SecureTestPass123!";
const response = await fetch("http://localhost:3000/api/test/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!response.ok) throw new Error("Failed to create test user");
await use({ email, password });
// Cleanup after test
await fetch("http://localhost:3000/api/test/users", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
},
authenticatedPage: async ({ page, testUser }, use) => {
await page.goto("/login");
await page.getByLabel("Email").fill(testUser.email);
await page.getByLabel("Password").fill(testUser.password);
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL("/dashboard");
await use(page);
},
});
test("authenticated user can view dashboard", async ({
authenticatedPage,
}) => {
await expect(
authenticatedPage.getByRole("heading", { name: "Dashboard" })
).toBeVisible();
});Netzwerk-Intercepting
Mocke externe APIs, um Tests deterministisch zu machen. Playwrights Routen-Intercepting erledigt das ohne separaten Mock-Server.
test("displays payment error gracefully", async ({ page }) => {
// Intercept payment API to simulate failure
await page.route("**/api/payments", (route) => {
route.fulfill({
status: 422,
contentType: "application/json",
body: JSON.stringify({
error: "card_declined",
message: "Your card was declined",
}),
});
});
await page.goto("/checkout");
await page.getByLabel("Card number").fill("4242424242424242");
await page.getByRole("button", { name: "Pay" }).click();
await expect(
page.getByText("Your card was declined")
).toBeVisible();
// Verify the error state does not break the page
await expect(
page.getByRole("button", { name: "Pay" })
).toBeEnabled();
});
test("handles slow API responses", async ({ page }) => {
await page.route("**/api/products", async (route) => {
// Simulate 3-second delay
await new Promise((r) => setTimeout(r, 3000));
await route.continue();
});
await page.goto("/products");
// Loading state should appear
await expect(page.getByTestId("loading-skeleton")).toBeVisible();
// Products should eventually load
await expect(page.getByTestId("product-card").first()).toBeVisible({
timeout: 10_000,
});
});Visuelle Regressionstests
Erfasse unbeabsichtigte visuelle Änderungen, indem du Screenshots mit Baselines vergleichst.
test("product card renders correctly", async ({ page }) => {
await page.goto("/products");
const productCard = page.getByTestId("product-card").first();
await expect(productCard).toHaveScreenshot("product-card.png", {
maxDiffPixelRatio: 0.01,
});
});
test("responsive layout at mobile viewport", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/");
// Full page screenshot comparison
await expect(page).toHaveScreenshot("homepage-mobile.png", {
fullPage: true,
maxDiffPixelRatio: 0.02,
});
});Debugging fehlgeschlagener Tests
Playwrights Trace-Viewer zeigt jede Aktion, jeden Netzwerk-Request und jeden DOM-Snapshot eines fehlgeschlagenen Tests. Konfiguriere Traces so, dass sie bei Fehlschlag automatisch aufgezeichnet werden, und öffne sie dann lokal.
// View trace from a failed CI run
// npx playwright show-trace trace.zip
test("complex checkout flow", async ({ page, context }) => {
// Start tracing for this specific test
await context.tracing.start({
screenshots: true,
snapshots: true,
sources: true,
});
try {
await page.goto("/checkout");
// ... test steps
} finally {
await context.tracing.stop({
path: "checkout-trace.zip",
});
}
});Wichtige Erkenntnisse
End-to-End-Tests sollten kritische Nutzerpfade abdecken: Authentifizierung, Bezahlvorgang, Dateneingabe und Fehlerbehandlung. Nutze Page Objects, um Selektoränderungen von der Testlogik zu entkoppeln. Erzeuge pro Test einen frischen Zustand mit Fixtures – geteilter Zustand zwischen Tests ist die Hauptursache für Instabilität.
Intercepte Netzwerk-Requests, um Fehlerzustände, langsame Antworten und Edge Cases zu testen, die sich mit einem echten Backend nicht reproduzieren lassen. Setze visuelle Regressionstests für Komponenten ein, bei denen pixelgenaue Übereinstimmung zählt, aber halte die Toleranz vernünftig, um falsche Positive durch Schriftarten-Rendering auszuschließen. Aktiviere Traces und Screenshots bei Fehlschlag in CI: Ein fehlgeschlagener E2E-Test ohne Trace ist wie ein Produktionsvorfall ohne Logs zu debuggen.


