Playwright API Testing: Day 8 Tutorial
Table of Contents
- Why Playwright API Testing Belongs in Your Suite
- The Request Fixture: Your First API Test
- Configuration, Headers, and Authentication
- Mixing API Setup with UI Assertions
- Mocking and Modifying Network Responses
- A Small Framework Pattern for API Tests
- Debugging API Tests in CI
- Common Pitfalls I See in Teams
- Key Takeaways
- FAQ
Contents
Why Playwright API Testing Belongs in Your Suite
Playwright API Testing is the fastest way to stop treating every check like a browser journey. I see teams open a page, click through four screens, submit a form, and call that an end-to-end test. That works for a few happy paths, but it becomes painful when the suite grows.
On Day 8 of this 21-day Playwright + TypeScript series, we move from browser-only tests to API-first thinking. Use the browser when the user experience matters, and use API calls when you need speed, setup, cleanup, or server-side validation.
The official Playwright API testing guide says Playwright can access your application’s REST API directly from Node.js, without loading a browser page. It lists three common use cases: test the server API, prepare server-side state before visiting the app, and validate server-side post-conditions after UI actions. That matches what I see in real test automation frameworks.
Here is the practical reason this matters. A UI test pays for browser startup, navigation, rendering, network calls, selector waits, animation timing, and DOM assertions. An API test skips most of that. If you need to create a user, create an order, seed a cart, or confirm a record exists, the API path is usually cleaner.
This does not mean API tests replace UI tests. It means you stop using the browser as a database setup tool.
Where this fits in the series
By now, you already have the base skills:
- Day 1 covered Playwright TypeScript setup.
- Day 2 covered locators and assertions.
- Day 5 covered Page Object Model structure.
- Day 7 covered Trace Viewer debugging.
Today, we connect those skills to API testing. This is where your framework starts looking like a real SDET framework, not a folder full of browser scripts.
The data point worth knowing
Playwright is not a small side tool anymore. The GitHub API showed 91,030 stars for microsoft/playwright, and the npm API showed 154,035,508 downloads for @playwright/test in the last-month window ending 2026-06-14. If you are preparing for SDET interviews in India, product companies expect Playwright knowledge beyond basic clicks.
The Request Fixture: Your First API Test
The easiest entry point for Playwright API Testing is the built-in request fixture. It is available inside a normal Playwright test. You do not need Axios, SuperTest, Postman exports, or a separate runner for basic REST checks.
Let us test a simple public API style flow. In your real project, replace the base URL and endpoints with your application.
import { test, expect } from '@playwright/test';
test('GET health endpoint returns ok', async ({ request }) => {
const response = await request.get('/health');
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.status).toBe('ok');
});
This looks almost too small, but it teaches the key pieces:
request.get()sends the HTTP request.response.ok()checks the status is in the 2xx range.response.status()gives the exact status code.response.json()parses the JSON response body.
Test POST requests with JSON bodies
Most real API tests are not health checks. You create something, read it back, and assert the contract. Here is a clean TypeScript example.
import { test, expect } from '@playwright/test';
test('create a customer through API', async ({ request }) => {
const createResponse = await request.post('/customers', {
data: {
name: 'Asha QA',
email: `asha.qa.${Date.now()}@example.com`,
plan: 'starter'
}
});
expect(createResponse.status()).toBe(201);
const created = await createResponse.json();
expect(created.id).toBeTruthy();
expect(created.email).toContain('@example.com');
const getResponse = await request.get(`/customers/${created.id}`);
expect(getResponse.ok()).toBeTruthy();
const fetched = await getResponse.json();
expect(fetched.name).toBe('Asha QA');
expect(fetched.plan).toBe('starter');
});
I prefer this create-and-read pattern for beginner API tests because it catches two classes of bugs. First, it verifies the POST endpoint accepts the payload. Second, it verifies the stored state is visible through the GET endpoint. That is more useful than asserting only the POST status code.
Screenshot description
If you are documenting this for your team, capture a screenshot of the terminal after running npx playwright test tests/api/customers.spec.ts. The screenshot should show the API spec file passing without opening a browser window. Add a second screenshot from the HTML report showing the request test title, duration, and assertion stack. This helps manual testers understand that Playwright is not only a UI automation tool.
Configuration, Headers, and Authentication
Hardcoding full URLs and tokens in every test is a bad habit. Playwright lets you define a baseURL and common headers inside playwright.config.ts. The official API testing docs show this pattern for GitHub API tests with baseURL and extraHTTPHeaders.
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
baseURL: process.env.API_BASE_URL ?? 'https://api.example.test',
extraHTTPHeaders: {
Accept: 'application/json',
'X-Test-Client': 'playwright-day-8'
}
}
});
Now your tests can call /customers instead of https://api.example.test/customers. That sounds minor, but it keeps your tests portable across local, QA, staging, and pre-prod environments.
Use environment variables for secrets
Do not commit tokens in the repository. Use environment variables in local runs and CI secrets in pipelines.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: process.env.API_BASE_URL,
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
Accept: 'application/json'
}
}
});
Run it locally like this:
API_BASE_URL=https://api.example.test \
API_TOKEN=replace-with-local-token \
npx playwright test tests/api
In GitHub Actions, store API_TOKEN as a secret. In Jenkins, store it as a credential. In Azure DevOps, use a secret variable. The tool name changes, but the principle stays the same.
Authentication through API login
A common pattern is to log in with API, save storage state, and reuse that state in browser tests. Playwright’s docs state that storage state is interchangeable between BrowserContext and APIRequestContext. That is powerful when the UI login is slow or protected by CAPTCHA in lower environments.
import { request, expect } from '@playwright/test';
async function globalSetup() {
const api = await request.newContext({
baseURL: process.env.API_BASE_URL
});
const login = await api.post('/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD
}
});
expect(login.ok()).toBeTruthy();
await api.storageState({ path: 'playwright/.auth/user.json' });
await api.dispose();
}
export default globalSetup;
Then wire it in config:
import { defineConfig } from '@playwright/test';
export default defineConfig({
globalSetup: './global-setup.ts',
use: {
storageState: 'playwright/.auth/user.json'
}
});
This is one of the first patterns I teach to manual testers moving into automation. It reduces noisy UI login steps and makes the actual test focus on the feature.
Mixing API Setup with UI Assertions
The strongest use of Playwright API Testing is not separate API suites. It is API-assisted UI testing. You create clean state through API, then verify the user experience through the browser.
Example: you want to test whether a newly created invoice appears in the UI. The slow path is to click through customer creation, product selection, tax setup, invoice creation, and final listing. The better path is to create the invoice through API, then open the invoice list and assert the UI renders it correctly.
import { test, expect } from '@playwright/test';
test('invoice created through API appears in UI', async ({ request, page }) => {
const invoiceNumber = `INV-${Date.now()}`;
const createInvoice = await request.post('/invoices', {
data: {
customerName: 'Demo Retail India',
invoiceNumber,
amount: 2499,
currency: 'INR',
status: 'DRAFT'
}
});
expect(createInvoice.status()).toBe(201);
await page.goto('/invoices');
await expect(page.getByRole('cell', { name: invoiceNumber })).toBeVisible();
await expect(page.getByRole('cell', { name: 'Demo Retail India' })).toBeVisible();
await expect(page.getByText('₹2,499')).toBeVisible();
});
Why this pattern is better
This test still checks the user-facing UI, but it avoids setup screens that are not part of the risk for this scenario.
Use this split when:
- The setup journey is already covered by another UI test.
- The scenario needs a very specific server-side state.
- The browser path would make the test slow or flaky.
- You need to create multiple records quickly.
- You want deterministic test data for visual or accessibility checks.
Validate post-conditions after UI actions
The reverse pattern is also useful. Perform a user action in the browser, then verify the server state through API.
test('user can archive an invoice from UI', async ({ request, page }) => {
const invoice = await request.post('/invoices', {
data: { customerName: 'Archive Test', amount: 999, status: 'DRAFT' }
});
const created = await invoice.json();
await page.goto(`/invoices/${created.id}`);
await page.getByRole('button', { name: 'Archive' }).click();
await page.getByRole('button', { name: 'Confirm archive' }).click();
const serverState = await request.get(`/invoices/${created.id}`);
const body = await serverState.json();
expect(body.status).toBe('ARCHIVED');
});
This catches bugs where the UI shows success but the backend did not persist the change. I have seen this bug in payment, order, and CRM flows. The toast says “saved,” but the API state says otherwise.
Mocking and Modifying Network Responses
API testing is not only about calling endpoints directly. Playwright can also mock and modify network traffic made by the page. The official Mock APIs guide states that Playwright can mock and modify HTTP and HTTPS traffic, including XHR and fetch requests.
This helps when you need to test rare states that are hard to create in the backend. Think empty dashboard, expired subscription, delayed shipment, failed payment, 500 error, or partial outage.
import { test, expect } from '@playwright/test';
test('dashboard shows empty state when API returns no projects', async ({ page }) => {
await page.route('**/api/projects', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ projects: [] })
});
});
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'No projects yet' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Create project' })).toBeVisible();
});
The key detail is order. Register the route before page.goto(). If you register it after navigation, the request may already be gone.
Modify a real response
Sometimes a full mock is too fake. You want the real response, but with one field changed. Playwright supports route.fetch() followed by route.fulfill() with a patched body.
test('billing banner appears for overdue account', async ({ page }) => {
await page.route('**/api/account', async route => {
const response = await route.fetch();
const account = await response.json();
await route.fulfill({
response,
json: {
...account,
billingStatus: 'OVERDUE',
daysOverdue: 12
}
});
});
await page.goto('/settings/billing');
await expect(page.getByText('Payment overdue')).toBeVisible();
await expect(page.getByText('12 days')).toBeVisible();
});
This gives you realistic data with controlled risk. I use this when backend test data is messy or shared across teams.
Screenshot description
Capture the Trace Viewer network tab for a mocked test. The screenshot should show the intercepted endpoint and fulfilled response, plus the UI state created by the mock. This makes the value visible to managers who only see pass or fail counts.
A Small Framework Pattern for API Tests
Once your API tests grow, raw request.post() calls in every spec become noisy. Do not overbuild a massive framework on Day 8. Start with small API clients.
Create a typed client
import { APIRequestContext, expect } from '@playwright/test';
export type CustomerPayload = {
name: string;
email: string;
plan: 'starter' | 'pro' | 'enterprise';
};
export class CustomerApi {
constructor(private readonly request: APIRequestContext) {}
async create(payload: CustomerPayload) {
const response = await this.request.post('/customers', { data: payload });
expect(response.status()).toBe(201);
return response.json();
}
async getById(id: string) {
const response = await this.request.get(`/customers/${id}`);
expect(response.ok()).toBeTruthy();
return response.json();
}
async delete(id: string) {
const response = await this.request.delete(`/customers/${id}`);
expect([200, 202, 204]).toContain(response.status());
}
}
Use it in the spec:
import { test, expect } from '@playwright/test';
import { CustomerApi } from '../support/api/customer-api';
test('pro customer can be created', async ({ request }) => {
const customers = new CustomerApi(request);
const created = await customers.create({
name: 'Nisha Product QA',
email: `nisha.${Date.now()}@example.com`,
plan: 'pro'
});
const fetched = await customers.getById(created.id);
expect(fetched.plan).toBe('pro');
await customers.delete(created.id);
});
Why this is enough for now
You get type safety, less duplication, readable tests, and a natural place for cleanup methods. You do not need a custom assertion library, custom runner, custom report generator, and ten layers of abstraction.
For most SDET teams, this folder structure works well:
tests/
api/
customers.spec.ts
invoices.spec.ts
ui/
invoice-list.spec.ts
support/
api/
customer-api.ts
invoice-api.ts
data/
customer-factory.ts
playwright.config.ts
Keep API clients close to the tests. If the product changes, you want the test code to be easy to update.
Clean up test data
API tests create data quickly. That is a strength, but it can become a mess. Add cleanup in afterEach when the system allows deletion.
test.afterEach(async ({ request }, testInfo) => {
const ids = testInfo.annotations
.filter(a => a.type === 'customer-id')
.map(a => a.description)
.filter(Boolean);
for (const id of ids) {
await request.delete(`/customers/${id}`);
}
});
In some regulated systems, deletion is not allowed. Then mark test data clearly with a prefix like AUTO_TEST_ and run a backend cleanup job owned by the QA environment team.
Debugging API Tests in CI
API tests fail differently from UI tests. You may not get a screenshot. You need request details, response status, and body excerpts. Do not print full tokens or private data in CI logs.
Add safe diagnostics
async function expectOk(response: APIResponse, label: string) {
if (!response.ok()) {
const text = await response.text();
throw new Error([
`${label} failed`,
`status=${response.status()}`,
`body=${text.slice(0, 500)}`
].join('\n'));
}
}
Use it like this:
const response = await request.post('/orders', { data: orderPayload });
await expectOk(response, 'create order');
This gives you enough context without dumping huge response bodies.
Run API and UI projects separately
Playwright projects are useful when you want separate commands for API and UI tests.
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'api',
testMatch: /.*\.api\.spec\.ts/
},
{
name: 'chromium-ui',
testMatch: /.*\.ui\.spec\.ts/,
use: { browserName: 'chromium' }
}
]
});
Run only API tests before the UI suite:
npx playwright test --project=api
npx playwright test --project=chromium-ui
In CI, I like API tests as an early gate. If the API contract is broken, running 400 UI tests is wasted compute.
Common Pitfalls I See in Teams
Playwright API Testing is simple, but teams still make avoidable mistakes.
1. Testing implementation instead of behavior
Do not assert every internal field just because the API returns it. Assert the contract that matters to the user or downstream consumer. If you assert 35 fields for a customer response, your test will fail on harmless backend changes.
2. Reusing production tokens
Never run automation with production credentials. Use dedicated lower-environment test users with clear permissions. Rotate tokens. Store them in CI secrets. This is basic, but many teams skip it until a log leak happens.
3. Forgetting isolation
Playwright docs explain that request contexts can either share browser cookies through browserContext.request and page.request, or stay isolated when created through playwright.request.newContext(). Pick intentionally. Cookie sharing is useful for authenticated flows. Isolation is safer for independent API checks.
4. Mocking everything
If every UI test uses mocks, you are not testing the real integration. Use mocks for rare states, negative paths, and deterministic edge cases. Keep a smaller number of real API-backed UI tests for confidence.
5. Skipping contract ownership
API tests need owners. If the backend team changes a field name, who updates the test? If the QA team owns all tests but the backend team owns the contract, create a review rule. Otherwise, API tests become noisy and people stop trusting them.
India SDET interview context
For Indian QA engineers targeting ₹25-40 LPA SDET roles, this topic matters. Interviewers at product companies often ask how you reduce end-to-end flakiness, how you seed data, how you validate backend state, and how you design test layers. A good answer mentions API setup, API cleanup, storage state, network mocking, and a clear split between API and UI coverage.
Key Takeaways: Playwright API Testing
Playwright API Testing gives your TypeScript framework a faster and cleaner test layer. The browser remains important, but it should not do every setup and validation job.
- Use the built-in
requestfixture for REST calls inside Playwright tests. - Move common URLs and headers to
playwright.config.ts. - Use API calls to create state before UI checks.
- Validate backend post-conditions after important UI actions.
- Use
page.route()for rare UI states and negative scenarios. - Create small typed API clients when duplication starts.
- Keep tokens out of code and logs.
Tomorrow, we can build on this by tightening authentication, storage state, and multi-user session patterns. That is where Playwright starts feeling like a production automation stack.
FAQ
Can Playwright replace Postman for API testing?
For automated checks inside a TypeScript test framework, yes, Playwright can cover many REST API testing needs. Postman is still useful for exploration, collections, and team collaboration. I use Playwright when the API check must run as part of CI with UI tests.
Should API tests and UI tests be in the same repository?
If they validate the same product and share test data, keeping them in the same repository is practical. Use separate folders and Playwright projects so you can run them independently.
Do Playwright API tests open a browser?
No. Tests that use only the request fixture do not need to open a browser. That is why they are useful as fast CI gates.
When should I mock API responses?
Mock when the state is rare, expensive, unstable, or hard to create in a lower environment. Do not mock every happy path. You still need real integration coverage.
What is the focus for beginners?
Start with three flows: GET validation, POST plus GET validation, and API setup followed by UI assertion. Once those are stable, add authentication state and network mocks.
Sources: Playwright API testing docs, Playwright Mock APIs docs, Playwright Network docs, GitHub API for microsoft/playwright, and npm downloads API for @playwright/test.
