API Testing August 5, 2026 16 min read

Playwright API Testing: Complete Guide to REST API Testing (2026)

Playwright isn't just for browser automation. Its built-in APIRequestContext lets you test REST APIs without launching a browser — and the real power is combining API and UI tests in a single framework. This guide covers everything from basic CRUD operations to advanced patterns like API-seeded UI tests, network mocking, and CI/CD integration.

Most teams use separate tools for API testing and UI testing. Postman or REST Assured for APIs, Playwright or Cypress for the browser. That means two frameworks, two assertion libraries, two CI configurations, and two sets of authentication logic. It's unnecessary overhead.

Playwright's APIRequestContext gives you a full HTTP client built into the same framework you use for browser tests. Same TypeScript, same assertions, same config file, same CI pipeline. This guide walks through every pattern you need — with real-world TypeScript examples you can copy into your project today.


Why API Testing in Playwright?

Before diving into code, here's why testing APIs directly in Playwright matters for your workflow:

  • One framework, zero context switching — write API tests and UI tests in the same language, same project, same IDE
  • Shared authentication — set up a bearer token once, use it in both API and browser tests via storageState
  • API-first test data — seed your database through API calls before running UI tests, which is 10–50x faster than creating data through the UI
  • Faster feedback loops — API tests run in milliseconds without a browser. Run them as a smoke suite before your full E2E suite
  • No extra dependencies — no need for Axios, Supertest, or REST Assured. Playwright's HTTP client is built in

The key insight: API tests aren't a replacement for UI tests — they're the fast layer that catches backend regressions in seconds. UI tests then verify the critical user-facing journeys. Together, they form a testing pyramid that's both fast and thorough.


Setting Up API Testing

Playwright's API testing requires no additional packages. If you have @playwright/test installed, you already have everything you need.

Configure baseURL

Set a baseURL in your Playwright config so you don't repeat the full URL in every test. You can create a separate project for API tests that runs without a browser:

playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      name: 'api',
      testDir: './tests/api',
      use: {
        baseURL: 'https://api.example.com',
        extraHTTPHeaders: {
          'Accept': 'application/json',
        },
      },
    },
    {
      name: 'e2e',
      testDir: './tests/e2e',
      use: {
        baseURL: 'https://app.example.com',
        browserName: 'chromium',
      },
    },
  ],
});

The request Fixture

Every Playwright test receives a request fixture — an instance of APIRequestContext. This is your HTTP client. It supports all standard methods: get, post, put, patch, delete, head, and fetch.

Basic API Test
import { test, expect } from '@playwright/test';

test('GET /users returns 200', async ({ request }) => {
  const response = await request.get('/api/users');

  await expect(response).toBeOK();  // asserts 2xx status

  const body = await response.json();
  expect(body.users).toHaveLength(10);
});

Note: The request fixture creates a new APIRequestContext per test, ensuring complete isolation. Cookies and headers do not leak between tests.


CRUD Operations

Let's walk through every HTTP method with practical examples. These tests target a typical REST API for managing a product catalog.

GET — Read Resources

GET Examples
test('GET /products returns paginated list', async ({ request }) => {
  const response = await request.get('/api/products', {
    params: { page: 1, limit: 20, category: 'electronics' },
  });

  await expect(response).toBeOK();

  const { products, total, page } = await response.json();
  expect(products.length).toBeLessThanOrEqual(20);
  expect(page).toBe(1);
  expect(total).toBeGreaterThan(0);
});

test('GET /products/:id returns single product', async ({ request }) => {
  const response = await request.get('/api/products/42');

  await expect(response).toBeOK();

  const product = await response.json();
  expect(product).toMatchObject({
    id: 42,
    name: expect.any(String),
    price: expect.any(Number),
    category: expect.any(String),
  });
});

POST — Create Resources

POST Example
test('POST /products creates a new product', async ({ request }) => {
  const newProduct = {
    name: 'Wireless Keyboard Pro',
    price: 89.99,
    category: 'electronics',
    sku: `SKU-${Date.now()}`,  // unique per test run
  };

  const response = await request.post('/api/products', {
    data: newProduct,
  });

  expect(response.status()).toBe(201);

  const created = await response.json();
  expect(created.id).toBeDefined();
  expect(created.name).toBe('Wireless Keyboard Pro');
  expect(created.price).toBe(89.99);
});

