API testing with request contexts

Use the request fixture for direct API tests, and API calls for setup, so a UI test starts from a known server state instead of a hundred clicks.

The request fixture

import { test, expect } from "@playwright/test";

test("creates an order", async ({ request }) => {
  const created = await request.post("/api/orders", {
    data: { items: [{ sku: "A-1", quantity: 2 }], currency: "GBP" },
    headers: { Authorization: "Bearer " + process.env.API_TOKEN },
  });

  expect(created.status()).toBe(201);
  const order = await created.json();
  expect(order).toMatchObject({ currency: "GBP", status: "pending" });

  const fetched = await request.get("/api/orders/" + order.id);
  await expect(fetched).toBeOK();          // 2xx, with a useful message otherwise
  expect((await fetched.json()).id).toBe(order.id);
});
  • The request fixture is scoped to the test and inherits baseURL and extraHTTPHeaders from the configuration.
  • data serialises to JSON and sets the content type; form sends url-encoded; multipart sends files.
  • expect(response).toBeOK() is a retry-free status assertion with a readable failure message.
  • Cookies set by an API call are kept in the context, so a login endpoint can be used to authenticate subsequent requests.
// a separate context, with its own base URL and headers
const api = await request.newContext({
  baseURL: "https://api.staging.example.com",
  extraHTTPHeaders: { Accept: "application/json" },
  storageState: "playwright/.auth/api.json",
});

API setup for UI tests

import { test as base, expect } from "@playwright/test";

type Fixtures = { seededOrder: { id: string; reference: string } };

export const test = base.extend<Fixtures>({
  seededOrder: async ({ request }, use) => {
    const res = await request.post("/api/test/orders", {
      data: { sku: "A-1", quantity: 2, currency: "GBP" },
    });
    expect(res.ok()).toBeTruthy();
    const order = await res.json();

    await use(order);

    // teardown runs even when the test fails
    await request.delete("/api/test/orders/" + order.id);
  },
});

test("shows the seeded order", async ({ page, seededOrder }) => {
  await page.goto("/orders/" + seededOrder.id);
  await expect(page.getByText(seededOrder.reference)).toBeVisible();
});
Setup styleSpeedWhen it breaks
Create data through the UISlowestAny UI change
Create data through the APIFastAPI contract change
Seed the database directlyFastestSchema change, and it bypasses validation
Mock the API for the pageFastestWhen the real contract drifts
💡
Creating a test's starting state through the API keeps the test focused on the behaviour it is named after. A checkout test that begins with eleven UI clicks to build a cart is really a test of the cart page, and it will fail for reasons that have nothing to do with checkout.

Schema checks and contract drift

import { z } from "zod";

const Order = z.object({
  id: z.string(),
  currency: z.enum(["GBP", "USD"]),
  total: z.number().int(),
  status: z.enum(["pending", "paid", "cancelled"]),
  createdAt: z.string().datetime(),
});

test("order payload matches the agreed shape", async ({ request }) => {
  const res = await request.get("/api/orders/1042");
  const parsed = Order.safeParse(await res.json());
  expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
});
  1. Assert the fields the client depends on, not the whole payload - an exhaustive schema makes every additive change a failure.
  2. Check enums and required fields explicitly; those are what break consumers.
  3. Run a small contract suite against the deployed environment to detect drift between the test fixtures and reality.
  4. Keep the schema in one module shared by the fixtures and the tests, so a change is made once.
// a compact contract suite
for (const path of ["/api/orders/1042", "/api/users/me"]) {
  test("contract: " + path, async ({ request }) => {
    const res = await request.get(path);
    await expect(res).toBeOK();
    expect(res.headers()["content-type"]).toContain("application/json");
  });
}

FAQ

Do I still need a separate API test tool?
Not necessarily. The request context covers the common cases - status, headers, payload assertions, authentication - and keeping API and UI tests in one runner means one report, one configuration and shared fixtures. A dedicated tool becomes worthwhile for load testing or advanced contract workflows.
How do I authenticate API requests in tests?
Either send the header on each request, or log in once through the API and save the resulting state with storageState. The second option matches what the browser does and keeps credentials in one place.

Network interception and mocking Authentication and storage state

Last refreshed 2026-09-18.