03 · API Testing with Request Context¶
Not every check needs a browser. Playwright ships a standalone HTTP client —
APIRequestContext — that shares its cookie jar with a BrowserContext,
which makes it uniquely useful for seeding data, asserting on backend
responses directly, and mixing API calls into UI tests without spinning up
requests or httpx as a separate dependency.
A pure API test, no browser at all¶
import pytest
from playwright.sync_api import APIRequestContext, sync_playwright
@pytest.fixture(scope="session")
def api_context(playwright):
context = playwright.request.new_context(
base_url="https://api.example.com",
extra_http_headers={"Authorization": "Bearer test-token"},
)
yield context
context.dispose()
def test_get_user(api_context: APIRequestContext):
response = api_context.get("/users/42")
assert response.ok
assert response.status == 200
body = response.json()
assert body["id"] == 42
assert body["email"].endswith("@example.com")
# playwright.request.new_context() creates an APIRequestContext
# independent of any browser — no Chromium/Firefox process is
# launched, so these tests run much faster than UI tests and
# are a good fit for backend contract checks
POST, PUT, DELETE with JSON bodies¶
def test_create_and_delete_todo(api_context):
create = api_context.post("/todos", data={"title": "Buy milk", "done": False})
assert create.status == 201
todo_id = create.json()["id"]
update = api_context.put(f"/todos/{todo_id}", data={"done": True})
assert update.ok
assert update.json()["done"] is True
delete = api_context.delete(f"/todos/{todo_id}")
assert delete.status == 204
confirm = api_context.get(f"/todos/{todo_id}")
assert confirm.status == 404
# `data=` is JSON-encoded automatically when it's a dict; use
# `data=raw_bytes` or `multipart=` for other body types. The
# request/response cycle here never touches a browser, so this
# is a fast, reliable way to verify backend behavior in isolation
# from any frontend rendering bugs
Sharing auth between API calls and a real browser context¶
Because APIRequestContext and BrowserContext can share cookies, you can
log in via the API and hand the resulting session to a real page — skipping
a UI login entirely, even faster than the storage-state approach from the
previous module:
def test_dashboard_after_api_login(playwright, browser):
api_context = playwright.request.new_context(base_url="https://example.com")
login = api_context.post("/api/login", data={"email": "qa@example.com", "password": "secret"})
assert login.ok
storage_state = api_context.storage_state()
browser_context = browser.new_context(storage_state=storage_state)
page = browser_context.new_page()
page.goto("/dashboard")
expect(page.get_by_text("Welcome")).to_be_visible()
browser_context.close()
api_context.dispose()
# api_context.storage_state() returns the same cookie/origin
# shape a BrowserContext produces, so it can be fed directly
# into new_context(storage_state=...) — logging in over HTTP
# is typically far faster than filling and submitting a form
Mixing API setup into a UI test¶
The most common real-world pattern: use the API to get the app into a specific state instantly, then use the browser only to test the UI behavior that actually needs rendering.
def test_edit_existing_order(page, api_context):
order = api_context.post("/orders", data={"item": "Widget", "qty": 3}).json()
page.goto(f"/orders/{order['id']}/edit")
page.get_by_label("Quantity").fill("5")
page.get_by_role("button", name="Save").click()
expect(page.get_by_text("Order updated")).to_be_visible()
confirm = api_context.get(f"/orders/{order['id']}")
assert confirm.json()["qty"] == 5
# the order is created via a single fast API call instead of
# clicking through a multi-step "create order" UI flow just to
# get to the screen under test, and the final assertion
# double-checks the backend directly rather than trusting the
# UI's own success message alone
Asserting on response headers and timing¶
def test_api_response_has_cache_headers(api_context):
response = api_context.get("/products/1")
assert response.headers.get("cache-control") == "max-age=300"
assert "etag" in response.headers
Full worked example: contract test for a paginated endpoint¶
# tests/test_api_pagination.py
import pytest
def test_products_pagination_contract(api_context):
page1 = api_context.get("/products?page=1&limit=10").json()
assert len(page1["items"]) == 10
assert page1["page"] == 1
assert page1["has_next"] is True
page2 = api_context.get("/products?page=2&limit=10").json()
ids_page1 = {item["id"] for item in page1["items"]}
ids_page2 = {item["id"] for item in page2["items"]}
assert ids_page1.isdisjoint(ids_page2) # no duplicate items across pages
How It Actually Works¶
playwright.request.new_context() does not launch a browser process at
all — it asks the Node driver to open a plain HTTP client directly, using
Node's own networking stack rather than routing traffic through a browser's
CDP Fetch/Network domains. This is the actual source of the speed
difference the module calls out: a UI test's every request goes
browser-process → renderer → network stack → back through CDP events to the
driver, while an APIRequestContext call is just driver-process → network
stack → response, with no browser in the loop whatsoever.
The cookie-sharing between APIRequestContext and BrowserContext is real
shared state, not a translation layer: when you create an
APIRequestContext from a BrowserContext (or hand its storage_state()
to browser.new_context(storage_state=...)), you're working with the exact
same cookie-jar representation described in Module 2 — a plain list of
cookie dicts with domain/path/expiry — so a Set-Cookie header returned by
api_context.post("/api/login", ...) becomes, after storage_state() and
new_context(storage_state=...), a Network.setCookie call on the browser
context precisely as if a real login form's response had set it. There's no
special-casing for "API-originated" cookies; the format is identical
because both paths ultimately produce the same storage-state JSON shape
covered in Module 2.
Exercise¶
- Write a fixture that creates an
APIRequestContextpointed at a real or mock API and use it toGETa resource, asserting on bothstatusand the parsed JSON body. - Write a test that
POSTs a resource, thenPUTs an update, thenDELETEs it, asserting the expected status code at each step. - Log in via an API call, extract
storage_state(), and use it to open an authenticated page in a real browser context — confirm no login form was ever rendered. - Rewrite one existing UI test so it uses the API to set up preconditions (e.g. creating a record) instead of clicking through the UI to create it, and note in a comment how much of the test's runtime that setup used to take.