PUT — Full Update

PUT Example
test('PUT /products/:id replaces entire product', async ({ request }) => {
  // First, create a product to update
  const createRes = await request.post('/api/products', {
    data: {
      name: 'Old Name',
      price: 49.99,
      category: 'accessories',
      sku: `SKU-${Date.now()}`,
    },
  });
  const { id } = await createRes.json();

  // PUT replaces the entire resource
  const response = await request.put(`/api/products/${id}`, {
    data: {
      name: 'Updated Keyboard',
      price: 79.99,
      category: 'electronics',
      sku: `SKU-${Date.now()}-updated`,
    },
  });

  expect(response.status()).toBe(200);

  const updated = await response.json();
  expect(updated.name).toBe('Updated Keyboard');
  expect(updated.price).toBe(79.99);
});

PATCH — Partial Update

PATCH Example
test('PATCH /products/:id updates only specified fields', async ({ request }) => {
  const response = await request.patch('/api/products/42', {
    data: {
      price: 59.99,  // only update the price
    },
  });

  expect(response.status()).toBe(200);

  const updated = await response.json();
  expect(updated.price).toBe(59.99);
  expect(updated.name).toBeDefined();  // other fields preserved
});

DELETE — Remove Resources

DELETE Example
test('DELETE /products/:id removes the product', async ({ request }) => {
  // Create, then delete
  const createRes = await request.post('/api/products', {
    data: {
      name: 'Temporary Product',
      price: 9.99,
      category: 'test',
      sku: `SKU-del-${Date.now()}`,
    },
  });
  const { id } = await createRes.json();

  const deleteRes = await request.delete(`/api/products/${id}`);
  expect(deleteRes.status()).toBe(204);

  // Verify it's gone
  const getRes = await request.get(`/api/products/${id}`);
  expect(getRes.status()).toBe(404);
});

Authentication

Most APIs require authentication. Here's how to handle the three most common patterns in Playwright API tests.

Bearer Token Authentication

The most common approach: log in once, extract the token, and pass it in the Authorization header for subsequent requests.

Bearer Token Pattern
import { test, expect } from '@playwright/test';

let token: string;

test.beforeAll(async ({ request }) => {
  const response = await request.post('/api/auth/login', {
    data: {
      email: 'admin@example.com',
      password: process.env.ADMIN_PASSWORD,
    },
  });
  await expect(response).toBeOK();
  const body = await response.json();
  token = body.accessToken;
});

test('authenticated request to protected endpoint', async ({ request }) => {
  const response = await request.get('/api/admin/dashboard', {
    headers: {
      'Authorization': `Bearer ${token}`,
    },
  });

  await expect(response).toBeOK();
});

Cookie-Based Authentication

Playwright's APIRequestContext automatically stores and sends cookies — just like a browser. If your login endpoint sets a session cookie, subsequent requests include it automatically:

Cookie Auth (Automatic)
test('cookie-based auth persists across requests', async ({ request }) => {
  // Login sets a session cookie
  await request.post('/api/auth/login', {
    data: { email: 'user@example.com', password: 'secret' },
  });

  // Cookie is automatically included in this request
  const profile = await request.get('/api/me');
  await expect(profile).toBeOK();

  const data = await profile.json();
  expect(data.email).toBe('user@example.com');
});

Custom Auth Fixture

For cleaner tests, create a custom fixture that provides an authenticated APIRequestContext to every test:

fixtures/auth.ts
import { test as base } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';

type AuthFixtures = {
  authRequest: APIRequestContext;
};

export const test = base.extend<AuthFixtures>({
  authRequest: async ({ playwright }, use) => {
    const context = await playwright.request.newContext({
      baseURL: 'https://api.example.com',
      extraHTTPHeaders: {
        'Authorization': `Bearer ${process.env.API_TOKEN}`,
        'Accept': 'application/json',
      },
    });

    await use(context);
    await context.dispose();
  },
});

export { expect } from '@playwright/test';

Pro tip: Store credentials in environment variables or a .env file, never in test code. Use dotenv or your CI's secret management to inject them at runtime.


Request/Response Validation

Testing that an endpoint returns 200 OK is not enough. You need to validate status codes, headers, response shape, and data types.

Status Code Assertions

Status Code Validation
// Assert exact status code
expect(response.status()).toBe(201);

// Assert any 2xx status
await expect(response).toBeOK();

