UI tests catch bugs the user can see. API tests catch bugs the user can't see — silent backend failures, incorrect status codes, malformed JSON. The most powerful test pattern in any modern QA suite combines both: API for setup, UI for the actual test, API for verification, API for cleanup. The page fixture and the request fixture share a context, so a single test can use both seamlessly. This lesson is about why that pattern catches the most bugs per test, and how to structure it without the test becoming a 200-line wall of code.
The canonical pattern in one test
Every well-formed combined test has the same five beats:
import { test, expect } from "@playwright/test";
test("user can log in and see their profile", async ({ page, request }) => {
// 1. SETUP — create a user via API
const createRes = await request.post("/api/users", {
data: {
name: "Alice",
email: `alice-${Date.now()}@test.com`,
password: "Sup3rS3cret!"
}
});
expect(createRes.status()).toBe(201);
const user = await createRes.json();
try {
// 2. UI TEST — drive the login flow
await page.goto("/login");
await page.getByLabel("Email").fill(user.email);
await page.getByLabel("Password").fill("Sup3rS3cret!");
await page.getByRole("button", { name: "Sign in" }).click();
// 3. UI ASSERTION — what the user sees
await expect(page).toHaveURL(/dashboard/);
await expect(page.getByRole("heading")).toContainText("Welcome, Alice");
// 4. API VERIFICATION — what the backend recorded
const sessionRes = await request.get("/api/session");
expect(sessionRes.ok()).toBeTruthy();
const session = await sessionRes.json();
expect(session.userId).toBe(user.id);
} finally {
// 5. CLEANUP — delete the test user no matter what
await request.delete(`/api/users/${user.id}`);
}
});Read each beat for the job it's doing:
- Setup (API) is deterministic state creation. Milliseconds, no UI.
- UI test is the thing under test — the part you actually want assertions about.
- UI assertion confirms what the user sees.
- API verification confirms what the backend persisted. This is the assertion most teams forget — and it's where silent bugs hide.
- Cleanup (API) ensures the next test starts fresh. The
try/finallyguarantees cleanup runs even on assertion failure.
Why this pattern catches more bugs
A pure UI test that ends at "Welcome, Alice" passes whenever the UI renders the welcome string. It can't tell the difference between:
- The login worked, the session is valid, the backend recorded everything correctly.
- The login worked but the session never persisted; the user is rendered as cached state and will be logged out on the next page reload.
- The login failed silently and the UI is showing a stale "welcome" string from a previous session.
The API verification catches all three. GET /api/session is the source of truth — the test fails if the backend doesn't agree with the UI. Without it, you have a green test that hides a real bug.
The cleanup matters for a different reason: test isolation. If the test creates Alice and never deletes her, the next test that creates an Alice gets a unique-email-already-exists error. Multiply by 200 tests, and your suite slowly drifts toward "works locally, breaks on CI" because the test database accumulates state.
API setup — when to reach for it
The setup step earns its keep when:
- The thing you want to test isn't sign-up. If the test is about checkout, don't waste 30 seconds clicking through registration first. API-create the user.
- The state is hard to set up via UI. A user with 50 saved cards, an admin with specific permissions, an order in a half-complete state. API endpoints for these scenarios usually exist (or can be added) — UI flows often don't.
- The state is destructive. Deleting all of a user's orders to test the empty state, or marking a product as out-of-stock. API setup is fast, reversible, and doesn't pollute the UI test environment.
A common shortcut for auth specifically: storage state. Instead of API-logging-in for every test, log in once and save cookies to a file:
// global-setup.ts (configured in playwright.config.ts)
const context = await browser.newContext();
const page = await context.newPage();
await page.goto("/login");
await page.getByLabel("Email").fill("alice@test.com");
await page.getByLabel("Password").fill("pw123");
await page.getByRole("button", { name: "Sign in" }).click();
await context.storageState({ path: "auth.json" });
// playwright.config.ts
use: { storageState: "auth.json" }
// Now every test starts already logged inWe'll see this pattern in depth in chapter 6's authentication lesson. For per-test setup of non-auth state, the API call is the right tool.
API verification — close the loop
After every state-changing UI action, ask: "What's the backend's view of what just happened?" Then assert it.
test("delete order via UI removes it from backend", async ({ page, request }) => {
// Setup: create order
const orderRes = await request.post("/api/orders", { data: { items: [{ id: "p1", qty: 1 }] } });
const order = await orderRes.json();
// UI: delete it
await page.goto(`/orders/${order.id}`);
await page.getByRole("button", { name: "Cancel order" }).click();
page.on("dialog", dialog => dialog.accept());
// UI assertion
await expect(page).toHaveURL(/orders$/);
await expect(page.getByText(`Order #${order.id}`)).toBeHidden();
// API verification — what did the backend actually do?
const checkRes = await request.get(`/api/orders/${order.id}`);
expect(checkRes.status()).toBe(404);
});The UI assertion confirms "the user can no longer see this order." The API verification confirms "the order was actually deleted." Both are needed: a buggy UI that hides the order without sending the DELETE to the backend would pass the UI assertion and fail the API one.
API cleanup — the test-isolation safety net
Two things to internalise:
- Always wrap the UI section in
try/finally. If the UI assertion fails, the test stops — but you still want the cleanup to run.try/finallyis the cleanest way;test.afterEachworks too if the cleanup is shared across tests. - Tag every test resource with a unique-per-run identifier. Set
emailto a template literal like`alice-${Date.now()}@test.com`as a simple version. For more complex apps, prefix with the run ID (process.env.GITHUB_RUN_ID || 'local') so failed-cleanup leftovers can be found and bulk-deleted later.
test.afterEach(async ({ request }) => {
// Bulk delete any users created by this test run
await request.delete(`/api/admin/users?prefix=alice-${Date.now()}`);
});The combined-test flow
Step 1 of 5
API setup
request.post('/api/users', { data: ... }) — create the test data this scenario depends on. Fast, deterministic, no UI cost.
A complete e-commerce test
A typed test that exercises the full pattern against a hypothetical app:
import { test, expect } from "@playwright/test";
test.describe("E-commerce — combined UI + API", () => {
test("places an order through UI; backend records it", async ({ page, request }) => {
// 1. SETUP — create a user and a product via API
const userRes = await request.post("/api/users", {
data: {
email: `buyer-${Date.now()}@test.com`,
password: "pw123"
}
});
const user = await userRes.json();
const productRes = await request.post("/api/products", {
data: {
name: "Test Headphones",
price: 29.99,
stock: 5
}
});
const product = await productRes.json();
try {
// 2. UI — log in, find the product, complete checkout
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 page.getByRole("link", { name: "Shop" }).click();
await page.getByText("Test Headphones").click();
await page.getByRole("button", { name: "Add to cart" }).click();
await page.getByRole("link", { name: "Cart" }).click();
await page.getByRole("button", { name: "Checkout" }).click();
await page.getByRole("button", { name: "Place order" }).click();
// 3. UI ASSERTION
await expect(page.getByText("Order confirmed")).toBeVisible();
const orderUrl = page.url();
const orderId = orderUrl.match(/orders\/(\d+)/)?.[1];
expect(orderId).toBeDefined();
// 4. API VERIFICATION — backend recorded the order with the right data
const orderRes = await request.get(`/api/orders/${orderId}`);
const order = await orderRes.json();
expect(order.userId).toBe(user.id);
expect(order.status).toBe("pending");
expect(order.items).toEqual([{ productId: product.id, quantity: 1 }]);
// Stock decremented?
const stockRes = await request.get(`/api/products/${product.id}`);
const updatedProduct = await stockRes.json();
expect(updatedProduct.stock).toBe(4);
} finally {
// 5. CLEANUP — order, product, user
await request.delete(`/api/orders?userId=${user.id}`);
await request.delete(`/api/products/${product.id}`);
await request.delete(`/api/users/${user.id}`);
}
});
});Read this for the proportions: the actual UI flow is six lines. The setup is six lines. The API verification is eight lines. The cleanup is three. Even with comprehensive verification and cleanup, the test reads top-to-bottom in under 50 lines and tells one clear story: "a logged-in user can buy a product, the backend records it, stock decrements."
When NOT to combine
Three cases where pure UI or pure API is the right call:
- Pure rendering tests. The component renders correctly given fixed input. Mock the API entirely with
page.routeand assert on the DOM. No backend involvement needed. - Pure API contract tests. The API behaves correctly given fixed input. No browser, no UI. Faster and more focused.
- Visual / accessibility regression tests. The page looks the same, the page is accessible. Backend state isn't varying — set it via API once, snapshot the page.
The combined pattern is for behaviour tests — flows where both UI and backend behaviour matter and you want a single test that validates the full round-trip.
Coming from Cypress?
The combined pattern in Cypress looks like:
cy.request("POST", "/api/users", { ... }).then(res => {
cy.visit("/login");
cy.get(...).type(res.body.email);
cy.request("GET", "/api/session").its("body.userId").should("eq", res.body.id);
});Playwright's version is structurally identical but reads more naturally because async/await is linear:
const userRes = await request.post("/api/users", { ... });
const user = await userRes.json();
await page.goto("/login");
await page.getByLabel("Email").fill(user.email);
const sessionRes = await request.get("/api/session");
expect((await sessionRes.json()).userId).toBe(user.id);If your Cypress combined tests have a callback-pyramid problem (.then inside .then inside .then), the migration to Playwright tends to flatten them dramatically.
⚠️ Common mistakes
- Skipping API verification because "the UI looks right." This is the single most common reason a test passes locally but the user reports a bug in production. The UI can show stale state, cached values, optimistic updates that never hit the backend. Always close the loop with
request.get(...)after a state-changing UI action. - Using shared user accounts across tests. "We just have a
test@test.comaccount that every test uses." Now tests can't run in parallel without colliding, and one test mutating state breaks the others. Always create a per-run unique resource (timestamp + random suffix) and delete it infinallyorafterEach. - Doing setup via UI when API would do. "Sign up via the UI in
beforeEach" might run 200 times in a CI run, costing minutes of dead time clicking through a registration flow that isn't the test focus. API setup turns those minutes into milliseconds. Reserve UI setup for tests that are specifically about the setup flow itself.
🎯 Practice task
Write a full combined test against a real backend. 30-40 minutes.
-
Use
https://reqres.in— a free public API that supportsGET,POST,PUT,DELETEand returns realistic responses without persisting (so it's safe to hammer). SetbaseURL: "https://reqres.in". -
Create
tests/combined.spec.ts:import { test, expect } from "@playwright/test"; test.describe("Combined UI + API", () => { test("API setup + assertion + cleanup", async ({ request }) => { // 1. SETUP — create const createRes = await request.post("/api/users", { data: { name: "Alice", job: "QA Engineer" } }); expect(createRes.status()).toBe(201); const user = await createRes.json(); expect(user).toHaveProperty("id"); expect(user).toHaveProperty("createdAt"); try { // 3. VERIFICATION — read back (reqres is read-only on /users/:id, so we just verify the response shape) expect(user.name).toBe("Alice"); expect(user.job).toBe("QA Engineer"); // 2/3. UPDATE const updateRes = await request.put(`/api/users/${user.id}`, { data: { name: "Alice Reed", job: "Senior QA Engineer" } }); expect(updateRes.status()).toBe(200); const updated = await updateRes.json(); expect(updated.name).toBe("Alice Reed"); } finally { // 5. CLEANUP const deleteRes = await request.delete(`/api/users/${user.id}`); expect(deleteRes.status()).toBe(204); } }); test("API setup, UI test, API verification (mocked UI flow)", async ({ page, request }) => { // 1. SETUP via API const userRes = await request.get("/api/users/2"); const user = (await userRes.json()).data; // Mock the products endpoint so the UI test has data await page.route("**/api/products", async route => { await route.fulfill({ status: 200, json: [{ id: 1, name: "Test Product", price: 9.99 }] }); }); // 2. UI — visit, see the mocked product await page.goto("https://example.com"); // (real test would navigate to your app; here we just verify the route was set up) expect(user.first_name).toBeDefined(); }); }); -
Run them. The first test demonstrates pure API setup → assertion → cleanup. The second is a sketch of UI + API combined (you'd flesh out the UI portion against your own app).
-
Demonstrate cleanup-on-failure. In test 1, change one of the middle assertions to fail (
expect(user.name).toBe('WRONG')). Run again. The test fails — but the cleanupDELETEstill runs, because oftry/finally. Watch the test output to confirm. Withoutfinally, the user would be leaked into the test environment. -
Stretch: if you have your own dev environment, write a real combined test against it: API-create a product, UI-add it to a cart, UI-complete checkout, API-verify the order, API-delete-everything. This is the senior-level test pattern that typical CI suites run hundreds of in parallel.
You now have the highest-leverage testing pattern any QA team uses — and you have all four primitives (request, page, page.route, page.waitForResponse) to compose it. The next and final lesson in this chapter steps sideways into a different network technique: HAR file recording and replay, for tests that run fully offline against snapshotted real responses.