Playwright API testing uses the same framework, the same Playwright project/configuration, and the same test suite you already write UI tests in to send real HTTP requests, mock responses, and verify the resulting server-side state after a UI action. No second tool, no second test runner, no context-switching between a UI suite and an API suite that drift out of sync with each other.
That's the core shift this post covers: what changes when API testing stops being a separate discipline, how to set it up for pure API tests and for UI tests that need backend validation, and how this approach affects coverage, feedback, and CI cost.
The Problem: Two Test Suites, One Application
Most teams end up with API coverage and UI coverage living in different tools: Postman or a REST client for the API; Playwright or Selenium for the UI, because that's how the tooling has traditionally been split. That split has a cost:
- Duplicated setup. Auth, base URLs, and test data creation get written twice, once per suite, and drift apart over time.
- Blind spots at the seam. UI tests confirm a button click looked successful. API tests confirm an endpoint works in isolation. Neither one confirms that clicking the button actually triggered the right backend call with the right payload.
- Slow, UI-only precondition setup. To test "issue #42 shows correctly," a UI-only suite has to create that issue by clicking through the UI first, and one more brittle flow that can fail before the actual test even starts.
- No shared debugging story. When something breaks at the UI/API boundary, you're reconciling two separate tools' worth of logs to find out where the mismatch happened.
None of this is a tooling limitation you have to accept, it's a consequence of treating "API testing" and "UI testing" as different jobs.
The Fix: One Framework, One Request Context
Playwright provides the same API-testing primitives for both pure API tests and UI tests that need backend setup or validation.
| Task | Split-tooling approach | With Playwright API |
|---|---|---|
| Test an endpoint directly | Separate Postman collection or REST client, maintained apart from the UI suite | A request fixture test, in the same repo, same config, same CI job |
| Seed data before a UI test | Click through the UI to create it, or maintain a separate seeding script | One request.post() call in beforeAll, reusing the same auth |
| Validate a UI action's server-side result | Trust the UI state, or manually check the database/API afterward |
request.get() after the UI action, asserting on the real server-side state |
| Keep cookie/local-storage auth in sync between suites | Separate auth setup | Reuse Playwright storageState between API and browser contexts |
Pure API Testing
For endpoint-level tests with no browser involved, configure a base URL and headers once, and every test gets a ready-to-use request fixture.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: 'https://api.github.com',
extraHTTPHeaders: {
'Accept': 'application/vnd.github.v3+json',
'Authorization': `token ${process.env.API_TOKEN}`,
},
},
});
test('should create a bug report', async ({ request }) => {
const newIssue = await request.post('/repos/USER/REPO/issues', {
data: { title: '[Bug] report 1', body: 'Bug description' },
});
expect(newIssue.ok()).toBeTruthy();
const issues = await request.get('/repos/USER/REPO/issues');
expect(await issues.json()).toContainEqual(
expect.objectContaining({ title: '[Bug] report 1' })
);
});
Setup and teardown for test data can use the built-in request fixture in beforeAll/afterAll. If you create a standalone APIRequestContext yourself with playwright.request.newContext(), dispose of it in afterAll.
Combined UI + API: Validating That Actions Trigger Backend Calls
This is where the split-tooling approach falls down hardest and where a single framework pays off the most. Two patterns cover most cases.
Establishing preconditions via API, before touching the UI
Create state through the API, then confirm it's reflected correctly in the UI without clicking through slow setup flows.
let apiContext;
test.beforeAll(async ({ playwright }) => {
apiContext = await playwright.request.newContext({
baseURL: 'https://api.github.com',
extraHTTPHeaders: { 'Authorization': `token ${process.env.API_TOKEN}` },
});
});
test('last created issue should be first in the list', async ({ page }) => {
const newIssue = await apiContext.post('/repos/USER/REPO/issues', {
data: { title: '[Feature] request 1' },
});
expect(newIssue.ok()).toBeTruthy();
await page.goto('https://github.com/USER/REPO/issues');
await expect(
page.locator(`a[data-hovercard-type='issue']`).first()
).toHaveText('[Feature] request 1');
});
The test only ever clicks through the one flow it's actually testing, the setup happens instantly over the API.
Validating postconditions after a UI action
Drive the interaction through the UI, then confirm the backend actually recorded it, not just that the UI rendered a success state.
test('creating an issue via the UI should persist on the server', async ({ page }) => {
await page.goto('https://github.com/USER/REPO/issues');
await page.getByText('New Issue').click();
await page.getByRole('textbox', { name: 'Title' }).fill('Bug report 1');
await page.getByText('Submit new issue').click();
const issueId = new URL(page.url()).pathname.split('/').pop();
const newIssue = await apiContext.get(`/repos/USER/REPO/issues/${issueId}`);
expect(newIssue.ok()).toBeTruthy();
expect(await newIssue.json()).toEqual(
expect.objectContaining({ title: 'Bug report 1' })
);
});
This is the check a UI-only test structurally can't do: it proves the click didn't just update local state or a client-side cache, it verifies that the UI action resulted in the expected server-side state.
Sharing auth between both
Log in once via the API, save the resulting storageState, and reuse it in a browser context, one login flow instead of two:
const requestContext = await request.newContext();
await requestContext.get('https://api.example.com/login');
await requestContext.storageState({ path: 'state.json' });
const context = await browser.newContext({ storageState: 'state.json' });
Mocking: Testing States You Can't Easily Produce
Not every test should hit a real backend. Playwright can intercept, modify, or fully mock network traffic at the browser level with page.route(), useful for error states, edge cases, and tests that shouldn't depend on a live API being up.
Mock a response entirely: the real API is never called:
await page.route('*/**/api/v1/fruits', async route => {
await route.fulfill({ json: [{ name: 'Strawberry', id: 21 }] });
});
Call the real API, then patch the response: useful when you need mostly-real data with one controlled edge case injected:
await page.route('*/**/api/v1/fruits', async route => {
const response = await route.fetch();
const json = await response.json();
json.push({ name: 'Loquat', id: 100 });
await route.fulfill({ response, json });
});
Replay from a recorded HAR file: record real traffic once, then run tests against it offline and deterministically, with no live dependency at all:
await page.routeFromHAR('./hars/fruit.har', {
url: '*/**/api/v1/fruits',
update: false,
});
These interactions are visible in Playwright's trace/network tooling, making mocked and live network behavior debuggable from the same workflow.
Beyond HTTP: Playwright can also intercept and mock WebSocket connections with page.routeWebSocket() for real-time features. For browser APIs that Playwright doesn't directly automate, page.addInitScript() can be used to install mocks before the page's own code runs.
The Efficiency Case
For a team deciding whether to consolidate API and UI testing into one framework, these are the levers that actually move:
- One suite to maintain, not two. Auth, base URLs, and test data setup exist in a single place instead of being reimplemented per tool, every hour spent keeping a Postman collection and a Playwright suite in sync is an hour not spent on coverage.
- Faster test setup, lower CI time. API-based setup is typically much faster than reproducing the same state through a browser workflow, because it avoids navigation, rendering, and UI interaction.
- Fewer false negatives. A UI test that only checks for a success toast can pass while the backend call silently failed. Postcondition checks against the real API catch that class of bug before a customer does.
- Deterministic tests, fewer flaky-test investigations. Mocked and HAR-replayed tests aren't affected by a slow or temporarily-down backend, which removes one of the most common sources of "flaky" CI failures that cost engineering time to triage and usually turn out to be infrastructure, not the code under test.
CI/CD Recommendations
- Keep pure API tests in their own project/job. They're fast and don't need a browser, running them separately from UI tests shortens feedback on backend-only regressions.
- Use HAR replay for UI tests that don't need live data. It removes a live dependency from the critical path of your CI pipeline, and makes those tests immune to unrelated backend outages.
- Reserve postcondition checks for flows where "the UI looked right" genuinely isn't enough proof. Checkout, payments, anything where a silent backend failure would be expensive. Applying it everywhere adds maintenance cost without a matching increase in the bugs it catches.
Official Resources
-
API testing —
APIRequestContext, setup/teardown, sharing auth between API and browser contexts - Mock APIs — mocking requests, modifying responses, HAR file recording and replay
- Mock browser APIs — mocking browser-level APIs Playwright doesn't automate directly
- Network — request/response interception, modification, and WebSocket mocking in depth
Final Thoughts
Playwright API testing isn't about replacing every API test with a browser test, or using one tool simply for the sake of consolidation. It's about having the flexibility to test the right layer with the same framework.
Pure API tests can stay fast and focused. UI tests can use the API to create preconditions or verify server-side outcomes. Network mocking and HAR replay can remove unnecessary dependencies from UI tests. And because all of these live within the same Playwright project, they can share configuration, authentication, test data strategies, debugging tools, and CI infrastructure.
The result is a testing strategy that's easier to compose: use the API where it makes sense, use the browser where it matters, and combine them when testing one without the other leaves gaps.
Keep Reading
If you're interested in getting more out of Playwright when tests fail, I also cover two useful approaches to test evidence and debugging:
- Playwright Trace Viewer: Debug Failed Tests Without Reproducing Them Stop
- Debugging Blind: Playwright Screencast API for Richer Test Evidence
Both take a closer look at how Playwright can make failures easier to understand without having to reproduce them locally.
Top comments (0)