API-Testing mit Playwright: eine lauffähige Suite und was 1.62 bis 1.64 gebracht haben

Teilen

Die meisten kennen Playwright als Werkzeug für Browsertests. Es bringt aber auch einen vollständigen HTTP-Client mit, das Fixture request, und damit kannst du API-Tests schreiben, ohne überhaupt einen Browser zu starten. Die Tests laufen im selben Runner, mit denselben Reports, Retries und parallelen Workern wie deine UI-Tests.

In diesem Beitrag bauen wir damit eine kleine API-Testsuite gegen unsere kostenlose Test-API. Jedes Beispiel lief mit Playwright 1.64.0, erschienen am 7. Oktober 2026, und alle 12 Tests bestehen. Der letzte Teil zeigt, was die letzten drei Versionen fürs API-Testing gebracht haben: typisierte Antworten, Locks für Tests, die Daten teilen, Cookies im Request-Kontext, Antwortzeiten und das Abbrechen eines Requests.

Einrichtung

Du brauchst Node.js 20 oder neuer. API-Tests brauchen keine Browser, also entfällt der Schritt npx playwright install:

Terminal
$ mkdir playwright-api && cd playwright-api
$ npm init -y
$ npm install --save-dev @playwright/test@1.64.0

Die Konfiguration setzt eine Basis-URL, damit Tests Pfade wie /api/Products nutzen können, und einen Standard-Header Accept für jeden Request:

playwright.config.ts
import { defineConfig } from "@playwright/test";

// API tests only: no browsers are launched, so `npx playwright install` isn't needed.
export default defineConfig({
  testDir: "./tests",
  reporter: "list",
  retries: 1,
  // Playwright 1.62: run retries at the end, one at a time, instead of straight away in parallel.
  retryStrategy: "isolated",
  use: {
    // Point the tests at another server with BASE_URL, e.g. a copy of the API on your machine.
    baseURL: process.env.BASE_URL ?? "https://api.testingapis.com",
    extraHTTPHeaders: { Accept: "application/json" },
  },
});

Mit BASE_URL richtest du dieselben Tests auf einen anderen Server, etwa eine lokale Kopie der API oder eine Staging-Umgebung, ohne sie zu ändern.

Dein erster API-Test

Fordere in einem Test das Fixture request an, und du bekommst einen APIRequestContext mit get, post, put, patch, delete und fetch. Jede Methode gibt eine APIResponse mit Status, Headern und Body zurück. Diese Datei prüft einen Listen-Endpunkt, einen Fehler-Body, einen Validierungsfehler und eine Handvoll Statuscodes:

tests/read.spec.ts
import { expect, test } from "@playwright/test";
import type { ApiError, Product } from "./types";

test("lists products filtered by category, with paging headers", async ({ request }) => {
  const response = await request.get<Product[]>("/api/Products", {
    params: { category: "electronics", limit: 2 },
  });

  expect(response.status()).toBe(200);
  expect(response.headers()["content-type"]).toContain("application/json");
  expect(Number(response.headers()["x-total-count"])).toBeGreaterThan(2);

  const products = await response.json();
  expect(products).toHaveLength(2);
  for (const product of products) {
    expect(product.category).toBe("electronics");
    expect(typeof product.price).toBe("number");
  }
});

test("returns 404 with an error body for a product that doesn't exist", async ({ request }) => {
  const response = await request.get<ApiError>("/api/Products/99999");

  expect(response.status()).toBe(404);
  expect(await response.json()).toEqual({
    status: 404,
    error: "Not Found",
    message: "Product with ID 99999 does not exist.",
  });
});

test("rejects a product without a title", async ({ request }) => {
  const response = await request.post<ApiError>("/api/Products", { data: { price: 5 } });

  expect(response.status()).toBe(400);
  expect((await response.json()).message).toBe("Missing required field: title");
});

