Playwright isn't just a browser-automation library — it ships a first-class HTTP client called APIRequestContext. From inside a pytest-playwright test you can call page.request.post(...) to hit any API directly, no browser launched, no DOM rendering. This lets you write pure API tests in the same suite as your UI tests, share authentication between them, and use API calls for fast test data setup before driving the UI. If you've worked through the API Testing Masterclass course, this is how you do the same workflows from inside Playwright Python.
The smallest API test
Every Page has a .request property — that's the APIRequestContext. It speaks HTTP directly:
def test_create_user(page: Page):
response = page.request.post("/api/users", data={
"name": "Alice",
"email": "alice@test.com",
"role": "admin"
})
assert response.ok
assert response.status == 201
user = response.json()
assert user["name"] == "Alice"
assert "id" in userThe pattern: call an HTTP method, get an APIResponse, assert on its status and body. No browser windows opened, no DOM queries — pure HTTP.
response.ok is true for 2xx; response.status is the integer code; response.json() parses the body. You can also call .text(), .body() (raw bytes), and .headers (dict).
The five HTTP verbs
page.request.get("/api/users")
page.request.post("/api/users", data={"name": "Alice"})
page.request.put("/api/users/42", data={"name": "Alice Smith"})
page.request.patch("/api/users/42", data={"name": "Alice S."})
page.request.delete("/api/users/42")Plus head and options for the corner cases. Each takes the same kwargs: data (form-encoded), json (JSON-encoded), headers, params (query string), multipart (file uploads), timeout.
For JSON specifically:
page.request.post("/api/users", json={"name": "Alice"})json=... sets Content-Type: application/json automatically and serialises the value. Use it instead of data=json.dumps(...).
Headers — the auth pattern
The default headers carry whatever the page sets (cookies, etc.). Override or extend them per request:
response = page.request.get("/api/admin/users", headers={
"Authorization": f"Bearer {token}",
"X-Tenant-Id": "acme",
})For an authenticated suite, set the auth header once in a fixture and reuse it:
@pytest.fixture(scope="session")
def auth_headers():
response = httpx.post("/api/auth/login", json={"email": "...", "password": "..."})
token = response.json()["token"]
return {"Authorization": f"Bearer {token}"}
def test_list_admins(page: Page, auth_headers):
response = page.request.get("/api/admin/users", headers=auth_headers)
assert response.okStandalone API tests — no page fixture needed
If you genuinely don't need a browser at all, build a session-scoped APIRequestContext directly:
import pytest
from playwright.sync_api import APIRequestContext, Playwright
@pytest.fixture(scope="session")
def api_context(playwright: Playwright):
context = playwright.request.new_context(
base_url="https://api.myapp.com",
extra_http_headers={"Accept": "application/json"},
)
yield context
context.dispose()
def test_list_users(api_context: APIRequestContext):
response = api_context.get("/users")
assert response.ok
users = response.json()
assert len(users) > 0
assert all("email" in u for u in users)playwright.request.new_context(...) is the API-only equivalent of browser.new_context(...). No browser launches; the context only knows how to make HTTP requests. dispose() releases the underlying connection pool. Tag tests like these with @pytest.mark.api so you can run pure API checks without launching browsers.
Sharing auth between API and UI
This is where Playwright's design shines: APIRequestContext and the browser context share auth state via storage_state.
def test_create_via_api_assert_via_ui(page: Page, context):
# 1. Log in via UI once
page.goto("/login")
page.get_by_label("Email").fill("admin@test.com")
page.get_by_label("Password").fill("AdminPass")
page.get_by_role("button", name="Login").click()
# 2. Now use page.request — it inherits the cookies from the page
response = page.request.post("/api/products", json={"name": "API Product", "price": 99})
assert response.ok
product = response.json()
# 3. Verify in the UI
page.goto(f"/products/{product['id']}")
expect(page.get_by_role("heading")).to_have_text("API Product")Because page.request is bound to the browser context, the session cookie set during login is automatically used on the API call. No token-juggling needed. We'll cover the storage_state flow properly in chapter 5; for now, the takeaway is that the same fixture can drive both.
API setup, UI test — the core pattern
The pattern that saves the most time on a real test suite: use the API to create whatever data the test needs, then drive the UI to verify behaviour.
import time
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture
def test_product(page: Page):
# Setup via API — fast, no UI clicks
response = page.request.post("/api/products", json={
"name": f"Test Product {time.time()}",
"price": 29.99
})
product = response.json()
yield product
# Teardown via API — clean state for the next test
page.request.delete(f"/api/products/{product['id']}")
def test_product_page_renders(page: Page, test_product):
page.goto(f"/products/{test_product['id']}")
expect(page.get_by_role("heading")).to_have_text(test_product["name"])
expect(page.get_by_test_id("price")).to_contain_text("29.99")The fixture creates a product through the API in a few hundred milliseconds. The test focuses on what it actually cares about — does the product page render correctly? — and the teardown deletes the product no matter what happened.
If you'd done this through the UI ("click New Product", "fill form", "click Save") the fixture would take 5-10 seconds per test and exercise UI code that has nothing to do with what you're testing.
API testing vs UI testing vs combined
Three shapes of test in a Playwright Python suite
Pure UI
Driver: page fixture; uses page.goto, get_by_role, fill, click
What it tests: rendering, interaction, accessibility, layout
Speed: slowest — full browser, full DOM
Right for: visual regressions, end-to-end happy paths, journey tests
Pure API
Driver: api_context (session-scoped fixture from playwright.request)
What it tests: contract, auth, validation, business logic
Speed: fastest — no browser launched at all
Right for: contract tests, validation matrices, status code checks
Combined (API setup + UI assert)
Driver: page.request for setup, page for assertions
What it tests: rendering of known-good data, full integration paths
Speed: medium — UI for the test body, API for setup overhead
Right for: 80% of real QA suites — fast, focused, robust
A healthy suite has all three shapes. Pure UI for the user-journey smoke tests. Pure API for the contract matrix. Combined for the bulk of feature tests.
Coming from Playwright TypeScript?
The mappings:
- TS
await page.request.post('/api/users', { data: {...} })→ Pythonpage.request.post("/api/users", json={...})(note:datawas renamedjsonfor JSON specifically) - TS
await response.ok()→ Pythonresponse.ok(property, not method) - TS
await response.json()→ Pythonresponse.json()(no await) - TS
request: APIRequestContextfixture → Pythonpage.requestor session-scopedplaywright.request.new_context(...) - TS
await context.storageState({ path })→ Pythoncontext.storage_state(path=...)— same shared-auth pattern
Behaviourally identical. The Python form is a bit more verbose for the standalone case (you build the context yourself in a fixture), but the per-page page.request for inline use is just as ergonomic.
Cross-reference: API Testing Masterclass
Everything you'd learn in the API Testing Masterclass — REST conventions, status codes, JSON Schema validation, contract testing, auth flows — applies inside Playwright Python identically. The masterclass uses Postman and httpx; the Playwright way is page.request with the same assertions. Same skills, different driver.
⚠️ Common mistakes
- Using
data=when you meanjson=. Python'sdata=sends form-encoded values (name=Alice&email=...) withContent-Type: application/x-www-form-urlencoded. For JSON APIs, usejson=— it sets the right header and serialises. The most common cause of mysterious 415 Unsupported Media Type errors. - Asserting on
response.json()when the response is empty. A 204 No Content response has no body —response.json()raises a JSON parse error. Check the status first:assert response.status == 204, then skip.json(). Same caveat for HEAD responses and redirects. - Sharing an
APIRequestContextacross tests when the auth differs per test. A session-scoped context carries headers across every test that uses it. If tests need different auth, use per-test contexts (scope="function") or pass the auth header in each call. Catching this at debug time is harder than catching it at design time.
🎯 Practice task
Mix UI and API testing in one suite. 30 minutes.
-
Use a public testing API like
https://jsonplaceholder.typicode.com(read-only fake REST API) for the API half. For the UI half, keep using Sauce Demo or your dev environment. -
Create
tests/test_api_basics.pywith three pure API tests:import pytest from playwright.sync_api import APIRequestContext, Playwright @pytest.fixture(scope="session") def api(playwright: Playwright): ctx = playwright.request.new_context(base_url="https://jsonplaceholder.typicode.com") yield ctx ctx.dispose() @pytest.mark.api class TestPostsApi: def test_list_posts(self, api: APIRequestContext): response = api.get("/posts") assert response.ok posts = response.json() assert len(posts) == 100 def test_get_single_post(self, api: APIRequestContext): response = api.get("/posts/1") assert response.ok post = response.json() assert post["id"] == 1 assert "title" in post def test_create_post(self, api: APIRequestContext): response = api.post("/posts", json={"title": "Hello", "body": "World", "userId": 1}) assert response.status == 201 assert response.json()["title"] == "Hello" -
Register the
apimarker inpytest.ini:markers = api: API-only tests (no browser launched) -
Run only the API tests:
pytest -m api -v. Three test results. -
Combine API setup with UI test. Add a fixture that POSTs a fake user to
/users(the API echoes it back without persisting, but it's enough to demonstrate the pattern):@pytest.fixture def fake_user(api: APIRequestContext): res = api.post("/users", json={"name": "Alice", "email": "alice@test.com"}) return res.json()Use it in a test that opens a UI page and asserts the user's name appears (any UI page that displays a user works — adapt to your dev app).
-
Stretch: add an authenticated context fixture. Pick a public API that needs an API key (or use your dev environment's JWT login endpoint). Build a session-scoped context with the token in
extra_http_headers. Run a couple of tests against it. Then change the token in the fixture and watch every test fail with 401 — the token is shared, just like a real session.
You've got both halves of the network-testing toolkit. The next lesson combines them — the API-setup-then-UI-test pattern that turns a slow, flaky integration suite into a fast, focused one.