Combining UI and API Tests

8 min read

The most-effective Playwright Python tests barely look like UI tests. They use the API to seed exactly the data the test needs, drive the UI through the one specific behaviour they care about, then use the API again to verify the persisted state and clean up. The UI half stays small and focused; the API half handles everything that doesn't need to look like a user. This pattern is genuinely a Playwright superpower — it's why teams that internalise it write 5x more tests in the same time, and why those tests rarely flake. This lesson is the playbook.

The golden pattern — API setup → UI action → API verify → API cleanup

The shape of a well-designed integration test:

import time
from playwright.sync_api import Page, expect
 
 
def test_checkout_creates_order(page: Page):
    # 1. API SETUP — create the user and product without touching the UI
    user_res = page.request.post("/api/users", json={
        "name": "Test User",
        "email": f"test-{time.time()}@test.com",
        "password": "pass123",
    })
    user = user_res.json()
 
    product_res = page.request.post("/api/products", json={
        "name": "Test Product",
        "price": 49.99,
    })
    product = product_res.json()
 
    # 2. UI ACTION — drive the UI through the one behaviour we care about
    page.goto("/login")
    page.get_by_label("Email").fill(user["email"])
    page.get_by_label("Password").fill("pass123")
    page.get_by_role("button", name="Login").click()
 
    page.goto(f"/products/{product['id']}")
    page.get_by_role("button", name="Add to cart").click()
    page.get_by_role("link", name="Checkout").click()
    page.get_by_role("button", name="Place order").click()
 
    expect(page.get_by_text("Order confirmed")).to_be_visible()
 
    # 3. API VERIFY — confirm the order actually exists in the backend
    orders_res = page.request.get(f"/api/users/{user['id']}/orders")
    orders = orders_res.json()
    assert len(orders) == 1
    assert orders[0]["total"] == 49.99
    assert orders[0]["product_id"] == product["id"]
 
    # 4. API CLEANUP — delete the user and product
    page.request.delete(f"/api/users/{user['id']}")
    page.request.delete(f"/api/products/{product['id']}")

Read it as four phases: setup, action, verify, cleanup. Each phase has one job. The UI section is the smallest part of the test, even though the test is about the checkout UI — because everything else has been factored out to the API.

Why this pattern wins

Compared to a pure UI version that creates the user and product through the UI sign-up flow and admin panel:

  • Speed. API calls take 50-200ms each; equivalent UI flows take 5-15 seconds. A 50-test suite shrinks from ~10 minutes to ~2 minutes.
  • Focus. The test code is about the checkout flow — not about how to register a user, not about admin product creation. When checkout breaks, this test fails for that reason, not because the registration form changed.
  • Reliability. The API setup is deterministic — no flaky locators, no timing waits, no UI-state assumptions. UI-driven setup adds those failure modes to every single test.
  • Data isolation. Each test creates its own user and product with timestamped names. Tests don't fight over shared fixtures, parallelism is safe.

Wrapping setup/cleanup in fixtures — the cleaner shape

The pattern above is verbose because the setup/cleanup is inline. Move it to a fixture:

import pytest
import time
 
@pytest.fixture
def seeded_user(page: Page):
    res = page.request.post("/api/users", json={
        "name": "Test User",
        "email": f"test-{time.time()}@test.com",
        "password": "pass123",
    })
    user = res.json()
    yield user
    page.request.delete(f"/api/users/{user['id']}")
 
 
@pytest.fixture
def seeded_product(page: Page):
    res = page.request.post("/api/products", json={"name": "Test Product", "price": 49.99})
    product = res.json()
    yield product
    page.request.delete(f"/api/products/{product['id']}")
 
 
def test_checkout_creates_order(page: Page, seeded_user, seeded_product):
    # The fixtures handled setup and will handle cleanup. Test body is the UI flow only.
    page.goto("/login")
    page.get_by_label("Email").fill(seeded_user["email"])
    page.get_by_label("Password").fill("pass123")
    page.get_by_role("button", name="Login").click()
 
    page.goto(f"/products/{seeded_product['id']}")
    page.get_by_role("button", name="Add to cart").click()
    page.get_by_role("link", name="Checkout").click()
    page.get_by_role("button", name="Place order").click()
 
    expect(page.get_by_text("Order confirmed")).to_be_visible()
 
    # Verify via API
    orders = page.request.get(f"/api/users/{seeded_user['id']}/orders").json()
    assert len(orders) == 1

The test body now reads like a recipe: log in, add to cart, checkout, verify. The fixture machinery handles everything else. This is the shape every mature Playwright Python suite converges on.

The lifecycle — what runs when

Step 1 of 5

1. API setup

Fixtures POST users, products, and any other dependencies via page.request. Fast, deterministic, no UI clicks involved.

The verification phase (step 4) is what catches the most subtle bugs. UI tests that only assert the toast message ("Order confirmed") miss bugs where the toast lies — the order didn't actually save, the email didn't go out, the inventory wasn't decremented. API verification reads the source of truth.

When to skip API verification — and when not to