for (const code of [400, 401, 429, 500, 503]) {
  test(`handles a ${code} response`, async ({ request }) => {
    const response = await request.get(`/api/status/${code}`);

    expect(response.status()).toBe(code);
    expect(response.ok()).toBe(false);
  });
}
  • params baut den Query-String, du musst ?category=electronics&limit=2 also nicht von Hand kodieren.
  • response.headers() liefert Header-Namen in Kleinbuchstaben. Unsere API gibt die Zahl der passenden Produkte in X-Total-Count zurück, also prüft der Test neben dem Body auch die Paginierung.
  • data bei einem post wird als JSON gesendet, Content-Type: application/json wird für dich gesetzt.
  • Die for-Schleife erzeugt einen Test pro Statuscode. /api/status/{code} gibt jeden Status zurück, den du anforderst, praktisch, um zu prüfen, wie dein eigener Client-Code auf ein 429 oder 503 reagiert.

Ein 404 oder 500 lässt request.get keine Ausnahme werfen. Du bekommst die Antwort zurück und prüfst sie, genau das willst du, wenn der Fehler das ist, was du testest. Soll ein Fehlerstatus den Test einfach scheitern lassen, gib failOnStatusCode mit:

typescript
// Throw on any 4xx or 5xx instead of returning the response.
const response = await request.get("/api/Products/1", { failOnStatusCode: true });

Antworten typisieren

Seit Playwright 1.63 nehmen die Request-Methoden ein Typargument, und response.json() gibt diesen Typ zurück. Genau das hat request.get<Product[]> oben getan. Die Typen stehen in einer Datei:

tests/types.ts
// The shapes the tests read. Only the fields they check are listed.

export interface Product {
  id: number;
  title: string;
  price: number;
  category: string;
  inStock: boolean;
}

/** What POST and PUT on /api/Products return. */
export interface ProductEnvelope {
  message: string;
  data: Product;
}

/** The API's error body, for 4xx and 5xx responses. */
export interface ApiError {
  status: number;
  error: string;
  message: string;
}

Das ist ein Hinweis an TypeScript, keine Prüfung. Schickt die API einen String, wo du eine Zahl deklariert hast, kompiliert und läuft der Test trotzdem. Behalte die Assertions für die Werte, auf die es ankommt, so wie die Tests oben mit typeof product.price. Um ganze Antwort-Bodies gegen ein Schema zu prüfen, sieh dir unsere Lektion zur Antwortvalidierung (auf Englisch) an.

Ein Test für Anlegen, Lesen, Ändern und Löschen, der aufräumt

Produkte auf unserer API sind geteilt: Ein Produkt, das du anlegst, sehen alle, bis es gelöscht ist. Tests gegen eine geteilte Umgebung müssen hinter sich aufräumen, auch wenn sie mittendrin scheitern:

tests/crud.spec.ts
import { expect, test } from "@playwright/test";
import type { Product, ProductEnvelope } from "./types";

// Products are shared by everyone who uses the API. Playwright 1.63's lock keeps any other test that writes
// products from running at the same time as this file, in any worker.
test.describe.configure({ lock: "products" });

test("creates, reads, updates and deletes a product", async ({ request }) => {
  const created = await request.post<ProductEnvelope>("/api/Products", {
    data: { title: "Playwright test product", price: 12.5, category: "electronics" },
  });
  expect(created.status()).toBe(201);
  const { data: product } = await created.json();

  try {
    await test.step("read it back", async () => {
      const read = await request.get<Product>(`/api/Products/${product.id}`);
      expect(read.status()).toBe(200);
      expect(await read.json()).toMatchObject({ id: product.id, title: "Playwright test product", price: 12.5 });
    });

    await test.step("change the price", async () => {
      const updated = await request.put<ProductEnvelope>(`/api/Products/${product.id}`, {
        data: { title: "Playwright test product", price: 9.99, category: "electronics" },
      });
      expect(updated.status()).toBe(200);
      expect((await updated.json()).data.price).toBe(9.99);
    });
  } finally {
    // Clean up even when a step fails, so a broken run doesn't leave products behind.
    const deleted = await request.delete(`/api/Products/${product.id}`);
    expect(deleted.status()).toBe(200);
  }

  const gone = await request.get(`/api/Products/${product.id}`);
  expect(gone.status()).toBe(404);
});
  • Der finally-Block löscht das Produkt auch dann, wenn Lesen oder Ändern scheitert, ein kaputter Lauf hinterlässt also keine Testdaten.
  • test.stepbenennt jede Phase im Report und im Trace Viewer, ein Fehler sagt also "change the price", statt auf Zeile 23 zu zeigen.
  • Der letzte Request prüft, ob das Produkt wirklich weg ist. Ein Delete, das 200 zurückgibt, den Datensatz aber stehen lässt, ist ein echter Bug, und nur ein anschließendes Lesen findet ihn.
  • test.describe.configure({ lock: "products" }) ist neu in 1.63. Tests mit demselben Lock-Namen laufen nie gleichzeitig, über Dateien und Worker hinweg. Gib jedem Test, der Produkte schreibt, denselben Lock, und sie kommen sich bei den Daten nicht in die Quere, während der Rest der Suite weiter parallel läuft.

