DEV Community

Cover image for Playwright API Testing: Why Your API and UI Tests Should Live Together
Vanessa Sastre
Vanessa Sastre

Posted on AI-assisted

Playwright API Testing: Why Your API and UI Tests Should Live Together

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}`,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode
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' })
  );
});
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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' })
  );
});
Enter fullscreen mode Exit fullscreen mode

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' });
Enter fullscreen mode Exit fullscreen mode

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 }] });
});
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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 testingAPIRequestContext, 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:

Both take a closer look at how Playwright can make failures easier to understand without having to reproduce them locally.

Top comments (0)