So far every test in this course has driven a browser. But many of the most valuable QA assertions don't need a UI at all: "POST /api/users with a valid body returns 201 and a user object," "GET /api/orders for an unauthenticated user returns 401," "DELETE /api/products/:id removes the row from the database." For those, launching a browser is wasted seconds and synthetic complexity. Playwright ships a built-in HTTP client — the request fixture — that lets you write pure API tests in the same file, the same suite, the same syntax as your UI tests. This lesson is what request does, when to reach for it, and how to combine it with page to build the test patterns that catch the most bugs.
Meet the request fixture
request is destructured out of the test fixtures, just like page:
import { test, expect } from "@playwright/test";
test("creates a user via API", async ({ request }) => {
const response = await request.post("/api/users", {
data: {
name: "Alice",
email: "alice@test.com",
role: "admin"
}
});
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(201);
const user = await response.json();
expect(user.name).toBe("Alice");
expect(user.id).toBeDefined();
});request is an APIRequestContext — a fully-featured HTTP client. No browser launches, no Page renders. The test takes milliseconds because nothing has to load.
Three things that come for free:
- The same baseURL.
request.get('/api/users')resolves against thebaseURLset inplaywright.config.ts, exactly likepage.goto. - Shared cookies and storage. The
requestandpagefixtures share aBrowserContextby default — auth cookies set by one are visible to the other. (Useful for "log in once via API, then drive the UI as the logged-in user.") - TypeScript-native. No third-party HTTP libraries. The
ResponseandRequesttypes are typed, thedataandjsonpayloads are typed.
The HTTP verb methods
const get = await request.get("/api/users");
const post = await request.post("/api/users", { data: { name: "Alice" } });
const put = await request.put("/api/users/1", { data: { name: "Alice Updated" } });
const patch = await request.patch("/api/users/1", { data: { role: "admin" } });
const del = await request.delete("/api/users/1");
const head = await request.head("/api/users/1");Each one returns an APIResponse. The full options bag:
await request.post("/api/users", {
data: { name: "Alice" }, // request body (auto-JSON-stringified)
headers: { Authorization: "Bearer abc123" }, // request headers
params: { include: "profile,settings" }, // query string params
timeout: 10_000, // override default timeout
multipart: { file: { name: "avatar.jpg", buffer, contentType: "image/jpeg" } }, // file upload
failOnStatusCode: true, // throw on 4xx/5xx (default false)
ignoreHTTPSErrors: true // for self-signed certs
});data is the most-used option. Pass any JSON-serialisable value; Playwright handles the headers and stringification.
Inspecting the response
Every response has methods for status, headers, and body parsing:
const response = await request.get("/api/orders");
response.ok(); // true for 2xx
response.status(); // 200, 404, etc.
response.statusText(); // "OK", "Not Found"
response.url(); // final URL after redirects
response.headers(); // { 'content-type': 'application/json', ... }
response.headersArray(); // [{ name, value }, ...] preserving duplicates
await response.json(); // parsed JSON
await response.text(); // raw body
await response.body(); // BufferThe body methods return promises — await them. JSON is the common case; text() is useful for HTML responses or when you want to assert on the raw bytes.
A typical API-only spec
Every API a real app exposes can be tested in this shape — no browser, fast, deterministic:
import { test, expect } from "@playwright/test";
test.use({ baseURL: "https://jsonplaceholder.typicode.com" });
test.describe("Posts API", () => {
test("GET /posts returns a list", async ({ request }) => {
const response = await request.get("/posts");
expect(response.ok()).toBeTruthy();
expect(response.headers()["content-type"]).toContain("application/json");
const posts = await response.json();
expect(Array.isArray(posts)).toBe(true);
expect(posts.length).toBeGreaterThan(0);
expect(posts[0]).toHaveProperty("id");
expect(posts[0]).toHaveProperty("title");
});
test("GET /posts/1 returns a single post", async ({ request }) => {
const response = await request.get("/posts/1");
const post = await response.json();
expect(response.status()).toBe(200);
expect(post.id).toBe(1);
expect(typeof post.title).toBe("string");
});
test("POST /posts creates a new post", async ({ request }) => {
const response = await request.post("/posts", {
data: { title: "Playwright API test", body: "Hello", userId: 1 }
});
expect(response.status()).toBe(201);
const created = await response.json();
expect(created.title).toBe("Playwright API test");
expect(created.id).toBeDefined();
});
test("DELETE /posts/1 returns 200", async ({ request }) => {
const response = await request.delete("/posts/1");
expect(response.ok()).toBeTruthy();
});
});Read this for the shape of API testing in Playwright: every test is a small request-response-assert. No browser, no waits, no actionability checks. The test runs in milliseconds.
API for setup, UI for the test
This is where the request fixture really earns its keep — using it for setup, then driving the UI for the actual assertion:
test("logs in as a freshly created user", async ({ page, request }) => {
// 1. SETUP — create a user via API (fast)
const createRes = await request.post("/api/users", {
data: { email: `test-${Date.now()}@test.com`, password: "pw123" }
});
const user = await createRes.json();
// 2. UI TEST — drive the login flow (the thing under test)
await page.goto("/login");
await page.getByLabel("Email").fill(user.email);
await page.getByLabel("Password").fill("pw123");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL(/dashboard/);
await expect(page.getByRole("heading")).toContainText("Welcome");
});The user creation step takes 50 milliseconds; the equivalent UI sign-up flow would take 5 seconds and test code paths that aren't the focus of this test. By isolating the UI under test from the setup it depends on, every test runs faster and the assertion is sharper.
API for verification — close the loop
The same trick works in reverse. A UI action mutates state; an API call confirms the mutation actually persisted:
test("creates an order via UI and verifies via API", async ({ page, request }) => {
// UI: complete a checkout
await page.goto("/checkout");
await page.getByRole("button", { name: "Place order" }).click();
await expect(page.getByText("Order placed")).toBeVisible();
// API: verify the order made it to the backend
const ordersRes = await request.get("/api/orders/latest");
const order = await ordersRes.json();
expect(order.status).toBe("pending");
expect(order.total).toBeGreaterThan(0);
expect(order.items.length).toBeGreaterThan(0);
});Without the API check, you're trusting the UI's "Order placed" toast — which could lie if the backend silently failed. With it, the test asserts the actual end state.
Pure API specs — no browser anywhere
Sometimes an API contract is the entire test surface — no UI involved. Use a separate file for these:
import { test, expect } from "@playwright/test";
test.use({
baseURL: "https://api.shop.example.com",
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
"X-API-Version": "2024-01"
}
});
test.describe("Orders API contract", () => {
test("POST /orders requires authentication", async ({ request }) => {
const res = await request.post("/orders", {
headers: { Authorization: "" }, // override auth
data: { items: [] }
});
expect(res.status()).toBe(401);
});
test("POST /orders with valid body returns 201 and an order", async ({ request }) => {
const res = await request.post("/orders", {
data: { items: [{ productId: "p1", quantity: 1 }] }
});
expect(res.status()).toBe(201);
const order = await res.json();
expect(order).toMatchObject({
status: "pending",
total: expect.any(Number),
created_at: expect.any(String)
});
});
});test.use at the top of a describe sets fixture defaults for that file or block — every test inherits the baseURL and auth headers. Pure API specs run in tens of milliseconds each, so a contract suite of 200 tests fits inside a single CI minute.
UI test vs API test vs combined
Three shapes of test, three jobs
UI test
Fixtures: page
Drives a real browser
Tests rendering, layout, user interaction
Slowest — but the only way to test the UI
API test
Fixtures: request
No browser launched at all
Tests contract, status codes, JSON shape
Fastest — runs in tens of milliseconds
UI + API combined
Fixtures: page AND request
API for setup; UI for the feature; API for verification
Catches bugs UI tests miss (silent backend failures)
The pattern most senior QA engineers reach for
Coming from Cypress?
The mappings:
cy.request('GET', '/api/users')→await request.get('/api/users')cy.request({ method: 'POST', url: '/api/x', body: {...}, headers: {...} })→await request.post('/api/x', { data: {...}, headers: {...} })cy.request(...).then(res => expect(res.status).to.eq(200))→const res = await request.get(...); expect(res.status()).toBe(200)
The conceptual difference: Cypress's cy.request is one of many commands you chain off cy. Playwright's request is a fully separate fixture you destructure — and crucially, you can have a test that uses only request with no browser at all. Cypress's cy.request is always part of a Cypress test that already has a browser session.
The other practical win: request is fully async (await request.get(...)), so you can compose multiple requests, conditional logic, and shared helpers far more naturally than Cypress's chained-callback style.
⚠️ Common mistakes
- Treating
requestas a slower replacement forpage. It's not slower — it's faster (no browser launch). It's also not a replacement; it has a different scope. Userequestfor API-shape assertions and setup/teardown; usepagewhen the test needs to render or interact with the UI. - Forgetting
failOnStatusCode: truewhen you want errors to throw. By default,request.get('/missing')returns a 404 response object instead of throwing. If your test logic assumes "the GET worked" and proceeds without checking, downstreamawait response.json()blows up confusingly. Either assertexpect(response.ok()).toBeTruthy()after every request, or passfailOnStatusCode: trueso 4xx/5xx throw. - Using
requestwhere apage.routemock would do. If the goal is "test how the UI behaves when the API returns 500," usepage.routeto mock the response — don't userequestto actually call the real API.requestis for testing APIs (or setting up state);page.routeis for controlling what the UI under test sees.
🎯 Practice task
Build a mixed UI + API spec against JSONPlaceholder and Sauce Demo. 25-30 minutes.
-
Create
tests/api-only.spec.tswith three pure-API tests against JSONPlaceholder:import { test, expect } from "@playwright/test"; test.use({ baseURL: "https://jsonplaceholder.typicode.com" }); test.describe("Posts API contract", () => { test("GET /posts returns 100 posts", async ({ request }) => { const res = await request.get("/posts"); expect(res.ok()).toBeTruthy(); const posts = await res.json(); expect(posts).toHaveLength(100); }); test("POST /posts returns a created post", async ({ request }) => { const res = await request.post("/posts", { data: { title: "Hello", body: "World", userId: 1 } }); expect(res.status()).toBe(201); const post = await res.json(); expect(post.title).toBe("Hello"); expect(post.id).toBeDefined(); }); test("GET /posts/9999 returns 404 (graceful)", async ({ request }) => { const res = await request.get("/posts/9999"); expect(res.status()).toBe(404); }); }); -
Run them. Each should finish in under 200ms — that's the speed advantage of pure API tests.
-
Now create
tests/ui-plus-api.spec.tsfor a combined test against Sauce Demo (baseURL: "https://www.saucedemo.com"):import { test, expect } from "@playwright/test"; test.describe("UI + API setup", () => { test("logs in via UI, then asserts inventory shape via captured response", async ({ page }) => { // Capture the inventory page response const inventoryResponse = page.waitForResponse(/inventory\.html$/); await page.goto("/"); await page.getByPlaceholder("Username").fill("standard_user"); await page.getByPlaceholder("Password").fill("secret_sauce"); await page.getByRole("button", { name: "Login" }).click(); const response = await inventoryResponse; expect(response.status()).toBe(200); // UI assertion await expect(page).toHaveURL(/inventory/); await expect(page.locator(".inventory_item")).toHaveCount(6); }); }); -
Run. The test does the UI flow but also captures the response for an HTTP-level assertion — the simplest form of UI + API combined.
-
Stretch: if you have access to a real backend (your own dev environment, or a sandbox like
https://reqres.in), write a real combined test:- API: create a user via POST.
- UI: log in as that user.
- UI: assert their profile name renders.
- API: verify the session cookie exists.
- API: clean up — delete the user. This is the canonical "API setup + UI test + API verify + API teardown" pattern that runs at every senior QA team. The framework gives you all four primitives in one fixture.
You now have three distinct testing surfaces — UI, API, and the combined pattern that uses both. The next lesson zooms in on the combined pattern itself: the canonical "API for setup, UI for the test, API for verification" workflow that catches bugs UI-only tests don't.