Unsere Lektion zum CRUD-Testing erklärt, welche Prüfungen jeder dieser Schritte braucht, und warum doppeltes Löschen ebenfalls ein nützlicher Test ist.

Was 1.62 bis 1.64 für API-Tests gebracht haben

VersionNeuWofür
1.64request.addCookies() / cookies() / clearCookies()Cookies in einem Request-Kontext setzen oder löschen, so wie in einem Browser-Kontext
1.64--shuffle [seed]Tests in zufälliger Reihenfolge ausführen, um Tests zu finden, die nur nach einem anderen bestehen
1.63request.get<T>()Typisiertes response.json(), oben gezeigt
1.63lockVerhindern, dass Tests, die dieselben Daten ändern, gleichzeitig laufen
1.62apiResponse.timing()DNS-, Verbindungs-, Request- und Antwortzeiten eines Aufrufs
1.62signalEinen Request mit einem AbortSignal abbrechen
1.62retryStrategy: "isolated"Retries am Ende ausführen, einen nach dem anderen

Diese Datei testet die Neuerungen auf Request-Ebene. Unser Endpunkt /api/echo gibt den empfangenen Request zurück, so siehst du genau, was Playwright gesendet hat:

tests/new-in-1-62-to-1-64.spec.ts
import { expect, test } from "@playwright/test";

// /api/echo returns what it received, which makes it easy to see what the request context sent.
interface Echo {
  method: string;
  cookies: Record<string, string>;
}

test("1.64: cookies added to the request context are sent with every request", async ({ request, baseURL }) => {
  await request.addCookies([{ name: "session-id", value: "42", url: baseURL! }]);

  const withCookie = await request.get<Echo>("/api/echo");
  expect((await withCookie.json()).cookies).toEqual({ "session-id": "42" });

  await request.clearCookies({ name: "session-id" });
  const withoutCookie = await request.get<Echo>("/api/echo");
  expect((await withoutCookie.json()).cookies).toEqual({});
});

test("1.62: apiResponse.timing() shows where the time went", async ({ request }) => {
  const response = await request.get("/api/health");
  const timing = response.timing();

  // Each value is milliseconds after startTime, or -1 when it doesn't apply (no DNS lookup on a reused
  // connection, for example).
  expect(timing.startTime).toBeGreaterThan(0);
  expect(timing.responseEnd).toBeGreaterThanOrEqual(timing.responseStart);
  expect(timing.responseEnd).toBeLessThan(5_000);
});

test("1.62: an AbortSignal cancels a request that takes too long", async ({ request }) => {
  // ?delay= makes the API wait before answering. The signal gives up after 500 ms.
  const pending = request.get("/api/echo", { params: { delay: 3000 }, signal: AbortSignal.timeout(500) });

  await expect(pending).rejects.toThrow();
});
  • Cookies. Ein Request-Kontext hat schon immer Cookies behalten, die Antworten setzen, und konnte mit einigen aus storageState starten, aber direkt hinzufügen oder entfernen konntest du keines. Jetzt geht das, mitten im Test: Lösche zum Beispiel das Session-Cookie und prüfe, ob der nächste Request abgelehnt wird.
  • Zeiten. timing() liefert dieselben Felder wie die Resource Timing API des Browsers, in Millisekunden nach startTime, mit -1 für Phasen, die nicht stattfanden. Eine wiederverwendete Verbindung macht keinen DNS-Lookup, prüfe also responseEnd, statt alle Felder zu addieren. Das Limit von 5 Sekunden im Test ist absichtlich großzügig: Eine knappe Zeit-Assertion gegen einen Server im Internet macht einen Test wackelig.
  • Abbrechen. Die Release Notes zu 1.62 beschreiben signal für Actions und Assertions. Es funktioniert auch bei den Request-Methoden, das haben wir in den Typdefinitionen von 1.64 und im Test oben geprüft: ?delay=3000 lässt unsere API drei Sekunden warten, und AbortSignal.timeout(500) gibt nach einer halben Sekunde auf. Die eigene Option timeout des Requests erledigt Ähnliches; ein Signal ist nützlich, wenn etwas anderes entscheidet, wann Schluss ist, etwa ein Test, der früher endet.

