Zum Inhalt springen

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.

4 Min. Lesezeit
Ein Playwright-Test-Runner, der grüne Häkchen für mehrere parallele Browserszenarien anzeigt

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.

tstypescript
// 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.

tstypescript
// ❌ 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.

tstypescript
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.

tstypescript
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.

tstypescript
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.

tstypescript
// 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.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX