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
requestfixture is scoped to the test and inheritsbaseURLandextraHTTPHeadersfrom the configuration. dataserialises to JSON and sets the content type;formsends url-encoded;multipartsends 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 style | Speed | When it breaks |
|---|---|---|
| Create data through the UI | Slowest | Any UI change |
| Create data through the API | Fast | API contract change |
| Seed the database directly | Fastest | Schema change, and it bypasses validation |
| Mock the API for the page | Fastest | When 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);
});- Assert the fields the client depends on, not the whole payload - an exhaustive schema makes every additive change a failure.
- Check enums and required fields explicitly; those are what break consumers.
- Run a small contract suite against the deployed environment to detect drift between the test fixtures and reality.
- 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.Related
Network interception and mocking Authentication and storage state
Last refreshed 2026-09-18.