Not every test needs the API-verify phase. Skip it when:

  • The UI itself displays the result of a backend read. A test that creates a product via UI and asserts the product appears on the listing page is implicitly checking the backend — the listing reads from the database. Adding GET /api/products afterward is redundant.

Keep it when:

  • The UI shows a success message but the backend write is async or might fail silently. "Email sent!" toasts famously lie — the queue might be full, the SMTP server might be down. Hit GET /api/email-queue to confirm the email was actually queued.
  • The backend has invariants the UI doesn't show. A successful checkout should decrement inventory, log an audit event, and send a confirmation email. The UI shows none of these. API verification is the only way to know they happened.

Authentication shared across UI and API

A subtle but important detail: when you call page.request.post("/api/...") after logging in via the UI, the auth cookie set during login is automatically used. The same BrowserContext backs both the page and the API client.

# 1. Log in via UI — sets the session cookie on the context
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. The cookie is now on page.request — no Authorization header needed
admin_orders = page.request.get("/api/admin/orders").json()

This is the subtle reason page.request is preferable to a separate httpx.Client inside the test — Playwright handles the auth bridging for you.

Coming from Playwright TypeScript?

The TypeScript version of this pattern uses test.use({ storageState }) and request fixture:

const test = base.extend<{ seededUser: User }>({
  seededUser: async ({ page }, use) => {
    const res = await page.request.post('/api/users', { data: {...} });
    const user = await res.json();
    await use(user);
    await page.request.delete(`/api/users/${user.id}`);
  },
});

The Python equivalent is @pytest.fixture with yield — same lifecycle, less ceremony. The auth-sharing between UI and API is identical: both sides use the same context.

⚠️ Common mistakes

  • Skipping cleanup because "the test passed." A test that creates a user and doesn't delete it leaks data into the next test. After 100 runs, your test environment has 100 stray users, each with a slightly different timestamp in the email. Always yield in the fixture and delete on the way out — Python's try/finally semantics ensure cleanup runs even when the test fails.
  • Using API for both setup and UI assertions. A test that creates a user via API, then asserts via API that the user exists, isn't testing the UI at all — it's testing the API end-to-end against itself. The API-verify step should be paired with a UI-driven action, not a UI-skipping one. Otherwise it belongs in your pure-API test suite.
  • Hardcoding test emails like test@test.com. Two tests using the same email run in parallel collide. Always use f"test-{time.time()}@test.com" or uuid.uuid4() to guarantee uniqueness. The cleanup fixture should target the specific user it created, not "all users with email starting with test@."

🎯 Practice task

Build a complete API-setup → UI-test → API-verify suite for a public testing API. 30-40 minutes.

  1. We'll use https://jsonplaceholder.typicode.com (the same fake REST API as the previous lesson) for the API half. It echoes POSTs back with a fake id but doesn't persist — perfect for practising the pattern without polluting a real database.

  2. Create tests/test_combined_pattern.py:

    import time
    import pytest
    from playwright.sync_api import APIRequestContext, Page, Playwright, expect
     
    @pytest.fixture(scope="session")
    def api(playwright: Playwright):
        ctx = playwright.request.new_context(base_url="https://jsonplaceholder.typicode.com")
        yield ctx
        ctx.dispose()
     
    @pytest.fixture
    def seeded_post(api: APIRequestContext):
        res = api.post("/posts", json={
            "title": f"Test {time.time()}",
            "body": "Generated by test",
            "userId": 1,
        })
        post = res.json()
        yield post
        # Cleanup — JSONPlaceholder doesn't actually persist, but the call mirrors real usage
        api.delete(f"/posts/{post['id']}")
     
    def test_post_round_trip(api: APIRequestContext, seeded_post):
        # UI step omitted — JSONPlaceholder has no UI. Pretend we navigated to /posts/{id}.
        # Then verify via API that the post is "fetchable":
        res = api.get(f"/posts/{seeded_post['id']}")
        # JSONPlaceholder fakes GET /posts/101+ as 404, so just verify the structure
        # On a real API: assert res.json()["title"] == seeded_post["title"]
        assert res.status in (200, 404)
  3. Run with pytest tests/test_combined_pattern.py -v. The fixture creates the post; the test "verifies" via API; the fixture cleans up.

  4. Adapt to your real environment. If you have a dev API at hand, replace JSONPlaceholder with your own base URL. Use the pattern to test a real flow — create a user via API, log in via UI, change a setting via UI, verify via API. Watch the test pass and the cleanup leave no trace.

  5. Demonstrate auth bridging. In a single test, log in via the UI, then make an authenticated API call without setting any headers. Confirm it succeeds because page.request inherits the session cookie. Then call context.clear_cookies() and try the same API call — confirm it returns 401.

  6. Stretch: measure the speed difference. Take an existing test that creates a user via UI form-fill (sign-up flow). Re-write it to create the user via API. Time both versions with pytest --durations=10. The difference is usually 5-10 seconds per test — multiply by the size of your suite.

You've got the pattern that defines healthy Playwright test suites. The next lesson — the last in this chapter — covers the supporting infrastructure: JSON fixtures, dataclasses for typed test data, and the loader patterns that keep test data version-controlled and easy to maintain.

// tip to track lessons you complete and pick up where you left off across devices.