// Assert error responses
expect(response.status()).toBe(422); // validation error
expect(response.status()).toBe(401); // unauthorized
expect(response.status()).toBe(404); // not found

Header Validation

Header Checks
test('response headers are correct', async ({ request }) => {
  const response = await request.get('/api/products');

  // Check content type
  expect(response.headers()['content-type']).toContain('application/json');

  // Check cache headers
  expect(response.headers()['cache-control']).toBeDefined();

  // Check CORS headers
  expect(response.headers()['access-control-allow-origin']).toBe('*');
});

JSON Body Shape Validation

Use toMatchObject for partial matching and expect.any() to validate types without hardcoding values:

Shape Validation
test('product response matches expected shape', async ({ request }) => {
  const response = await request.get('/api/products/42');
  const product = await response.json();

  expect(product).toMatchObject({
    id: expect.any(Number),
    name: expect.any(String),
    price: expect.any(Number),
    category: expect.any(String),
    createdAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}/),
    inStock: expect.any(Boolean),
  });

  // Verify price is positive
  expect(product.price).toBeGreaterThan(0);
});

Schema Validation with Zod

For rigorous contract testing, pair Playwright with a schema validation library like Zod:

Zod Schema Validation
import { z } from 'zod';

const ProductSchema = z.object({
  id: z.number().positive(),
  name: z.string().min(1),
  price: z.number().positive(),
  category: z.enum(['electronics', 'accessories', 'software']),
  sku: z.string().regex(/^SKU-/),
  inStock: z.boolean(),
  createdAt: z.string().datetime(),
});

const ProductListSchema = z.object({
  products: z.array(ProductSchema),
  total: z.number(),
  page: z.number(),
});

test('GET /products matches API contract', async ({ request }) => {
  const response = await request.get('/api/products');
  const body = await response.json();

  // Zod throws if the shape doesn't match
  const parsed = ProductListSchema.parse(body);
  expect(parsed.products.length).toBeGreaterThan(0);
});

Error Response Validation

Negative Test Cases
test('POST /products with missing fields returns 422', async ({ request }) => {
  const response = await request.post('/api/products', {
    data: { name: 'Incomplete Product' },  // missing price, sku
  });

  expect(response.status()).toBe(422);

  const errors = await response.json();
  expect(errors.errors).toEqual(
    expect.arrayContaining([
      expect.objectContaining({ field: 'price' }),
      expect.objectContaining({ field: 'sku' }),
    ])
  );
});

test('GET /products/999999 returns 404', async ({ request }) => {
  const response = await request.get('/api/products/999999');
  expect(response.status()).toBe(404);
});

test('protected endpoint without auth returns 401', async ({ request }) => {
  const response = await request.get('/api/admin/users');
  expect(response.status()).toBe(401);
});

Combining API + UI Tests

This is where Playwright's API testing becomes truly powerful. Instead of using the UI to create test data (slow, fragile), you use the API to seed data and the browser to verify the user experience.

The Power Pattern: API Seed, UI Verify

API Seed + UI Verification
import { test, expect } from '@playwright/test';

test('new product appears in the catalog UI', async ({ request, page }) => {
  // 1. Seed data via API (fast, reliable)
  const response = await request.post('/api/products', {
    data: {
      name: 'Playwright Test Monitor',
      price: 299.99,
      category: 'electronics',
      sku: `SKU-UI-${Date.now()}`,
    },
  });
  expect(response.status()).toBe(201);

  // 2. Navigate to the UI and verify
  await page.goto('/catalog?category=electronics');

  // 3. Assert the product appears in the browser
  await expect(
    page.getByRole('heading', { name: 'Playwright Test Monitor' })
  ).toBeVisible();

  await expect(
    page.getByText('$299.99')
  ).toBeVisible();
});

Why this pattern wins: Creating a product through the UI means filling forms, clicking buttons, and waiting for navigation — about 5–15 seconds per test. An API call does the same thing in 50–200ms. Multiply by 100 tests and you save 10+ minutes per CI run.

API Cleanup in afterEach

Always clean up test data so tests remain isolated and repeatable:

Cleanup Pattern
let createdIds: number[] = [];

test.afterEach(async ({ request }) => {
  // Delete all products created during the test
  for (const id of createdIds) {
    await request.delete(`/api/products/${id}`);
  }
  createdIds = [];
});

