Playwright, when you think about it, is fundamentally a browser automation tool, so using it for API testing feels a bit like using a sledgehammer to crack a nut.

Let’s see it in action. Imagine we have a simple API endpoint at http://localhost:3000/users that returns a JSON array of user objects.

// Example API endpoint handler (Node.js with Express)
const express = require('express');
const app = express();
const port = 3000;

app.use(express.json());

let users = [
  { id: 1, name: 'Alice', email: 'alice@example.com' },
  { id: 2, name: 'Bob', email: 'bob@example.com' },
];

app.get('/users', (req, res) => {
  res.json(users);
});

app.post('/users', (req, res) => {
  const newUser = { id: users.length + 1, ...req.body };
  users.push(newUser);
  res.status(201).json(newUser);
});

app.listen(port, () => {
  console.log(`User API listening at http://localhost:${port}`);
});

Now, here’s how you’d test this using Playwright’s request context. This allows you to make HTTP requests directly, bypassing the browser entirely.

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

export default defineConfig({
  testDir: './tests',
  /* Run tests in files in parallel */
  fullyParallel: true,
  /* Fail the build on test failures */
  failOnScan: false,
  /* Retry on CI only */
  // retry: process.env.CI ? 2 : 0,
  /* Shared settings for all the projects */
  // use: {
  //   /* Base URL to use in actions like `await page.goto('/')`. */
  //   // baseURL: 'http://localhost:3000',
  //   /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
  //   trace: 'on-first-retry',
  // },

  /* Configure projects for major browsers */
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});
// tests/api.spec.ts
import { test, expect } from '@playwright/test';

test.describe('User API Tests', () => {
  test('should get all users', async ({ request }) => {
    const response = await request.get('http://localhost:3000/users');
    expect(response.ok()).toBeTruthy();
    const responseBody = await response.json();
    expect(Array.isArray(responseBody)).toBeTruthy();
    expect(responseBody.length).toBeGreaterThan(0);
    expect(responseBody[0]).toHaveProperty('id');
    expect(responseBody[0]).toHaveProperty('name');
  });

  test('should create a new user', async ({ request }) => {
    const newUserPayload = {
      name: 'Charlie',
      email: 'charlie@example.com',
    };
    const response = await request.post('http://localhost:3000/users', {
      data: newUserPayload,
      headers: {
        'Content-Type': 'application/json',
      },
    });
    expect(response.ok()).toBeTruthy();
    expect(response.status()).toBe(201);
    const responseBody = await response.json();
    expect(responseBody).toHaveProperty('id');
    expect(responseBody.name).toBe('Charlie');
    expect(responseBody.email).toBe('charlie@example.com');
  });
});

The request object, injected into your tests, is where the magic happens for API interactions. You can make get, post, put, delete, and other HTTP requests, passing in URLs, request bodies, and headers. The response object you get back has methods like ok() to check for success status codes (2xx), status() for the exact code, and json() to parse the response body.

This approach allows you to set up test data, interact with your backend services directly, and validate responses without the overhead of launching a full browser instance. It’s a clean way to ensure your API endpoints are functioning as expected, handling various request types, and returning correct data.

What most people don’t realize is that Playwright’s request context can also handle authentication, cookies, and even proxy configurations, making it surprisingly robust for complex API testing scenarios. You can configure requestContext globally in playwright.config.ts or create new contexts within individual tests for more granular control. For example, setting up an API key for authenticated requests:

// In a test file:
test('should access a protected endpoint', async ({ request }) => {
  const apiKey = 'your_super_secret_api_key';
  const response = await request.get('http://localhost:3000/protected', {
    headers: {
      'Authorization': `Bearer ${apiKey}`,
    },
  });
  // ... assertions
});

The real power lies in orchestrating these API calls. You can use them to seed your database before a UI test, validate data integrity after a user action, or test edge cases that are difficult to trigger through the UI alone.

You’ll next want to explore how to manage and reuse API request configurations, especially for larger projects.

Want structured learning?

Take the full Playwright course →