Playwright API Testing: Test REST Endpoints Without a Browser

Playwright API Testing: Test REST Endpoints Without a Browser

Playwright is famous for browser automation, but it ships with a capable HTTP client that lets you test REST APIs directly — no browser required. This makes it a powerful all-in-one tool: write your UI tests and API tests in the same framework, share authentication, and chain API calls with browser actions.

Why Use Playwright for API Testing?

Most teams use separate tools for API and UI testing — Postman or Newman for APIs, Playwright or Cypress for the browser. Consolidating in Playwright has real advantages:

  • One framework, one config — less tooling to maintain
  • Shared auth state — log in via API, reuse session in UI tests
  • API seeding — create test data via API before a UI test runs
  • Faster API tests — no browser overhead, pure HTTP
  • Same assertion library — consistent expect() syntax everywhere

The request Fixture

Playwright provides a request fixture that gives you a full HTTP client:

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

test('GET /users returns 200', async ({ request }) => {
  const response = await request.get('https://api.example.com/users');
  expect(response.status()).toBe(200);
});

The fixture manages cookies and headers automatically within a test session.

Making API Requests

GET Request

test('fetch user profile', async ({ request }) => {
  const response = await request.get('/api/users/123');
  
  expect(response.ok()).toBeTruthy();
  
  const body = await response.json();
  expect(body.id).toBe(123);
  expect(body.email).toBeDefined();
});

POST Request

test('create a new post', async ({ request }) => {
  const response = await request.post('/api/posts', {
    data: {
      title: 'My Post',
      content: 'Hello world',
      published: false,
    },
  });

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

  const post = await response.json();
  expect(post.id).toBeDefined();
  expect(post.title).toBe('My Post');
});

PUT and DELETE

test('update a post', async ({ request }) => {
  const response = await request.put('/api/posts/42', {
    data: { title: 'Updated Title' },
  });
  expect(response.status()).toBe(200);
});

test('delete a post', async ({ request }) => {
  const response = await request.delete('/api/posts/42');
  expect(response.status()).toBe(204);
});

Setting Base URL

Define a base URL in playwright.config.ts so you don't repeat it in every test:

// playwright.config.ts
export default {
  use: {
    baseURL: 'https://api.example.com',
  },
};

Now all requests are relative:

const response = await request.get('/users'); // → https://api.example.com/users

Authentication

Bearer Token

test('authenticated endpoint', async ({ request }) => {
  const response = await request.get('/api/profile', {
    headers: {
      Authorization: 'Bearer your-token-here',
    },
  });
  expect(response.ok()).toBeTruthy();
});

Login via API and Reuse Session

A common pattern: log in via API in a beforeAll hook, extract the token, and reuse it across tests.

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

let authToken: string;

test.beforeAll(async ({ request }) => {
  const response = await request.post('/api/auth/login', {
    data: {
      email: 'test@example.com',
      password: 'password123',
    },
  });
  
  expect(response.ok()).toBeTruthy();
  const body = await response.json();
  authToken = body.token;
});

test('get protected resource', async ({ request }) => {
  const response = await request.get('/api/protected', {
    headers: { Authorization: `Bearer ${authToken}` },
  });
  expect(response.status()).toBe(200);
});

If your API uses session cookies, the request fixture handles them automatically within a test. To persist cookies across tests, use storageState:

// auth.setup.ts
import { test as setup } from '@playwright/test';

setup('api auth', async ({ request }) => {
  await request.post('/api/login', {
    data: { email: 'user@example.com', password: 'password' },
  });
  await request.storageState({ path: '.auth/api-cookies.json' });
});

Then reference the storage state in your test project config.

Response Assertions

Playwright's expect works on response objects:

const response = await request.get('/api/users');

// Status code
expect(response.status()).toBe(200);
expect(response.ok()).toBeTruthy(); // status 200-299

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

// Body
const body = await response.json();
expect(body).toHaveLength(10);
expect(body[0]).toMatchObject({
  id: expect.any(Number),
  email: expect.stringContaining('@'),
});

Using Fixtures for Reusable API Clients

For larger test suites, create a custom fixture that wraps your API:

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