test('create and verify order flow', async ({ request, page }) => {
  const res = await request.post('/api/products', {
    data: { name: 'Test Widget', price: 19.99, sku: `SKU-${Date.now()}` },
  });
  const { id } = await res.json();
  createdIds.push(id);  // track for cleanup

  // ... UI assertions ...
});

Sharing Auth Between API and Browser

Shared Auth Context
test('admin can see user management page', async ({ request, page }) => {
  // Get auth token via API
  const loginRes = await request.post('/api/auth/login', {
    data: { email: 'admin@test.com', password: 'admin123' },
  });
  const { token } = await loginRes.json();

  // Set token in browser context via cookie or localStorage
  await page.goto('/');
  await page.evaluate((t) => {
    localStorage.setItem('auth_token', t);
  }, token);

  // Navigate to protected page
  await page.goto('/admin/users');

  await expect(
    page.getByRole('heading', { name: 'User Management' })
  ).toBeVisible();
});

Network Mocking

While API tests hit real endpoints, UI tests often need to mock API responses — to test error states, empty results, or slow connections without depending on backend behavior.

Mock a GET Response

route.fulfill()
test('shows empty state when no products exist', async ({ page }) => {
  // Intercept the API call and return an empty list
  await page.route('**/api/products*', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        products: [],
        total: 0,
        page: 1,
      }),
    });
  });

  await page.goto('/catalog');

  await expect(
    page.getByText('No products found')
  ).toBeVisible();
});

Simulate Server Errors

Error Simulation
test('shows error message on 500 response', async ({ page }) => {
  await page.route('**/api/products*', async (route) => {
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({
        error: 'Internal Server Error',
      }),
    });
  });

  await page.goto('/catalog');

  await expect(
    page.getByText('Something went wrong')
  ).toBeVisible();

  // Verify retry button is present
  await expect(
    page.getByRole('button', { name: 'Try Again' })
  ).toBeVisible();
});

Modify Real Responses

Sometimes you want to use the real API response but modify a specific field:

Response Modification
test('shows sale badge when product is discounted', async ({ page }) => {
  await page.route('**/api/products/42', async (route) => {
    // Fetch the real response
    const response = await route.fetch();
    const body = await response.json();

    // Modify the discount field
    body.discount = 25;
    body.originalPrice = body.price;
    body.price = body.price * 0.75;

    await route.fulfill({
      response,
      body: JSON.stringify(body),
    });
  });

  await page.goto('/products/42');

  await expect(page.getByText('25% OFF')).toBeVisible();
});

Abort Requests

route.abort()
test('page loads without analytics scripts', async ({ page }) => {
  // Block analytics and tracking to speed up tests
  await page.route('**/*', async (route) => {
    const url = route.request().url();
    if (
      url.includes('google-analytics') ||
      url.includes('hotjar') ||
      url.includes('segment.io')
    ) {
      await route.abort();
    } else {
      await route.continue();
    }
  });

  await page.goto('/catalog');
  // Page loads without third-party tracking
});

Important: page.route() only works in browser-based tests (those using the page fixture). For standalone API tests using the request fixture, you hit real endpoints. Use environment-specific baseURL values to point API tests at staging servers.


Parallel API Tests

API tests are lightweight — no browser, no rendering, no DOM. This makes them ideal for aggressive parallelization.

Configure Parallel Execution

playwright.config.ts
export default defineConfig({
  projects: [
    {
      name: 'api',
      testDir: './tests/api',
      fullyParallel: true,  // every test runs independently
      workers: 8,            // more workers since no browser overhead
      retries: 1,           // one retry for network flakiness
      use: {
        baseURL: 'https://api.staging.example.com',
      },
    },
  ],
});

Test Isolation Rules

Parallel API tests require the same isolation discipline as parallel UI tests (see our Playwright best practices guide for more):

  • Unique identifiers — use Date.now() or crypto.randomUUID() in SKUs, emails, and names so parallel tests never create conflicting data
  • Own cleanup — each test deletes the data it creates in afterEach
  • No shared state — don't rely on a specific product existing in the database. Create what you need, test it, delete it
  • Idempotent tests — running the same test twice should produce the same result regardless of database state