Die Konfiguration oben nutzt bereits retryStrategy: "isolated". Standardmäßig wird ein fehlgeschlagener Test wiederholt, sobald ein Worker frei ist, während der Rest der Suite noch läuft. Isolierte Retries warten bis zum Ende und laufen einer nach dem anderen. Scheitert ein Test im parallelen Lauf, besteht aber im isolierten Retry, steht ihm wahrscheinlich ein anderer Test im Weg, etwa zwei Tests, die denselben Datensatz ändern, und ein lock ist die Lösung. --shuffle findet das umgekehrte Problem: Lass die Suite ab und zu in zufälliger Reihenfolge laufen, und jeder Test, der nur nach einem anderen besteht, fällt auf.

Terminal
$ npx playwright test --shuffle

Die Suite ausführen

Terminal
$ npx playwright test
Ausgabe (Playwright 1.64.0, Node 24, 14. Oktober 2026)
Running 12 tests using 3 workers

  ✓   1 tests\read.spec.ts:4:5 › lists products filtered by category, with paging headers (285ms)
  ✓   2 tests\new-in-1-62-to-1-64.spec.ts:9:5 › 1.64: cookies added to the request context are sent with every request (263ms)
  ✓   3 tests\crud.spec.ts:8:5 › creates, reads, updates and deletes a product (334ms)
  ✓   4 tests\read.spec.ts:21:5 › returns 404 with an error body for a product that doesn't exist (90ms)
  ✓   5 tests\new-in-1-62-to-1-64.spec.ts:20:5 › 1.62: apiResponse.timing() shows where the time went (104ms)
  ✓   6 tests\read.spec.ts:32:5 › rejects a product without a title (118ms)
  ✓   8 tests\read.spec.ts:40:7 › handles a 400 response (109ms)
  ✓   9 tests\read.spec.ts:40:7 › handles a 401 response (107ms)
  ✓  10 tests\read.spec.ts:40:7 › handles a 429 response (120ms)
  ✓   7 tests\new-in-1-62-to-1-64.spec.ts:31:5 › 1.62: an AbortSignal cancels a request that takes too long (512ms)
  ✓  11 tests\read.spec.ts:40:7 › handles a 500 response (107ms)
  ✓  12 tests\read.spec.ts:40:7 › handles a 503 response (97ms)

  12 passed (2.1s)

Die ganze Suite braucht etwa zwei Sekunden. Lade sie als playwright-api-examples.zip herunter, führe npm install und npx playwright test aus, und sie läuft gegen dieselbe API. Jeder Endpunkt, den sie nutzt, ist kostenlos, ohne Konto oder Schlüssel.

API-Aufrufe in UI-Tests

Derselbe Client steht in Browsertests als page.request zur Verfügung. Er teilt Cookies mit der Seite, du kannst also Testdaten anlegen oder dich über die API anmelden und das Ergebnis dann in der UI prüfen, viel schneller, als für jeden Test Formulare durchzuklicken:

typescript
test("shows a product created through the API", async ({ page }) => {
  // page.request shares cookies with the page, so a session set up here carries over to the browser.
  const created = await page.request.post("https://api.testingapis.com/api/Products", {
    data: { title: "Seeded", price: 1 },
  });
  // ...then open the page that lists products and check it appears.
});

Die Endpunkte, die diese Tests aufrufen, sind in unserer Doku zur Produkt-API und der Doku zur Statuscode-API beschrieben, mit einem Send-Button, um jeden Request auszuprobieren, bevor du einen Test dafür schreibst.