type ApiClient = {
  createUser: (data: object) => Promise<any>;
  deleteUser: (id: number) => Promise<void>;
};

export const test = base.extend<{ api: ApiClient }>({
  api: async ({ request }, use) => {
    const api: ApiClient = {
      async createUser(data) {
        const res = await request.post('/api/users', { data });
        return res.json();
      },
      async deleteUser(id) {
        await request.delete(`/api/users/${id}`);
      },
    };
    await use(api);
  },
});

Use it in tests:

import { test } from './fixtures';
import { expect } from '@playwright/test';

test('create and delete user', async ({ api }) => {
  const user = await api.createUser({ name: 'Alice', email: 'alice@example.com' });
  expect(user.id).toBeDefined();

  await api.deleteUser(user.id);
});

Combining API and UI Tests

This is where Playwright really shines. Use the API to set up state, then verify it in the browser — or vice versa.

Create data via API, verify in UI:

test('created post appears in dashboard', async ({ page, request }) => {
  // Seed data via API
  const { id } = await request.post('/api/posts', {
    data: { title: 'Test Post', published: true },
  }).then(r => r.json());

  // Verify in browser
  await page.goto('/dashboard');
  await expect(page.getByText('Test Post')).toBeVisible();

  // Cleanup via API
  await request.delete(`/api/posts/${id}`);
});

Perform action in UI, verify via API:

test('deleting post removes it from API', async ({ page, request }) => {
  await page.goto('/posts/42');
  await page.getByRole('button', { name: 'Delete' }).click();
  await page.getByRole('button', { name: 'Confirm' }).click();

  // Verify via API
  const response = await request.get('/api/posts/42');
  expect(response.status()).toBe(404);
});

Testing Error Responses

Don't only test happy paths — validate error handling too:

test('returns 404 for missing resource', async ({ request }) => {
  const response = await request.get('/api/users/99999');
  expect(response.status()).toBe(404);
  
  const body = await response.json();
  expect(body.message).toBe('User not found');
});

test('returns 422 for invalid data', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: { email: 'not-an-email' },
  });
  expect(response.status()).toBe(422);
  
  const body = await response.json();
  expect(body.errors).toHaveProperty('email');
});

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

Running API Tests Without a Browser

API tests don't need a browser. You can configure a separate Playwright project for them:

// playwright.config.ts
export default {
  projects: [
    {
      name: 'api',
      testMatch: /api\/.*.spec.ts/,
      use: {
        baseURL: 'https://api.example.com',
      },
    },
    {
      name: 'ui',
      testMatch: /ui\/.*.spec.ts/,
      use: {
        browserName: 'chromium',
        baseURL: 'https://app.example.com',
      },
    },
  ],
};

Run only API tests:

npx playwright test --project=api

API tests run in seconds since no browser is launched.

Tips for Production API Tests

Use environment variables for credentials:

const token = process.env.API_TOKEN;

Isolate test data — create resources in beforeEach, delete in afterEach:

let userId: number;

test.beforeEach(async ({ request }) => {
  const res = await request.post('/api/users', { data: testUser });
  userId = (await res.json()).id;
});

test.afterEach(async ({ request }) => {
  await request.delete(`/api/users/${userId}`);
});

Parallel-safe tests — ensure tests don't share mutable state. Use unique identifiers per test run:

const unique = `test-${Date.now()}`;
await request.post('/api/items', { data: { name: unique } });

What to Test Next

Once your API tests are in shape:

  • Contract testing — validate your API against an OpenAPI spec
  • Load testing — use k6 for performance scenarios
  • Mutation testing — verify that your API correctly rejects bad inputs

For continuous API monitoring — running your Playwright API tests on a schedule and alerting you when endpoints fail — HelpMeTest runs your tests every 5 minutes and sends alerts on failure. No infrastructure to manage.

Summary

Playwright's request fixture gives you a full-featured HTTP client inside your existing test suite:

  • Use request.get/post/put/delete for direct API calls
  • Set baseURL in config to avoid repetition
  • Store auth tokens in beforeAll or use storageState for cookie-based auth
  • Combine API setup with UI verification for powerful integration tests
  • Separate API and UI test projects for faster CI runs

Read more