Parallel-Safe Test
test('create, update, and delete a product', async ({ request }) => {
  const uniqueId = crypto.randomUUID();

  // Create
  const createRes = await request.post('/api/products', {
    data: {
      name: `Test Product ${uniqueId}`,
      price: 49.99,
      sku: `SKU-${uniqueId}`,
    },
  });
  const { id } = await createRes.json();

  // Update
  const updateRes = await request.patch(`/api/products/${id}`, {
    data: { price: 39.99 },
  });
  expect(updateRes.status()).toBe(200);

  // Delete
  const deleteRes = await request.delete(`/api/products/${id}`);
  expect(deleteRes.status()).toBe(204);
});

CI/CD Integration

API tests are the fastest layer in your test pyramid. Run them first in CI — if APIs are broken, there's no point running slow browser tests. For a full pipeline walkthrough, see our Playwright + GitHub Actions CI/CD guide.

GitHub Actions Pipeline

.github/workflows/test.yml
name: Test Pipeline
on: [push, pull_request]

jobs:
  api-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright test --project=api
        env:
          API_TOKEN: ${{ secrets.API_TOKEN }}

  e2e-tests:
    needs: api-tests  # only run if API tests pass
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test --project=e2e --shard=${{ matrix.shard }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: report-${{ matrix.shard }}
          path: playwright-report/

Notice the needs: api-tests dependency. If the API smoke suite fails in 15 seconds, the pipeline stops before spending 5 minutes launching browsers and running E2E tests. This saves CI minutes and gives faster feedback.

Project Dependencies in Config

You can also express this dependency directly in playwright.config.ts:

Project Dependencies
export default defineConfig({
  projects: [
    {
      name: 'api-smoke',
      testDir: './tests/api',
      testMatch: '**/*.smoke.spec.ts',
    },
    {
      name: 'e2e-chromium',
      testDir: './tests/e2e',
      dependencies: ['api-smoke'],  // waits for api-smoke to pass
      use: { browserName: 'chromium' },
    },
  ],
});

Quick win: Create a small health.smoke.spec.ts file with 3–5 API tests that verify your critical endpoints return 200. Run these as a gate before every E2E suite. This catches deployment issues, database outages, and environment misconfigurations before you waste CI time on browser tests.


API Testing Checklist

Every API Test Should

  • Assert status code explicitly
  • Validate response body shape
  • Use unique identifiers per test
  • Clean up created data in afterEach
  • Not depend on other tests' data
  • Handle auth via fixtures, not inline
  • Test both success and error paths
  • Run in parallel without flaking

Your API Test Suite Should

  • Run as a separate Playwright project
  • Execute before E2E tests in CI
  • Use environment-specific baseURL
  • Store credentials in env variables
  • Include schema validation (Zod)
  • Cover all CRUD operations
  • Test authentication edge cases
  • Complete in under 30 seconds

Frequently Asked Questions

Can Playwright be used for API testing?

Yes. Playwright has a built-in APIRequestContext that sends HTTP requests without launching a browser. Use the request fixture in any test to make GET, POST, PUT, PATCH, and DELETE calls. You can test REST APIs and UI in the same framework, sharing auth, assertions, and CI configuration.

How do I set up API testing in Playwright with TypeScript?

Install @playwright/test, configure a baseURL in playwright.config.ts, then use the built-in request fixture: test('name', async ({ request }) => { ... }). No additional packages needed. Call request.get(), request.post(), etc. against your endpoints.

What is the difference between request fixture and page.request?

The request fixture creates standalone HTTP requests without a browser. page.request sends HTTP requests within the browser's context, sharing its cookies and auth state. Use request for pure API tests, page.request when you need API calls that share the browser session's authentication.

Should I use Playwright or Postman for API testing?

If you already use Playwright for UI testing, use it for API testing too. One framework, one CI pipeline, one assertion library, shared auth. Postman is better for ad-hoc exploration and API documentation, but Playwright is superior for automated API test suites in CI/CD.

How do I mock API responses in Playwright?

Use page.route() to intercept requests and return mock responses with route.fulfill(). You can mock specific endpoints, modify response bodies, simulate error codes, or abort requests with route.abort(). This works in browser-based tests using the page fixture.


Asim Noaman - Playwright and Claude AI course instructor

Asim Noaman

Senior QA Automation Engineer & AI Testing Specialist

With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.

Udemy Instructor Published course author
Playwright + AI Expert Specialized in AI-powered QA
Production Experience Enterprise-grade frameworks
Connect on LinkedIn